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

326 lines
12 KiB
Go

// Package queryapi: real handler implementations for the
// SuperAdminSchemaService routes declared in the OpenAPI spec.
//
// Each handler:
// 1. Extracts the authenticated Cognito subject via cognitoauth.GetUserSubject.
// The handler never trusts a body-supplied "createdBy" — the request schema
// does not even expose the field, and the service takes the actor as a
// separate argument.
// 2. Binds the request body (for POST) into the generated request type.
// 3. Delegates to customschema.Service.
// 4. Maps customschema sentinel errors and validator errors onto HTTP codes.
// 5. Serializes the returned model into the generated response type.
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"
openapi_types "github.com/oapi-codegen/runtime/types"
)
// validatorErrorMarker is the prefix customschema.Service wraps around every
// validator failure it surfaces from CreateSchema / CreateSchemaVersion. The
// handler layer uses it (via strings.Contains) to distinguish a 400-class
// validation problem from a 500-class infrastructure error after sentinel
// checks have already ruled out ErrSchemaNotFound, ErrSchemaInUse, etc.
const validatorErrorMarker = "validate schema definition"
// customSchemaListRow is a type alias onto the anonymous element type that
// oapi-codegen produced inside CustomSchemaListResponse.Schemas. Using an
// alias (not a new type) preserves Go's structural identity so we can append
// directly into the generated slice.
type customSchemaListRow = struct {
// CanDelete True when documentCount is zero.
CanDelete bool `json:"canDelete"`
// ClientId The client that owns this schema.
ClientId string `json:"clientId"`
// CreatedAt Timestamp when this schema row was created.
CreatedAt time.Time `json:"createdAt"`
// CreatedBy Cognito subject ID (JWT sub claim) of the caller. Opaque UUID, not an email.
CreatedBy string `json:"createdBy"`
// Description Optional human-readable description of the schema.
Description *string `json:"description,omitempty"`
// DocumentCount Number of documents referencing this schema version.
DocumentCount int32 `json:"documentCount"`
// Id The schema row ID.
Id openapi_types.UUID `json:"id"`
// Name The schema name (shared across versions in a lineage).
Name string `json:"name"`
// Status Lifecycle state of a client_metadata_schemas row. `active` is
// the version currently assignable to new documents. `superseded`
// is automatically set when a newer version of the same (clientId,
// name) lineage is created via POST /versions. `retired` is set
// when a super_admin explicitly deletes an unused schema.
Status SchemaStatus `json:"status"`
// Version Monotonic version number within the lineage.
Version int32 `json:"version"`
}
// CreateCustomSchema is POST /super-admin/custom-schemas.
//
// Body: CustomSchemaRequest. Actor: JWT subject. Returns 201 with the full
// CustomSchemaResponse on success.
func (s *Controllers) CreateCustomSchema(ctx echo.Context) error {
actor, ok := cognitoauth.GetUserSubject(ctx)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
var req CustomSchemaRequest
if err := ctx.Bind(&req); err != nil {
slog.Error("failed to parse custom schema request", "error", err)
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
schemaBytes, err := json.Marshal(req.Schema)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid schema JSON")
}
description := ""
if req.Description != nil {
description = *req.Description
}
input := customschema.CreateSchemaInput{
ClientID: req.ClientId,
Name: req.Name,
Description: description,
SchemaDef: schemaBytes,
}
created, err := s.svc.CustomSchema.CreateSchema(ctx.Request().Context(), input, actor)
if err != nil {
return mapCustomSchemaError(err)
}
resp, err := schemaToResponse(created, 0)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode schema response")
}
return ctx.JSON(http.StatusCreated, resp)
}
// ListCustomSchemas is GET /super-admin/custom-schemas. The required
// clientId query parameter is enforced by the generated wrapper; the
// handler still guards for an empty string to fail fast with a clear 400.
func (s *Controllers) ListCustomSchemas(ctx echo.Context, params ListCustomSchemasParams) error {
if _, ok := cognitoauth.GetUserSubject(ctx); !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
if strings.TrimSpace(params.ClientId) == "" {
return echo.NewHTTPError(http.StatusBadRequest, "clientId is required")
}
filters := customschema.ListFilters{
Limit: 50,
Offset: 0,
}
if params.Name != nil {
filters.Name = *params.Name
}
if params.IncludeAllVersions != nil {
filters.IncludeAllVersions = *params.IncludeAllVersions
}
if params.Limit != nil {
if *params.Limit < 1 || *params.Limit > 200 {
return echo.NewHTTPError(http.StatusBadRequest, "limit must be between 1 and 200")
}
filters.Limit = *params.Limit
}
if params.Offset != nil {
if *params.Offset < 0 {
return echo.NewHTTPError(http.StatusBadRequest, "offset must be >= 0")
}
filters.Offset = *params.Offset
}
if params.Status != nil {
if *params.Status == ListCustomSchemasParamsStatusAny {
filters.StatusAny = true
} else {
filters.Status = string(*params.Status)
}
}
summaries, err := s.svc.CustomSchema.ListSchemas(ctx.Request().Context(), params.ClientId, filters)
if err != nil {
return mapCustomSchemaError(err)
}
resp := CustomSchemaListResponse{
Schemas: make([]customSchemaListRow, 0, len(summaries)),
}
for _, sum := range summaries {
resp.Schemas = append(resp.Schemas, summaryToListRow(sum))
}
return ctx.JSON(http.StatusOK, resp)
}
// GetCustomSchema is GET /super-admin/custom-schemas/{schemaId}.
func (s *Controllers) GetCustomSchema(ctx echo.Context, schemaId openapi_types.UUID) error {
if _, ok := cognitoauth.GetUserSubject(ctx); !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
summary, err := s.svc.CustomSchema.GetSchema(ctx.Request().Context(), uuid.UUID(schemaId))
if err != nil {
return mapCustomSchemaError(err)
}
resp, err := schemaToResponse(&summary.Schema, summary.DocumentCount)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode schema response")
}
return ctx.JSON(http.StatusOK, resp)
}
// CreateCustomSchemaVersion is POST /super-admin/custom-schemas/{schemaId}/versions.
func (s *Controllers) CreateCustomSchemaVersion(ctx echo.Context, schemaId openapi_types.UUID) error {
actor, ok := cognitoauth.GetUserSubject(ctx)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
var req CustomSchemaVersionRequest
if err := ctx.Bind(&req); err != nil {
slog.Error("failed to parse custom schema version request", "error", err)
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
schemaBytes, err := json.Marshal(req.Schema)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid schema JSON")
}
description := ""
if req.Description != nil {
description = *req.Description
}
input := customschema.CreateSchemaVersionInput{
Description: description,
SchemaDef: schemaBytes,
}
created, err := s.svc.CustomSchema.CreateSchemaVersion(ctx.Request().Context(), uuid.UUID(schemaId), input, actor)
if err != nil {
return mapCustomSchemaError(err)
}
resp, err := schemaToResponse(created, 0)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode schema response")
}
return ctx.JSON(http.StatusCreated, resp)
}
// DeleteCustomSchema is DELETE /super-admin/custom-schemas/{schemaId}.
func (s *Controllers) DeleteCustomSchema(ctx echo.Context, schemaId openapi_types.UUID) error {
actor, ok := cognitoauth.GetUserSubject(ctx)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject")
}
if err := s.svc.CustomSchema.DeleteSchema(ctx.Request().Context(), uuid.UUID(schemaId), actor); err != nil {
return mapCustomSchemaError(err)
}
return ctx.NoContent(http.StatusNoContent)
}
// mapCustomSchemaError translates a customschema.Service error into an
// echo.HTTPError with the correct status code and message. Sentinel checks
// run first; anything that looks like a validator failure maps to 400;
// everything else is a 500.
func mapCustomSchemaError(err error) error {
switch {
case errors.Is(err, customschema.ErrSchemaNotFound):
return echo.NewHTTPError(http.StatusNotFound, "schema not found")
case errors.Is(err, customschema.ErrSchemaInUse):
return echo.NewHTTPError(http.StatusConflict, "schema is in use by one or more documents and cannot be deleted")
case errors.Is(err, customschema.ErrSchemaNameConflict):
return echo.NewHTTPError(http.StatusConflict, "schema with this duplicate name already exists for client")
case errors.Is(err, customschema.ErrSchemaParentRetired):
return echo.NewHTTPError(http.StatusConflict, "parent schema is retired; cannot create a new version from a retired parent")
case errors.Is(err, customschema.ErrSchemaParentNotActive):
return echo.NewHTTPError(http.StatusConflict, "parent schema is not the active version in its lineage")
}
if strings.Contains(err.Error(), validatorErrorMarker) {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
slog.Error("custom schema handler internal error", "error", err)
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
}
// schemaToResponse copies a customschema.Schema into the generated
// CustomSchemaResponse. documentCount is the live document binding count;
// CanDelete is derived from it (true iff count == 0). The model's SchemaDef
// is json.RawMessage and the generated type expects map[string]interface{},
// so we round-trip through encoding/json to reshape without losing structure.
func schemaToResponse(schema *customschema.Schema, documentCount int64) (CustomSchemaResponse, error) {
var schemaMap map[string]interface{}
if err := json.Unmarshal(schema.SchemaDef, &schemaMap); err != nil {
return CustomSchemaResponse{}, err
}
var descriptionPtr *string
if schema.Description != "" {
d := schema.Description
descriptionPtr = &d
}
return CustomSchemaResponse{
Id: openapi_types.UUID(schema.ID),
ClientId: schema.ClientID,
Name: schema.Name,
Description: descriptionPtr,
Schema: schemaMap,
Version: int32(schema.Version), //nolint:gosec // schema.Version is the per-lineage monotonic counter; an int32 ceiling of ~2 billion is trivially safe for schema versions
Status: SchemaStatus(schema.Status),
CreatedAt: schema.CreatedAt,
CreatedBy: schema.CreatedBy,
DocumentCount: int32(documentCount), //nolint:gosec // document count per schema is bounded by total documents; int32 max (~2B) is safe
CanDelete: documentCount == 0,
}, nil
}
// summaryToListRow translates a customschema.SchemaSummary into the anonymous
// row struct declared inside CustomSchemaListResponse. The alias defined
// above preserves structural identity with the generated type.
func summaryToListRow(summary customschema.SchemaSummary) customSchemaListRow {
var descriptionPtr *string
if summary.Schema.Description != "" {
d := summary.Schema.Description
descriptionPtr = &d
}
return customSchemaListRow{
CanDelete: summary.CanDelete,
ClientId: summary.Schema.ClientID,
CreatedAt: summary.Schema.CreatedAt,
CreatedBy: summary.Schema.CreatedBy,
Description: descriptionPtr,
DocumentCount: int32(summary.DocumentCount), //nolint:gosec // documentCount is COUNT(*) of documents bound to this schema; an int32 ceiling of ~2 billion is far above any realistic per-client document count
Id: openapi_types.UUID(summary.Schema.ID),
Name: summary.Schema.Name,
Status: SchemaStatus(summary.Schema.Status),
Version: int32(summary.Schema.Version), //nolint:gosec // see schemaToResponse; per-lineage monotonic version counter
}
}