// 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 } }