Merged in feature/mutable-metadata1 (pull request #221)
M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user