17fc813823
M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs
265 lines
10 KiB
Go
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
|
|
}
|