diff --git a/app/lib/openapi-definition.json b/app/lib/openapi-definition.json new file mode 100644 index 0000000..1cc2bd7 --- /dev/null +++ b/app/lib/openapi-definition.json @@ -0,0 +1,39 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "TRASER API", + "version": "1.0.0", + "description": "Metadata TRAnslation SERvice — converts health dataset metadata between schema formats (HDRUK, GWDM, SchemaOrg, CRUK) and validates metadata against those schemas." + }, + "servers": [{ "url": "/", "description": "This server" }], + "tags": [ + { "name": "translate", "description": "Translate metadata between schemas" }, + { "name": "validate", "description": "Validate metadata against a schema" }, + { "name": "find", "description": "Discover which schemas match a metadata document" }, + { "name": "list", "description": "List available schemas, templates, and translation routes" }, + { "name": "get", "description": "Retrieve a schema definition, translation map, or form hydration" } + ], + "components": { + "schemas": { + "ValidationError": { + "type": "object", + "properties": { + "keyword": { "type": "string", "example": "enum" }, + "instancePath": { "type": "string", "example": "/summary/contactPoint/0/contactType" }, + "message": { "type": "string", "example": "must be equal to one of the allowed values" }, + "params": { "type": "object" }, + "invalidValue": {}, + "suggestion": { "type": "string", "example": "Allowed: \"primary\", \"secondary\"" }, + "allowedValues": { "type": "array", "items": {} } + } + }, + "ErrorMessage": { + "type": "object", + "required": ["message"], + "properties": { + "message": { "type": "string" } + } + } + } + } +} diff --git a/app/routes.ts b/app/routes.ts index 9fd57d0..f552e48 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -1,3 +1,15 @@ -import type { RouteConfig } from "@react-router/dev/routes"; +import { type RouteConfig, route } from "@react-router/dev/routes"; -export default [] satisfies RouteConfig; +export default [ + route("/status", "routes/api/status.ts"), + route("/openapi.json", "routes/api/openapi.json.ts"), + route("/translate", "routes/api/translate.ts"), + route("/validate", "routes/api/validate.ts"), + route("/find", "routes/api/find.ts"), + route("/list/schemas", "routes/api/list.schemas.ts"), + route("/list/templates", "routes/api/list.templates.ts"), + route("/list/translations", "routes/api/list.translations.ts"), + route("/get/schema", "routes/api/get.schema.ts"), + route("/get/map", "routes/api/get.map.ts"), + route("/get/form_hydration", "routes/api/get.form_hydration.ts"), +] satisfies RouteConfig; diff --git a/app/routes/api/find.ts b/app/routes/api/find.ts new file mode 100644 index 0000000..3652aae --- /dev/null +++ b/app/routes/api/find.ts @@ -0,0 +1,85 @@ +/** + * @openapi + * /find: + * post: + * tags: [find] + * summary: Find schemas that match a metadata document + * description: Tests the provided metadata against all loaded schemas and returns the ones that validate successfully. + * parameters: + * - name: with_errors + * in: query + * description: Set to 1 to include validation error details for non-matching schemas. + * schema: + * type: string + * enum: ["0", "1"] + * default: "0" + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * description: The metadata document to test. + * responses: + * '200': + * description: List of schemas that matched, optionally with validation error details for misses. + * content: + * application/json: + * schema: + * type: array + * '400': + * description: Invalid request (missing or non-JSON body). + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + */ +import { ensureLoaded, findMatchingSchemas } from "~/lib/schema.server"; +import { publishMessage } from "~/lib/audit.server"; +import { fieldError, invalidRequest, type FieldError } from "~/lib/errors.server"; + +export async function action({ request }: { request: Request }) { + await ensureLoaded(); + + const url = new URL(request.url); + const withErrorsRaw = url.searchParams.get("with_errors"); + + const errors: FieldError[] = []; + + // Content-Type must be application/json — reject when the header is absent or + // any other type. + const contentType = request.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + errors.push(fieldError("Invalid content type. Expected JSON.", "", "body")); + } + + // with_errors is optional and defaults to 0, but when present must be 0 or 1. + let withErrors = false; + if (withErrorsRaw !== null && withErrorsRaw !== "") { + if (withErrorsRaw === "0" || withErrorsRaw === "1") { + withErrors = withErrorsRaw === "1"; + } else { + errors.push(fieldError("Invalid value", "with_errors", "query", withErrorsRaw)); + } + } + + if (errors.length > 0) { + publishMessage( + "POST", + "find", + "Failed to validate posted metadata against available schemas" + ).catch(console.error); + return invalidRequest(errors); + } + + let metadata: unknown; + try { + metadata = await request.json(); + } catch { + return Response.json({ message: "Invalid JSON body" }, { status: 400 }); + } + + const result = await findMatchingSchemas(metadata, withErrors); + publishMessage("POST", "find", "Validated metadata against available schemas").catch(console.error); + return Response.json(result); +} diff --git a/app/routes/api/get.form_hydration.ts b/app/routes/api/get.form_hydration.ts new file mode 100644 index 0000000..e32ab02 --- /dev/null +++ b/app/routes/api/get.form_hydration.ts @@ -0,0 +1,102 @@ +/** + * @openapi + * /get/form_hydration: + * get: + * tags: [get] + * summary: Fetch a hydrated form schema + * description: Applies a JSONata form-hydration template to a schema definition and returns the result. Used to build dynamic form configurations. + * parameters: + * - name: name + * in: query + * required: true + * description: Schema name to hydrate (e.g. HDRUK). + * schema: + * type: string + * example: HDRUK + * - name: version + * in: query + * description: Schema version. Falls back to the HYDRATION_MAP_VERSION env var if omitted. + * schema: + * type: string + * - name: dataTypes + * in: query + * description: Comma-separated list of data types to inject into the hydration source. + * schema: + * type: string + * example: "Genomics,Imaging" + * responses: + * '200': + * description: Hydrated form schema. + * content: + * application/json: + * schema: + * type: object + * '400': + * description: Missing parameters or hydration template not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + */ +import jsonata from "jsonata"; +import { ensureLoaded, retrieveHydrationSchema } from "~/lib/schema.server"; +import { getFormHydrationTemplate } from "~/lib/templates.server"; +import { publishMessage } from "~/lib/audit.server"; +import { fieldError, invalidParams } from "~/lib/errors.server"; + +export async function loader({ request }: { request: Request }) { + await ensureLoaded(); + + const url = new URL(request.url); + const name = url.searchParams.get("name"); + const version = + url.searchParams.get("version") ?? process.env.HYDRATION_MAP_VERSION ?? ""; + const dataTypes = url.searchParams.get("dataTypes") ?? ""; + + if (!name) { + return invalidParams("Invalid query parameters.", [ + fieldError("Invalid value", "name", "query"), + ]); + } + + try { + const [template, source] = await Promise.all([ + getFormHydrationTemplate(name, version), + retrieveHydrationSchema(name, version), + ]); + + if (!template || !source) { + return Response.json({ message: "Hydration template or schema not found" }, { status: 400 }); + } + + const src = source as Record; + src.dataTypes = dataTypes.split(","); + + const expression = jsonata(template); + const result = await expression.evaluate(src); + + // A JSONata expression that matches nothing evaluates to `undefined`, which + // JSON.stringify turns into a malformed empty body — return a 400 instead. + if (result === undefined) { + publishMessage( + "GET", + "get/form_hydration", + `${name}-${version} failed to hydrate` + ).catch(console.error); + return Response.json({ message: "Hydration failed." }, { status: 400 }); + } + + publishMessage("GET", "get/form_hydration", `${name}-${version} retrieved`).catch(console.error); + return Response.json(result); + } catch (err) { + publishMessage( + "GET", + "get/form_hydration", + `Failed to retrieve ${name}-${version}` + ).catch(console.error); + return Response.json( + { error: err instanceof Error ? err.message : String(err) }, + { status: 400 } + ); + } +} diff --git a/app/routes/api/get.map.ts b/app/routes/api/get.map.ts new file mode 100644 index 0000000..00746ec --- /dev/null +++ b/app/routes/api/get.map.ts @@ -0,0 +1,163 @@ +/** + * @openapi + * /get/map: + * get: + * tags: [get] + * summary: Fetch a JSONata translation map + * description: Returns the raw JSONata template string used to translate between two specific schema/version pairs. + * parameters: + * - name: input_schema + * in: query + * required: true + * description: Source schema name. + * schema: + * type: string + * example: HDRUK + * - name: input_version + * in: query + * required: true + * description: Source schema version. + * schema: + * type: string + * example: "2.1.2" + * - name: output_schema + * in: query + * required: true + * description: Target schema name. + * schema: + * type: string + * example: GWDM + * - name: output_version + * in: query + * required: true + * description: Target schema version. + * schema: + * type: string + * example: "1.0" + * responses: + * '200': + * description: Translation map details. + * content: + * application/json: + * schema: + * type: object + * properties: + * input_schema: + * type: string + * input_version: + * type: string + * output_schema: + * type: string + * output_version: + * type: string + * translation_map: + * type: string + * description: The JSONata template string. + * '400': + * description: Translation not found or missing parameters. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + */ +import { getTemplate } from "~/lib/templates.server"; +import { TranslationGraph } from "~/lib/graph.server"; +import { publishMessage } from "~/lib/audit.server"; +import { + fieldError, + invalidParams, + type FieldError, +} from "~/lib/errors.server"; + +export async function loader({ request }: { request: Request }) { + const url = new URL(request.url); + const inputSchema = url.searchParams.get("input_schema"); + const inputVersion = url.searchParams.get("input_version"); + const outputSchema = url.searchParams.get("output_schema"); + const outputVersion = url.searchParams.get("output_version"); + + const paramErrors: FieldError[] = []; + if (!outputSchema) + paramErrors.push(fieldError("Invalid value", "output_schema", "query")); + if (!outputVersion) + paramErrors.push(fieldError("Invalid value", "output_version", "query")); + if (!inputSchema) + paramErrors.push(fieldError("Invalid value", "input_schema", "query")); + if (!inputVersion) + paramErrors.push(fieldError("Invalid value", "input_version", "query")); + if (paramErrors.length > 0) { + publishMessage( + "GET", + "get/map", + "Failed to retrieve mapping due to invalid inputs", + ).catch(console.error); + return invalidParams("Invalid query parameters.", paramErrors); + } + + const template = await getTemplate( + inputSchema!, + inputVersion!, + outputSchema!, + outputVersion!, + ); + + const { path, maps } = template + ? { path: null, maps: null } + : await resolveMultiHop( + `${inputSchema}:${inputVersion}`, + `${outputSchema}:${outputVersion}`, + ); + + publishMessage( + "GET", + "get/map", + `Map ${inputSchema}-${inputVersion} → ${outputSchema}-${outputVersion} retrieved`, + ).catch(console.error); + return Response.json({ + input_schema: inputSchema, + input_version: inputVersion, + output_schema: outputSchema, + output_version: outputVersion, + translation_map: template ?? null, + translation_path: path, + translation_maps: maps, + }); +} + +interface HopMap { + from: string; + to: string; + map: string; +} + +async function resolveMultiHop( + from: string, + to: string, +): Promise<{ path: string[] | null; maps: HopMap[] | null }> { + let hops: { name: string; version: string }[]; + try { + const graph = await TranslationGraph.create(); + const result = graph.getPath(from, to, graph.dijkstra(from)); + const found = (result as { translationsToApply?: { name: string; version: string }[] }) + .translationsToApply; + if (!found || found.length < 2) return { path: null, maps: null }; + hops = found; + } catch { + return { path: null, maps: null }; + } + + const maps: HopMap[] = []; + for (let i = 1; i < hops.length; i++) { + const a = hops[i - 1]; + const b = hops[i]; + const hopTemplate = await getTemplate(a.name, a.version, b.name, b.version); + if (!hopTemplate) return { path: null, maps: null }; + maps.push({ + from: `${a.name}:${a.version}`, + to: `${b.name}:${b.version}`, + map: hopTemplate, + }); + } + + return { path: hops.map((h) => `${h.name}:${h.version}`), maps }; +} diff --git a/app/routes/api/get.schema.ts b/app/routes/api/get.schema.ts new file mode 100644 index 0000000..586e127 --- /dev/null +++ b/app/routes/api/get.schema.ts @@ -0,0 +1,68 @@ +/** + * @openapi + * /get/schema: + * get: + * tags: [get] + * summary: Fetch a schema definition + * description: Returns the compiled JSON Schema for the specified schema name and version. + * parameters: + * - name: name + * in: query + * required: true + * description: Schema name (e.g. HDRUK). + * schema: + * type: string + * example: HDRUK + * - name: version + * in: query + * description: Schema version. Uses the latest available version if omitted. + * schema: + * type: string + * example: "3.0.0" + * responses: + * '200': + * description: Schema definition. + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * version: + * type: string + * schema: + * type: object + * description: The JSON Schema definition. + * '400': + * description: Schema not found or missing parameters. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + */ +import { ensureLoaded, getSchema } from "~/lib/schema.server"; +import { publishMessage } from "~/lib/audit.server"; +import { fieldError, invalidParams } from "~/lib/errors.server"; + +export async function loader({ request }: { request: Request }) { + await ensureLoaded(); + + const url = new URL(request.url); + const name = url.searchParams.get("name"); + const version = url.searchParams.get("version") ?? ""; + + if (!name) { + return invalidParams("Invalid query parameters.", [ + fieldError("Invalid value", "name", "query"), + ]); + } + + const validator = getSchema(name, version); + if (!validator?.schema) { + return Response.json({ error: `Schema ${name}:${version} not found` }, { status: 400 }); + } + + publishMessage("GET", "get/schema", `${name}-${version} retrieved`).catch(console.error); + return Response.json({ name, version, schema: validator.schema }); +} diff --git a/app/routes/api/list.schemas.ts b/app/routes/api/list.schemas.ts new file mode 100644 index 0000000..ce69fad --- /dev/null +++ b/app/routes/api/list.schemas.ts @@ -0,0 +1,32 @@ +/** + * @openapi + * /list/schemas: + * get: + * tags: [list] + * summary: List available schemas + * description: Returns all schema names and their available versions. + * responses: + * '200': + * description: Map of schema name to array of versions. + * content: + * application/json: + * schema: + * type: object + * additionalProperties: + * type: array + * items: + * type: string + * example: + * HDRUK: ["2.1.2", "2.1.3", "3.0.0"] + * GWDM: ["1.0", "2.0"] + * SchemaOrg: ["1.0"] + */ +import { ensureLoaded, getAvailableSchemas } from "~/lib/schema.server"; +import { publishMessage } from "~/lib/audit.server"; + +export async function loader() { + await ensureLoaded(); + const schemas = await getAvailableSchemas(); + publishMessage("GET", "list/schemas", "Retrieved available schemas").catch(console.error); + return Response.json(schemas); +} diff --git a/app/routes/api/list.templates.ts b/app/routes/api/list.templates.ts new file mode 100644 index 0000000..0195eda --- /dev/null +++ b/app/routes/api/list.templates.ts @@ -0,0 +1,23 @@ +/** + * @openapi + * /list/templates: + * get: + * tags: [list] + * summary: List available translation templates + * description: Returns all loaded JSONata translation template descriptors. + * responses: + * '200': + * description: Array of template descriptors. + * content: + * application/json: + * schema: + * type: array + */ +import { getAvailableTemplates } from "~/lib/templates.server"; +import { publishMessage } from "~/lib/audit.server"; + +export async function loader() { + const templates = await getAvailableTemplates(); + publishMessage("GET", "list/templates", "Retrieved available templates").catch(console.error); + return Response.json(templates); +} diff --git a/app/routes/api/list.translations.ts b/app/routes/api/list.translations.ts new file mode 100644 index 0000000..32b0b37 --- /dev/null +++ b/app/routes/api/list.translations.ts @@ -0,0 +1,81 @@ +/** + * @openapi + * /list/translations: + * get: + * tags: [list] + * summary: List reachable translation routes from a schema + * description: Returns all translation paths reachable from the given schema/version using Dijkstra's algorithm over the translation graph. + * parameters: + * - name: schema + * in: query + * required: true + * description: Source schema name (e.g. HDRUK). + * schema: + * type: string + * example: HDRUK + * - name: version + * in: query + * required: true + * description: Source schema version (e.g. 2.1.2). + * schema: + * type: string + * example: "2.1.2" + * responses: + * '200': + * description: Array of translation route strings. + * content: + * application/json: + * schema: + * type: array + * items: + * type: string + * example: ["HDRUK:2.1.2 -> GWDM:1.0", "HDRUK:2.1.2 -> GWDM:1.0 -> SchemaOrg:1.0"] + * '400': + * description: Missing required parameters. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + */ +import { ensureLoaded, getAvailableSchemas } from "~/lib/schema.server"; +import { TranslationGraph } from "~/lib/graph.server"; +import { publishMessage } from "~/lib/audit.server"; +import { fieldError, invalidParams, type FieldError } from "~/lib/errors.server"; + +export async function loader({ request }: { request: Request }) { + await ensureLoaded(); + + const url = new URL(request.url); + const schema = url.searchParams.get("schema"); + const version = url.searchParams.get("version"); + + const paramErrors: FieldError[] = []; + if (!schema) paramErrors.push(fieldError("Invalid value", "schema", "query")); + if (!version) paramErrors.push(fieldError("Invalid value", "version", "query")); + if (paramErrors.length > 0) { + publishMessage("GET", "list/translations", "Failed to retrieve available translations").catch(console.error); + return invalidParams("Translation has failed.", paramErrors); + } + + const startNode = `${schema}:${version}`; + const availableSchemas = await getAvailableSchemas(); + + const allNodes: string[] = []; + for (const [s, versions] of Object.entries(availableSchemas)) { + for (const v of versions) allNodes.push(`${s}:${v}`); + } + + const graph = await TranslationGraph.create(); + const predecessors = graph.dijkstra(startNode); + + const routes = allNodes + .map((endNode) => { + const { translationsToApply } = graph.getPath(startNode, endNode, predecessors); + if (!translationsToApply) return null; + return translationsToApply.map((e) => `${e.name}:${e.version}`).join(" -> "); + }) + .filter((r): r is string => r !== null); + + publishMessage("GET", "list/translations", `Retrieved translations for ${schema}:${version}`).catch(console.error); + return Response.json(routes); +} diff --git a/app/routes/api/openapi.json.ts b/app/routes/api/openapi.json.ts new file mode 100644 index 0000000..1a7606e --- /dev/null +++ b/app/routes/api/openapi.json.ts @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import definition from "~/lib/openapi-definition.json"; + +const GENERATED_SPEC = resolve(process.cwd(), "build/openapi.json"); +const SOURCE_GLOB = resolve(process.cwd(), "app/routes/api/*.ts"); + +async function loadSpec(): Promise { + try { + return JSON.parse(await readFile(GENERATED_SPEC, "utf8")); + } catch (err) { + if (process.env.NODE_ENV === "production") { + throw new Error( + `build/openapi.json is missing — run "npm run build" to generate it (${String(err)})` + ); + } + const { default: swaggerJsdoc } = await import("swagger-jsdoc"); + return swaggerJsdoc({ definition, apis: [SOURCE_GLOB] }); + } +} + +let cachedSpec: unknown; + +export async function loader() { + cachedSpec ??= await loadSpec(); + return Response.json(cachedSpec); +} diff --git a/app/routes/api/status.ts b/app/routes/api/status.ts new file mode 100644 index 0000000..efa572b --- /dev/null +++ b/app/routes/api/status.ts @@ -0,0 +1,25 @@ +/** + * @openapi + * /status: + * get: + * tags: [list] + * summary: Liveness probe. + * description: > + * Returns 200 as long as the process is serving. Does not touch schemas, + * templates or the dataset cache, so it stays cheap and cannot fail for + * reasons unrelated to liveness. + * responses: + * '200': + * description: The service is up. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: ok + */ +export async function loader() { + return Response.json({ message: "ok" }); +} diff --git a/app/routes/api/translate.ts b/app/routes/api/translate.ts new file mode 100644 index 0000000..b1cd1b2 --- /dev/null +++ b/app/routes/api/translate.ts @@ -0,0 +1,381 @@ +/** + * @openapi + * /translate: + * post: + * tags: [translate] + * summary: Translate metadata to another schema + * description: > + * Translates a metadata document from one schema/version to another. + * If input_schema/input_version are omitted, the input schema is auto-detected. + * If output_schema/output_version are omitted, the configured default output schema is used. + * Multi-hop translations (e.g. HDRUK → GWDM → SchemaOrg) are resolved automatically via Dijkstra's algorithm. + * parameters: + * - name: input_schema + * in: query + * description: Source schema name (e.g. HDRUK). Auto-detected from the metadata if omitted. + * schema: + * type: string + * example: HDRUK + * - name: input_version + * in: query + * description: Source schema version (e.g. 2.1.2). Auto-detected if omitted. + * schema: + * type: string + * example: "2.1.2" + * - name: output_schema + * in: query + * description: Target schema name. Defaults to the server-configured default if omitted. + * schema: + * type: string + * example: GWDM + * - name: output_version + * in: query + * description: Target schema version. Defaults to the server-configured default if omitted. + * schema: + * type: string + * example: "2.0" + * - name: validate_input + * in: query + * description: Set to 0 to skip input validation. Enabled by default. + * schema: + * type: string + * enum: ["0", "1"] + * default: "1" + * - name: validate_output + * in: query + * description: Set to 0 to skip output validation. Enabled by default. + * schema: + * type: string + * enum: ["0", "1"] + * default: "1" + * - name: subsection + * in: query + * description: Validate/translate only a named subsection of the schema. + * schema: + * type: string + * - name: select_first_matching + * in: query + * description: Set to false to error when multiple schemas match during auto-detection instead of picking the first. + * schema: + * type: string + * enum: ["true", "false"] + * default: "true" + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [metadata] + * properties: + * metadata: + * type: object + * description: The metadata document to translate. + * extra: + * type: object + * description: Optional supplementary data passed to JSONata templates as extra.*. + * responses: + * '200': + * description: Translated metadata document. + * content: + * application/json: + * schema: + * type: object + * '400': + * description: Validation failed or parameters missing. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * details: + * oneOf: + * - type: object + * properties: + * validationErrors: + * type: array + * items: + * $ref: '#/components/schemas/ValidationError' + * data: + * type: object + * - type: array + * items: + * $ref: '#/components/schemas/ValidationError' + * data: + * type: object + */ +import { + ensureLoaded, + validateMetadata, + validateMetadataSection, +} from "~/lib/schema.server"; +import { + findModelAndVersion, + getDefaultModelAndVersion, + translate, +} from "~/lib/translation.server"; +import { TranslationGraph } from "~/lib/graph.server"; +import { publishMessage } from "~/lib/audit.server"; +import { + fieldError, + forwardKnownError, + errorResponse, + invalidParams, + type FieldError, +} from "~/lib/errors.server"; + +const TRANSLATE_FAILED = "Translation has failed."; + +function isPlainObject(value: unknown): boolean { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isEmptyish(value: unknown): boolean { + if (value === undefined || value === null) return true; + if (Array.isArray(value)) return value.length === 0; + return String(value).length === 0; +} + +function parseBooleanFlag( + raw: string | null, + name: string, + errors: FieldError[], +): boolean { + if (raw === null || raw === "") return true; + if (raw === "1" || raw === "true") return true; + if (raw === "0" || raw === "false") return false; + errors.push( + fieldError("Needs to be boolean (either 1 or 0)", name, "query", raw), + ); + return true; +} + +export async function action({ request }: { request: Request }) { + await ensureLoaded(); + + const url = new URL(request.url); + let inputSchema = url.searchParams.get("input_schema") ?? undefined; + let inputVersion = url.searchParams.get("input_version") ?? undefined; + let outputSchema = url.searchParams.get("output_schema") ?? undefined; + let outputVersion = url.searchParams.get("output_version") ?? undefined; + const flagErrors: FieldError[] = []; + const validateInput = parseBooleanFlag( + url.searchParams.get("validate_input"), + "validate_input", + flagErrors, + ); + const validateOutput = parseBooleanFlag( + url.searchParams.get("validate_output"), + "validate_output", + flagErrors, + ); + const subsection = url.searchParams.get("subsection") ?? undefined; + const selectFirstMatching = + url.searchParams.get("select_first_matching") !== "false"; + + let body: { metadata?: unknown; extra?: unknown }; + try { + body = await request.json(); + } catch { + return Response.json({ message: "Invalid JSON body" }, { status: 400 }); + } + + const { metadata, extra } = body; + + const paramErrors: FieldError[] = []; + if (!isPlainObject(metadata)) + paramErrors.push(fieldError("Invalid value", "metadata", "body", metadata)); + if (isEmptyish(metadata)) + paramErrors.push(fieldError("Invalid value", "metadata", "body", metadata)); + if (extra !== undefined && !isPlainObject(extra)) + paramErrors.push(fieldError("Invalid value", "extra", "body", extra)); + paramErrors.push(...flagErrors); + + if (paramErrors.length > 0) { + publishMessage( + "POST", + "translate", + "Failed to translate due to invalid inputs", + ).catch(console.error); + return invalidParams(TRANSLATE_FAILED, paramErrors); + } + + try { + // ── Auto-detect input schema ─────────────────────────────────────────── + if (!inputSchema || !inputVersion) { + const detected = await findModelAndVersion(metadata, selectFirstMatching); + if (detected.error) { + publishMessage( + "POST", + "translate", + "Failed to detect input schema for posted metadata", + ).catch(console.error); + return forwardKnownError(detected.error); + } + inputSchema = detected.name!; + inputVersion = detected.version!; + } + + // ── Default output schema ────────────────────────────────────────────── + if (!outputSchema || !outputVersion) { + const defaulted = await getDefaultModelAndVersion( + outputSchema, + outputVersion, + ); + if (defaulted.error) { + publishMessage( + "POST", + "translate", + "Failed to determine output schema", + ).catch(console.error); + return forwardKnownError(defaulted.error); + } + outputSchema = defaulted.name!; + outputVersion = defaulted.version!; + } + + // ── Build translation graph ──────────────────────────────────────────── + const graph = await TranslationGraph.create(); + + if (!graph.nodes[`${inputSchema}:${inputVersion}`]) { + publishMessage( + "POST", + "translate", + `Unsupported input model ${inputSchema}:${inputVersion}`, + ).catch(console.error); + return Response.json( + { + message: `Cannot support the input model (${inputSchema}:${inputVersion})`, + }, + { status: 400 }, + ); + } + if (!graph.nodes[`${outputSchema}:${outputVersion}`]) { + publishMessage( + "POST", + "translate", + `Unsupported output model ${outputSchema}:${outputVersion}`, + ).catch(console.error); + return Response.json( + { + message: `Cannot support the output model (${outputSchema}:${outputVersion})`, + }, + { status: 400 }, + ); + } + + // ── Validate input ───────────────────────────────────────────────────── + if (validateInput) { + const errors = subsection + ? await validateMetadataSection( + metadata, + inputSchema, + inputVersion, + subsection, + ) + : await validateMetadata(metadata, inputSchema, inputVersion); + if (errors.length > 0) { + publishMessage( + "POST", + "translate", + `Input metadata failed validation as ${inputSchema}:${inputVersion}`, + ).catch(console.error); + return Response.json( + { + message: "Input metadata validation failed", + details: { validationErrors: errors, data: metadata }, + }, + { status: 400 }, + ); + } + } + + // ── Find translation path via Dijkstra ──────────────────────────────── + const startNode = `${inputSchema}:${inputVersion}`; + const endNode = `${outputSchema}:${outputVersion}`; + const predecessors = graph.dijkstra(startNode); + const { translationsToApply, error: pathError } = graph.getPath( + startNode, + endNode, + predecessors, + ); + if (pathError) { + // Forward the graph's own 400 + message rather than masking a + // missing-route (a client-input problem) as a 500 server fault. + publishMessage( + "POST", + "translate", + `No translation path between ${startNode} and ${endNode}`, + ).catch(console.error); + return forwardKnownError(pathError); + } + + // ── Apply chained translations ───────────────────────────────────────── + let current: unknown = metadata; + for (let i = 1; i < translationsToApply!.length; i++) { + const inM = translationsToApply![i - 1]; + const outM = translationsToApply![i]; + const result = await translate( + current, + extra, + inM.name, + inM.version, + outM.name, + outM.version, + ); + if (result.error) { + publishMessage( + "POST", + "translate", + `Translation step ${inM.name}:${inM.version} → ${outM.name}:${outM.version} failed`, + ).catch(console.error); + return forwardKnownError(result.error); + } + current = result.translatedMetadata ?? result.outputMetadata; + } + + const outputMetadata = current; + + // ── Validate output ──────────────────────────────────────────────────── + if (validateOutput) { + const errors = subsection + ? await validateMetadataSection( + outputMetadata, + outputSchema, + outputVersion, + subsection, + ) + : await validateMetadata(outputMetadata, outputSchema, outputVersion); + if (errors.length > 0) { + publishMessage( + "POST", + "translate", + `Output metadata failed validation as ${outputSchema}:${outputVersion}`, + ).catch(console.error); + return Response.json( + { + message: "Output metadata validation failed", + details: errors, + data: outputMetadata, + }, + { status: 400 }, + ); + } + } + + publishMessage( + "POST", + "translate", + `Translated ${inputSchema}:${inputVersion} → ${outputSchema}:${outputVersion}`, + ).catch(console.error); + return Response.json(outputMetadata); + } catch (err) { + // Unexpected/thrown error — genericise any 5xx so internal detail can't leak. + publishMessage("POST", "translate", "Translation failed").catch( + console.error, + ); + return errorResponse(err, 500); + } +} diff --git a/app/routes/api/validate.ts b/app/routes/api/validate.ts new file mode 100644 index 0000000..e4eb268 --- /dev/null +++ b/app/routes/api/validate.ts @@ -0,0 +1,207 @@ +/** + * @openapi + * /validate: + * post: + * tags: [validate] + * summary: Validate metadata against a schema + * description: > + * Validates a metadata document against the specified schema and version using AJV. + * Returns enriched validation errors including suggestions for additionalProperties and enum violations. + * parameters: + * - name: input_schema + * in: query + * required: true + * description: Schema name to validate against (e.g. HDRUK). + * schema: + * type: string + * example: HDRUK + * - name: input_version + * in: query + * required: true + * description: Schema version to validate against (e.g. 3.0.0). + * schema: + * type: string + * example: "3.0.0" + * - name: subsection + * in: query + * description: Validate only a named subsection of the schema. + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [metadata] + * properties: + * metadata: + * type: object + * description: The metadata document to validate. + * responses: + * '200': + * description: Metadata is valid. + * content: + * application/json: + * schema: + * type: object + * properties: + * details: + * type: string + * example: all ok + * '400': + * description: Metadata failed validation. + * content: + * application/json: + * schema: + * type: object + * required: [error, details, data] + * properties: + * error: + * type: string + * example: metadata validation failed + * details: + * type: array + * items: + * $ref: '#/components/schemas/ValidationError' + * data: + * type: object + * description: The original metadata that was validated. + */ +import { ensureLoaded, validateMetadata, validateMetadataSection, getPropertyIndex, getNameDiscriminatorMap } from "~/lib/schema.server"; +import { publishMessage } from "~/lib/audit.server"; +import { fieldError, invalidParams, type FieldError } from "~/lib/errors.server"; + +function getValueAtPath(metadata: unknown, instancePath: string): unknown { + if (!instancePath || instancePath === "/") return metadata; + const parts = instancePath.replace(/^\//, "").split("/"); + let current: unknown = metadata; + for (const part of parts) { + if (current == null || typeof current !== "object") return undefined; + const decoded = part.replace(/~1/g, "/").replace(/~0/g, "~"); + if (Array.isArray(current)) { + const idx = parseInt(decoded, 10); + if (isNaN(idx)) return undefined; + current = (current as unknown[])[idx]; + } else { + current = (current as Record)[decoded]; + } + } + return current; +} + +export async function action({ request }: { request: Request }) { + await ensureLoaded(); + + const url = new URL(request.url); + const inputSchema = url.searchParams.get("input_schema"); + const inputVersion = url.searchParams.get("input_version"); + const subsection = url.searchParams.get("subsection") ?? undefined; + + let body: { metadata?: unknown }; + try { + body = await request.json(); + } catch { + return Response.json({ message: "Invalid JSON body" }, { status: 400 }); + } + const { metadata } = body; + + // Collect missing-param and bad-metadata failures together into one + // "Validation has failed" 400 with an `errors` array. + const paramErrors: FieldError[] = []; + if (!inputSchema) paramErrors.push(fieldError("Invalid value", "input_schema", "query")); + if (!inputVersion) paramErrors.push(fieldError("Invalid value", "input_version", "query")); + const metadataIsObject = + typeof metadata === "object" && metadata !== null && !Array.isArray(metadata); + if (!metadataIsObject) + paramErrors.push(fieldError("Invalid value", "metadata", "body", metadata)); + if ( + metadata === undefined || + metadata === null || + (Array.isArray(metadata) ? metadata.length === 0 : String(metadata).length === 0) + ) + paramErrors.push(fieldError("Invalid value", "metadata", "body", metadata)); + if (paramErrors.length > 0) { + publishMessage("POST", "validate", "Failed to validate metadata").catch(console.error); + return invalidParams("Validation has failed", paramErrors); + } + + const errors = subsection + ? await validateMetadataSection(metadata, inputSchema!, inputVersion!, subsection) + : await validateMetadata(metadata, inputSchema!, inputVersion!); + + if (errors.length > 0) { + const propertyIndex = getPropertyIndex(inputSchema!, inputVersion!); + const nameDiscriminatorMap = getNameDiscriminatorMap(inputSchema!, inputVersion!); + + // Deduplicate: AJV with allErrors:true emits the same error once per anyOf branch. + // Keep only the first occurrence of each instancePath+message pair. + const seen = new Set(); + const deduped = (errors as Record[]).filter((e) => { + const key = `${e["instancePath"] ?? ""}::${e["message"] ?? ""}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + const enrichedErrors = deduped.map((e) => { + if (e["keyword"] === "additionalProperties") { + const addProp = (e["params"] as Record | undefined)?.["additionalProperty"] as string | undefined; + if (addProp) { + const paths = propertyIndex.get(addProp); + // Only suggest paths that have a parent (contain ".") — bare names come from walking + // $defs in isolation and aren't useful as move-to targets. + const rooted = paths?.filter(p => p.includes(".")) ?? []; + const suggestion = rooted.length > 0 ? `Move to: ${rooted.join(" or ")}` : undefined; + return suggestion ? { ...e, suggestion } : e; + } + } + if (e["keyword"] === "enum") { + const instancePath = (e["instancePath"] as string) ?? ""; + const invalidValue = getValueAtPath(metadata, instancePath); + const ajvAllowedValues = (e["params"] as Record | undefined)?.["allowedValues"] as unknown[] | undefined; + + // Walk up the instance path to find the nearest parent with a `name` field + // that acts as a discriminator (e.g. { name: "Health and disease", subTypes: [...] }) + let discriminatorAllowedValues: unknown[] | undefined; + const pathParts = instancePath.replace(/^\//, "").split("/").filter(Boolean); + for (let len = pathParts.length - 1; len >= 1; len--) { + const parentPath = "/" + pathParts.slice(0, len).join("/"); + const parentItem = getValueAtPath(metadata, parentPath); + if (parentItem && typeof parentItem === "object" && !Array.isArray(parentItem)) { + const parentName = (parentItem as Record)["name"] as string | undefined; + if (parentName && nameDiscriminatorMap.has(parentName)) { + discriminatorAllowedValues = nameDiscriminatorMap.get(parentName); + break; + } + } + } + + // If the value is actually valid for the intended discriminated branch, suppress + // (this error came from the wrong anyOf branch) + if (discriminatorAllowedValues && discriminatorAllowedValues.includes(invalidValue)) { + return null; + } + + const effectiveAllowedValues = discriminatorAllowedValues ?? ajvAllowedValues; + const MAX_SHOW = 6; + const suggestion = effectiveAllowedValues?.length + ? `Allowed: ${effectiveAllowedValues.slice(0, MAX_SHOW).map(v => JSON.stringify(v)).join(", ")}${effectiveAllowedValues.length > MAX_SHOW ? ` … (+${effectiveAllowedValues.length - MAX_SHOW} more)` : ""}` + : undefined; + + return { + ...e, + ...(invalidValue !== undefined ? { invalidValue } : {}), + ...(suggestion ? { suggestion } : {}), + ...(effectiveAllowedValues?.length ? { allowedValues: effectiveAllowedValues } : {}), + }; + } + return e; + }).filter(Boolean) as Record[]; + publishMessage("POST", "validate", `Validation failed for ${inputSchema}:${inputVersion}`).catch(console.error); + return Response.json({ error: "metadata validation failed", details: enrichedErrors, data: metadata }, { status: 400 }); + } + + publishMessage("POST", "validate", `Validated as ${inputSchema}:${inputVersion}`).catch(console.error); + return Response.json({ details: "all ok" }); +} diff --git a/package.json b/package.json index c178a86..0903007 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "type": "module", "scripts": { - "build": "react-router build", + "build": "react-router build && node scripts/build-openapi.mjs", "dev": "react-router dev", "start": "PORT=${PORT:-3001} node --env-file-if-exists=.env node_modules/.bin/react-router-serve ./build/server/index.js", "test": "vitest run --passWithNoTests --exclude '**/node_modules/**' --exclude 'src/**'", diff --git a/scripts/build-openapi.mjs b/scripts/build-openapi.mjs new file mode 100644 index 0000000..8567411 --- /dev/null +++ b/scripts/build-openapi.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import swaggerJsdoc from "swagger-jsdoc"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const outFile = resolve(root, "build", "openapi.json"); + +const definition = JSON.parse( + await readFile(resolve(root, "app", "lib", "openapi-definition.json"), "utf8") +); + +const spec = swaggerJsdoc({ + definition, + apis: [resolve(root, "app", "routes", "api", "*.ts")], +}); + +const pathCount = Object.keys(spec.paths ?? {}).length; +if (pathCount === 0) { + process.stderr.write( + "build-openapi: swagger-jsdoc produced a spec with no paths. " + + "Refusing to write an empty spec — check the JSDoc blocks in app/routes/api/.\n" + ); + process.exit(1); +} + +await mkdir(dirname(outFile), { recursive: true }); +await writeFile(outFile, `${JSON.stringify(spec, null, 2)}\n`, "utf8"); +process.stdout.write(`build-openapi: wrote build/openapi.json (${pathCount} paths)\n`);