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,325 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user