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