Files
Jay Brown 17fc813823 Merged in feature/mutable-metadata1 (pull request #221)
M1, M2 and M3 complete

* M1, M2 and M3 complete

* review changes

* docs

* docs
2026-04-16 23:11:26 +00:00

265 lines
10 KiB
Go

// Package queryapi: handler implementations for the CustomMetadataService
// routes (POST /custom-metadata, GET /custom-metadata,
// GET /custom-metadata/version, GET /custom-metadata/history) plus the
// super-admin PATCH /super-admin/documents/{id}/schema route.
//
// Every mutating handler derives the actor from the JWT sub claim via
// cognitoauth.GetUserSubject and never reads a createdBy field from the
// request body — the generated request types do not even declare one.
package queryapi
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"queryorchestration/internal/cognitoauth"
"queryorchestration/internal/customschema"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/oapi-codegen/nullable"
openapi_types "github.com/oapi-codegen/runtime/types"
)
// AssignDocumentSchema is PATCH /super-admin/documents/{id}/schema. The
// customSchemaId field may be null (clear the binding) or a UUID (bind).
func (s *Controllers) AssignDocumentSchema(ctx echo.Context, id openapi_types.UUID) error {
actor, ok := cognitoauth.GetUserSubject(ctx)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
var req DocumentSchemaAssignRequest
if err := ctx.Bind(&req); err != nil {
slog.Error("failed to parse assign schema request", "error", err)
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
// CustomSchemaId is a Nullable[UUID]: the caller can omit it
// (unspecified), explicitly set it to null (clear the binding), or
// provide a UUID (bind). Unspecified is treated as a 400 because the
// admin must make an explicit choice.
var schemaID *uuid.UUID
switch {
case req.CustomSchemaId.IsSpecified() && !req.CustomSchemaId.IsNull():
sid, err := req.CustomSchemaId.Get()
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid customSchemaId")
}
u := uuid.UUID(sid)
schemaID = &u
case req.CustomSchemaId.IsSpecified() && req.CustomSchemaId.IsNull():
// Explicit null → clear binding. schemaID stays nil.
default:
return echo.NewHTTPError(http.StatusBadRequest, "customSchemaId is required (use null to clear)")
}
result, err := s.svc.CustomSchema.AssignSchema(ctx.Request().Context(), uuid.UUID(id), schemaID, actor)
if err != nil {
return mapCustomMetadataError(err)
}
resp := DocumentSchemaAssignResponse{
DocumentId: openapi_types.UUID(result.DocumentID),
}
if result.CustomSchemaID != nil {
resp.CustomSchemaId = nullable.NewNullableWithValue(openapi_types.UUID(*result.CustomSchemaID))
} else {
resp.CustomSchemaId = nullable.NewNullNullable[openapi_types.UUID]()
}
if result.SchemaName != nil {
resp.SchemaName = nullable.NewNullableWithValue(*result.SchemaName)
} else {
resp.SchemaName = nullable.NewNullNullable[string]()
}
if result.SchemaVersion != nil {
v := int32(*result.SchemaVersion) //nolint:gosec // per-lineage monotonic counter
resp.SchemaVersion = nullable.NewNullableWithValue(v)
} else {
resp.SchemaVersion = nullable.NewNullNullable[int32]()
}
return ctx.JSON(http.StatusOK, resp)
}
// SetCustomMetadata is POST /custom-metadata.
func (s *Controllers) SetCustomMetadata(ctx echo.Context) error {
actor, ok := cognitoauth.GetUserSubject(ctx)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
var req CustomMetadataRequest
if err := ctx.Bind(&req); err != nil {
slog.Error("failed to parse custom metadata request", "error", err)
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
metadataBytes, err := json.Marshal(req.Metadata)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid metadata JSON")
}
input := customschema.SetMetadataInput{
DocumentID: uuid.UUID(req.DocumentId),
Metadata: metadataBytes,
}
got, err := s.svc.CustomSchema.SetDocumentMetadata(ctx.Request().Context(), input, actor)
if err != nil {
return mapCustomMetadataError(err)
}
resp, err := metadataToResponse(got)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode metadata response")
}
return ctx.JSON(http.StatusCreated, resp)
}
// GetCurrentCustomMetadata is GET /custom-metadata?documentId=....
func (s *Controllers) GetCurrentCustomMetadata(ctx echo.Context, params GetCurrentCustomMetadataParams) error {
if _, ok := cognitoauth.GetUserSubject(ctx); !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
got, err := s.svc.CustomSchema.GetCurrentMetadata(ctx.Request().Context(), uuid.UUID(params.DocumentId))
if err != nil {
return mapCustomMetadataError(err)
}
resp, err := metadataToResponse(got)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode metadata response")
}
return ctx.JSON(http.StatusOK, resp)
}
// GetCustomMetadataByVersion is GET /custom-metadata/version?documentId=...&version=N.
func (s *Controllers) GetCustomMetadataByVersion(ctx echo.Context, params GetCustomMetadataByVersionParams) error {
if _, ok := cognitoauth.GetUserSubject(ctx); !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
got, err := s.svc.CustomSchema.GetMetadataByVersion(ctx.Request().Context(), uuid.UUID(params.DocumentId), int(params.Version))
if err != nil {
return mapCustomMetadataError(err)
}
resp, err := metadataToResponse(got)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode metadata response")
}
return ctx.JSON(http.StatusOK, resp)
}
// customMetadataHistoryRow is a type alias onto the anonymous element
// type inside the generated CustomMetadataHistoryResponse.Versions slice.
// Structural identity with the generated type lets us append directly.
type customMetadataHistoryRow = struct {
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy"`
Version int32 `json:"version"`
}
// GetCustomMetadataHistory is GET /custom-metadata/history?documentId=...&limit=N&offset=M.
func (s *Controllers) GetCustomMetadataHistory(ctx echo.Context, params GetCustomMetadataHistoryParams) error {
if _, ok := cognitoauth.GetUserSubject(ctx); !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
limit := 50
if params.Limit != nil {
limit = int(*params.Limit)
}
offset := 0
if params.Offset != nil {
offset = int(*params.Offset)
}
got, err := s.svc.CustomSchema.GetMetadataHistory(ctx.Request().Context(), uuid.UUID(params.DocumentId), limit, offset)
if err != nil {
return mapCustomMetadataError(err)
}
out := CustomMetadataHistoryResponse{
Versions: make([]customMetadataHistoryRow, 0, len(got)),
}
for _, v := range got {
out.Versions = append(out.Versions, customMetadataHistoryRow{
Version: int32(v.Version), //nolint:gosec // metadata version is a per-doc monotonic counter, far below int32 ceiling
CreatedAt: v.CreatedAt,
CreatedBy: v.CreatedBy,
})
}
return ctx.JSON(http.StatusOK, out)
}
// mapCustomMetadataError translates a customschema.Service error coming out
// of the metadata/assign-schema paths into an echo HTTP error with the
// correct status code.
func mapCustomMetadataError(err error) error {
switch {
case errors.Is(err, customschema.ErrDocumentNotFound):
return echo.NewHTTPError(http.StatusNotFound, "document not found")
case errors.Is(err, customschema.ErrMetadataVersionNotFound):
return echo.NewHTTPError(http.StatusNotFound, "metadata version not found")
case errors.Is(err, customschema.ErrDocumentNotBoundToSchema):
return echo.NewHTTPError(http.StatusBadRequest, "document is not bound to a custom schema")
case errors.Is(err, customschema.ErrMetadataPayloadTooLarge):
return echo.NewHTTPError(http.StatusBadRequest, "metadata payload exceeds maximum size")
case errors.Is(err, customschema.ErrSchemaNotFound):
return echo.NewHTTPError(http.StatusNotFound, "schema not found")
case errors.Is(err, customschema.ErrSchemaNotActive):
return echo.NewHTTPError(http.StatusConflict, "schema is not active")
case errors.Is(err, customschema.ErrSchemaClientMismatch):
return echo.NewHTTPError(http.StatusConflict, "schema and document belong to different clients")
case errors.Is(err, customschema.ErrDocumentHasCustomMetadata):
return echo.NewHTTPError(http.StatusConflict, "document already has custom metadata")
case errors.Is(err, customschema.ErrDocumentHasLegacyExtractions):
return echo.NewHTTPError(http.StatusConflict, "document has legacy field extractions")
}
// Validator failures wrap "validate metadata". The history service
// method surfaces limit/offset errors as plain strings.
msg := err.Error()
if strings.Contains(msg, "validate metadata") ||
strings.Contains(msg, "limit must be") ||
strings.Contains(msg, "offset must be") ||
strings.Contains(msg, "document id is required") ||
strings.Contains(msg, "version must be") {
return echo.NewHTTPError(http.StatusBadRequest, msg)
}
slog.Error("custom metadata handler internal error", "error", err)
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
}
// metadataToResponse copies a customschema.CustomMetadata into the
// generated CustomMetadataResponse.
func metadataToResponse(m *customschema.CustomMetadata) (CustomMetadataResponse, error) {
var metaMap map[string]interface{}
if len(m.Metadata) > 0 {
if err := json.Unmarshal(m.Metadata, &metaMap); err != nil {
return CustomMetadataResponse{}, err
}
}
resp := CustomMetadataResponse{
Id: openapi_types.UUID(m.ID),
DocumentId: openapi_types.UUID(m.DocumentID),
Metadata: metaMap,
Version: int32(m.Version), //nolint:gosec // per-doc monotonic counter
CreatedAt: m.CreatedAt,
CreatedBy: m.CreatedBy,
}
if m.SchemaID != nil {
resp.SchemaId = nullable.NewNullableWithValue(openapi_types.UUID(*m.SchemaID))
} else {
resp.SchemaId = nullable.NewNullNullable[openapi_types.UUID]()
}
if m.SchemaName != nil {
resp.SchemaName = nullable.NewNullableWithValue(*m.SchemaName)
} else {
resp.SchemaName = nullable.NewNullNullable[string]()
}
if m.SchemaVersion != nil {
v := int32(*m.SchemaVersion) //nolint:gosec // per-lineage monotonic counter
resp.SchemaVersion = nullable.NewNullableWithValue(v)
} else {
resp.SchemaVersion = nullable.NewNullNullable[int32]()
}
return resp, nil
}