From bb8436bddf7806046b8fa73b9bc86901ff43f631 Mon Sep 17 00:00:00 2001 From: Calum Macdonald Date: Wed, 16 Sep 2026 15:58:45 +0100 Subject: [PATCH 1/3] feat(GAT-9591): port the JSON API resource routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the twelve JSON API resource routes into app/routes/api/ byte-identical to poc/GAT-XXXX and registers them in app/routes.ts: /translate, /validate, /find, /list/{schemas,templates,translations,datasets}, /get/{schema,map,form_hydration,dataset} and /openapi.json. These are the live contracts Gateway API, gateway-web-2 and the federation service call today. They land together because all twelve depend only on the ported module set, and because the spec endpoint enumerates its siblings. Also fixes /openapi.json for production. openapi.json.ts globbed app/routes/api/*.ts at runtime, but Dockerfile.prod's final stage copies only package.json, package-lock.json, node_modules and build/ -- app/ is not in the image, so the glob matched nothing and the spec shipped with zero paths. Local runs and CI could never see it because the repo is the working directory. The swagger definition moves to app/lib/openapi-definition.json so that the route and a new scripts/build-openapi.mjs share one source, and npm run build now generates build/openapi.json after react-router build (which clears build/). The generator exits non-zero rather than writing a spec with no paths, so the failure can never be silent again. The loader reads that artefact; outside production it falls back to globbing sources so npm run dev still works, and in production it throws rather than serving an empty spec. The @openapi JSDoc annotations are untouched — they are the generator's input, not commentary. Only when and where the spec is compiled has changed. --- app/lib/openapi-definition.json | 39 ++++ app/routes.ts | 17 +- app/routes/api/find.ts | 85 +++++++ app/routes/api/get.dataset.ts | 27 +++ app/routes/api/get.form_hydration.ts | 102 +++++++++ app/routes/api/get.map.ts | 131 +++++++++++ app/routes/api/get.schema.ts | 68 ++++++ app/routes/api/list.datasets.ts | 7 + app/routes/api/list.schemas.ts | 32 +++ app/routes/api/list.templates.ts | 23 ++ app/routes/api/list.translations.ts | 81 +++++++ app/routes/api/openapi.json.ts | 28 +++ app/routes/api/translate.ts | 328 +++++++++++++++++++++++++++ app/routes/api/validate.ts | 202 +++++++++++++++++ package.json | 2 +- scripts/build-openapi.mjs | 31 +++ 16 files changed, 1200 insertions(+), 3 deletions(-) create mode 100644 app/lib/openapi-definition.json create mode 100644 app/routes/api/find.ts create mode 100644 app/routes/api/get.dataset.ts create mode 100644 app/routes/api/get.form_hydration.ts create mode 100644 app/routes/api/get.map.ts create mode 100644 app/routes/api/get.schema.ts create mode 100644 app/routes/api/list.datasets.ts create mode 100644 app/routes/api/list.schemas.ts create mode 100644 app/routes/api/list.templates.ts create mode 100644 app/routes/api/list.translations.ts create mode 100644 app/routes/api/openapi.json.ts create mode 100644 app/routes/api/translate.ts create mode 100644 app/routes/api/validate.ts create mode 100644 scripts/build-openapi.mjs 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..1edcf83 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -1,3 +1,16 @@ -import type { RouteConfig } from "@react-router/dev/routes"; +import { type RouteConfig, route } from "@react-router/dev/routes"; -export default [] satisfies RouteConfig; +export default [ + 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"), + route("/list/datasets", "routes/api/list.datasets.ts"), + route("/get/dataset", "routes/api/get.dataset.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.dataset.ts b/app/routes/api/get.dataset.ts new file mode 100644 index 0000000..1c95963 --- /dev/null +++ b/app/routes/api/get.dataset.ts @@ -0,0 +1,27 @@ +// Internal endpoint — intentionally undocumented (omitted from the OpenAPI/Swagger spec). +import { readFile } from "fs/promises"; +import path from "path"; +import { extractMetadata, getDataDir } from "~/lib/cache.server"; + +export async function loader({ request }: { request: Request }) { + const url = new URL(request.url); + const pid = url.searchParams.get("pid"); + + if (!pid) { + return Response.json({ message: "pid query param is required" }, { status: 400 }); + } + + try { + const content = await readFile(path.join(getDataDir(), `${pid}.json`), "utf-8"); + const data = JSON.parse(content); + const metadata = extractMetadata(data); + + if (!metadata) { + return Response.json({ message: `No metadata found for pid ${pid}` }, { status: 404 }); + } + + return Response.json(metadata); + } catch { + return Response.json({ message: `Dataset ${pid} not found` }, { status: 404 }); + } +} 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..6d84eaa --- /dev/null +++ b/app/routes/api/get.map.ts @@ -0,0 +1,131 @@ +/** + * @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 { 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 (!inputSchema) + paramErrors.push(fieldError("Invalid value", "input_schema", "query")); + if (!inputVersion) + paramErrors.push(fieldError("Invalid value", "input_version", "query")); + if (!outputSchema) + paramErrors.push(fieldError("Invalid value", "output_schema", "query")); + if (!outputVersion) + paramErrors.push(fieldError("Invalid value", "output_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!, + ); + if (!template) { + const notImplemented = `Translation for ${inputSchema}-${inputVersion} to ${outputSchema}-${outputVersion} is not implemented`; + publishMessage( + "GET", + "get/map", + `Failed to retrieve mapping for ${inputSchema}-${inputVersion} to ${outputSchema}-${outputVersion}`, + ).catch(console.error); + return Response.json( + { + error: "Translation not found", + message: notImplemented, + details: notImplemented, + }, + { status: 400 }, + ); + } + + 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, + }); +} 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.datasets.ts b/app/routes/api/list.datasets.ts new file mode 100644 index 0000000..766b280 --- /dev/null +++ b/app/routes/api/list.datasets.ts @@ -0,0 +1,7 @@ +// Internal endpoint — intentionally undocumented (omitted from the OpenAPI/Swagger spec). +import { getDatasetIndex } from "~/lib/cache.server"; + +export async function loader() { + const datasets = await getDatasetIndex(); + return Response.json(datasets); +} 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/translate.ts b/app/routes/api/translate.ts new file mode 100644 index 0000000..4a41ff0 --- /dev/null +++ b/app/routes/api/translate.ts @@ -0,0 +1,328 @@ +/** + * @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 { forwardKnownError, errorResponse } from "~/lib/errors.server"; + +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 validateInput = url.searchParams.get("validate_input") !== "0"; + const validateOutput = url.searchParams.get("validate_output") !== "0"; + 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; + if (!metadata || typeof metadata !== "object") { + return Response.json( + { message: "metadata must be a non-empty object" }, + { status: 400 }, + ); + } + + 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..6621dd3 --- /dev/null +++ b/app/routes/api/validate.ts @@ -0,0 +1,202 @@ +/** + * @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")); + // Reject missing / non-object / array metadata; an empty object `{}` is allowed + // through to AJV, which reports the missing required fields. + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + 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`); From 5e392f63f432b990c4171ad0de5ddc8b8005427f Mon Sep 17 00:00:00 2001 From: Calum Macdonald Date: Mon, 21 Sep 2026 10:33:07 +0100 Subject: [PATCH 2/3] refactor(GAT-9591): move the dataset endpoints out of WS1 /list/datasets and /get/dataset read the dataset file cache, which the Express service does not have: src/routes/list.js serves only templates, schemas and translations, and src/routes/get.js only schema, map and form_hydration. Both endpoints are new surface built for the admin tooling, so they move to WS4 PR01 with cache.server.ts. Neither carried an @openapi block, so the generated spec is unchanged by this commit. Refs: upgrade-plans/07-strip-admin-surface-from-ws1.md --- app/routes.ts | 2 -- app/routes/api/get.dataset.ts | 27 --------------------------- app/routes/api/list.datasets.ts | 7 ------- 3 files changed, 36 deletions(-) delete mode 100644 app/routes/api/get.dataset.ts delete mode 100644 app/routes/api/list.datasets.ts diff --git a/app/routes.ts b/app/routes.ts index 1edcf83..de53d90 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -11,6 +11,4 @@ export default [ 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"), - route("/list/datasets", "routes/api/list.datasets.ts"), - route("/get/dataset", "routes/api/get.dataset.ts"), ] satisfies RouteConfig; diff --git a/app/routes/api/get.dataset.ts b/app/routes/api/get.dataset.ts deleted file mode 100644 index 1c95963..0000000 --- a/app/routes/api/get.dataset.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Internal endpoint — intentionally undocumented (omitted from the OpenAPI/Swagger spec). -import { readFile } from "fs/promises"; -import path from "path"; -import { extractMetadata, getDataDir } from "~/lib/cache.server"; - -export async function loader({ request }: { request: Request }) { - const url = new URL(request.url); - const pid = url.searchParams.get("pid"); - - if (!pid) { - return Response.json({ message: "pid query param is required" }, { status: 400 }); - } - - try { - const content = await readFile(path.join(getDataDir(), `${pid}.json`), "utf-8"); - const data = JSON.parse(content); - const metadata = extractMetadata(data); - - if (!metadata) { - return Response.json({ message: `No metadata found for pid ${pid}` }, { status: 404 }); - } - - return Response.json(metadata); - } catch { - return Response.json({ message: `Dataset ${pid} not found` }, { status: 404 }); - } -} diff --git a/app/routes/api/list.datasets.ts b/app/routes/api/list.datasets.ts deleted file mode 100644 index 766b280..0000000 --- a/app/routes/api/list.datasets.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Internal endpoint — intentionally undocumented (omitted from the OpenAPI/Swagger spec). -import { getDatasetIndex } from "~/lib/cache.server"; - -export async function loader() { - const datasets = await getDatasetIndex(); - return Response.json(datasets); -} From caf2cce5717ff33bc094d0e2ee6717075c68b106 Mon Sep 17 00:00:00 2001 From: Calum Macdonald Date: Wed, 16 Sep 2026 16:16:31 +0100 Subject: [PATCH 3/3] fix(GAT-9591): restore API contract parity with production Fixes the five divergences that replaying the production fixtures against the ported routes turned up. Kept separate from the port itself so that PR remains reviewable as a verbatim port and this one is reviewed as behaviour. - GET /status was missing entirely and answered 404. Express defines it (src/routes/index.js:13) and it is the likely liveness-probe target. Restored as a four-line resource route that touches no schemas, templates or cache, so it cannot fail for reasons unrelated to liveness. - /translate lost the express-validator error envelope, returning {message} instead of {message, errors:[{type,msg,path,location}]}. A consumer doing errors[0].msg got a TypeError. /validate had the same defect. Both restored, including express-validator's quirk that a missing metadata key trips isObject() and notEmpty() separately and so produces two identical entries, while a non-object non-empty value produces one. - /translate stopped validating validate_input / validate_output, silently accepting anything that was not "0". Now 1/true are true, 0/false are false, and anything else is a 400 carrying production's exact message. Accepting true/false is a deliberate widening over production, which took only "1"/"0"; no fixture sends them and "2" still errors identically. An empty value keeps defaulting to true, matching express-validator's .default("1"). - /translate stopped validating that extra is an object, forwarding a string into the JSONata binding where templates dereference extra.*. - /get/map returned 400 where production returned 200 with translation_map: null. A consumer branching on res.ok took the error path instead of seeing null. Restored to 200, and since most such pairs are still reachable by chaining templates, the response additively gains translation_path and translation_maps. Every field production returned is unchanged in name and type. Fixture replay goes from 6 exact / 2 status-mismatch to 13 exact / 0. --- app/routes.ts | 1 + app/routes/api/get.map.ts | 74 ++++++++++++++++++++++++++----------- app/routes/api/status.ts | 25 +++++++++++++ app/routes/api/translate.ts | 69 ++++++++++++++++++++++++++++++---- app/routes/api/validate.ts | 13 +++++-- 5 files changed, 149 insertions(+), 33 deletions(-) create mode 100644 app/routes/api/status.ts diff --git a/app/routes.ts b/app/routes.ts index de53d90..f552e48 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -1,6 +1,7 @@ import { type RouteConfig, route } from "@react-router/dev/routes"; 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"), diff --git a/app/routes/api/get.map.ts b/app/routes/api/get.map.ts index 6d84eaa..00746ec 100644 --- a/app/routes/api/get.map.ts +++ b/app/routes/api/get.map.ts @@ -61,6 +61,7 @@ * $ref: '#/components/schemas/ErrorMessage' */ import { getTemplate } from "~/lib/templates.server"; +import { TranslationGraph } from "~/lib/graph.server"; import { publishMessage } from "~/lib/audit.server"; import { fieldError, @@ -76,14 +77,14 @@ export async function loader({ request }: { request: Request }) { const outputVersion = url.searchParams.get("output_version"); const paramErrors: FieldError[] = []; - if (!inputSchema) - paramErrors.push(fieldError("Invalid value", "input_schema", "query")); - if (!inputVersion) - paramErrors.push(fieldError("Invalid value", "input_version", "query")); 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", @@ -99,22 +100,13 @@ export async function loader({ request }: { request: Request }) { outputSchema!, outputVersion!, ); - if (!template) { - const notImplemented = `Translation for ${inputSchema}-${inputVersion} to ${outputSchema}-${outputVersion} is not implemented`; - publishMessage( - "GET", - "get/map", - `Failed to retrieve mapping for ${inputSchema}-${inputVersion} to ${outputSchema}-${outputVersion}`, - ).catch(console.error); - return Response.json( - { - error: "Translation not found", - message: notImplemented, - details: notImplemented, - }, - { status: 400 }, - ); - } + + const { path, maps } = template + ? { path: null, maps: null } + : await resolveMultiHop( + `${inputSchema}:${inputVersion}`, + `${outputSchema}:${outputVersion}`, + ); publishMessage( "GET", @@ -126,6 +118,46 @@ export async function loader({ request }: { request: Request }) { input_version: inputVersion, output_schema: outputSchema, output_version: outputVersion, - translation_map: template, + 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/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 index 4a41ff0..b1cd1b2 100644 --- a/app/routes/api/translate.ts +++ b/app/routes/api/translate.ts @@ -118,7 +118,39 @@ import { } from "~/lib/translation.server"; import { TranslationGraph } from "~/lib/graph.server"; import { publishMessage } from "~/lib/audit.server"; -import { forwardKnownError, errorResponse } from "~/lib/errors.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(); @@ -128,8 +160,17 @@ export async function action({ request }: { request: Request }) { 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 validateInput = url.searchParams.get("validate_input") !== "0"; - const validateOutput = url.searchParams.get("validate_output") !== "0"; + 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"; @@ -142,11 +183,23 @@ export async function action({ request }: { request: Request }) { } const { metadata, extra } = body; - if (!metadata || typeof metadata !== "object") { - return Response.json( - { message: "metadata must be a non-empty object" }, - { status: 400 }, - ); + + 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 { diff --git a/app/routes/api/validate.ts b/app/routes/api/validate.ts index 6621dd3..e4eb268 100644 --- a/app/routes/api/validate.ts +++ b/app/routes/api/validate.ts @@ -111,11 +111,16 @@ export async function action({ request }: { request: Request }) { const paramErrors: FieldError[] = []; if (!inputSchema) paramErrors.push(fieldError("Invalid value", "input_schema", "query")); if (!inputVersion) paramErrors.push(fieldError("Invalid value", "input_version", "query")); - // Reject missing / non-object / array metadata; an empty object `{}` is allowed - // through to AJV, which reports the missing required fields. - if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + 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);