Skip to content

fix(GAT-9591): restore API contract parity with production - #129

Closed
calmacx wants to merge 7 commits into
feat/GAT-9591-api-routesfrom
feat/GAT-9591-api-contract-fixes
Closed

calmacx wants to merge 7 commits into
feat/GAT-9591-api-routesfrom
feat/GAT-9591-api-contract-fixes

Conversation

@calmacx

@calmacx calmacx commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

What

Fixes the five API contract divergences that replaying the production fixtures against the ported routes turned up: a missing GET /status, a lost errors[] envelope on /translate and /validate, two dropped query/body validations on /translate, and /get/map returning 400 where production returned 200.

Why

These are live contracts. A consumer doing errors[0].msg was getting a TypeError; one branching on res.ok for /get/map was taking the error path instead of seeing translation_map: null. Kept separate from the port PR so that one stays reviewable as a verbatim port and this one is reviewed as behaviour.

Testing

Fixture replay against production goes from 6 exact / 2 status-mismatch to 13 exact / 0 status-mismatch. /get/map verified on all three branches: direct pair → map populated, path null; multi-hop → 4-node path with 3 real templates; unroutable → 200 with all three fields null. Boolean flag matrix (1/0/true/false/empty accepted, 2/yes → 400 with production's exact message) checked case by case. npm run typecheck, npm run lint and npm run build exit 0.

Notes

  • Restoring the errors[] envelope meant reproducing an express-validator quirk, not just the shape: a missing metadata key trips isObject() and notEmpty() separately and yields two identical entries, while a non-object non-empty value yields one.
  • Accepting true/false for validate_input / validate_output is a deliberate widening — production took only "1"/"0". No fixture sends them and "2" still errors identically, so the baseline is unaffected, but it is a real divergence and is recorded as one.
  • /get/map gains translation_path and translation_maps. Both are additive; no field production returned changed name or type.

@gh-actions-pipelines-app

Copy link
Copy Markdown

🎉 Great job! Your PR title follows the correct format. 🚀

@calmacx
calmacx added this pull request to stack #131 September 17, 2026 09:59
Captures the parity baseline for the Express -> React Router v7 rewrite
(GAT-9016): what production TRASER actually returns today, across 6 real
ACTIVE Gateway datasets plus 20 dataset-independent calls.

Adds scripts/harvest-regression-fixtures.mjs (plain Node ESM, node: builtins
only, no package.json change) and the 104 fixtures it produced. The replay
harness that asserts against them is a later PR in the migration stack.

The sample is deliberately small — 112 files, ~900 KB. Parity drift is
per-code-path, not per-dataset, so many datasets sharing an input schema
exercise one code path many times over. Six datasets are the minimum that
still span every input-schema bucket production yields: HDRUK 3.0.0 x2,
HDRUK 4.0.0 x2, GWDM 2.0 x1 (canonical form) and one input matching no known
schema. Every behavioural class from a wider trial harvest survives at this
size: the working targets (GWDM 2.0, GWDM 2.1, SchemaOrg GoogleRecommended),
both known-failing 500 paths, and the blanket CRUK rejection.

Model and version live in the directory path, not the filename:
cases/translate/{Schema}/{Version}/[{variant}/]{pid}__{form}.json. The
filename carries only the dataset and which form of it was used.

Thirteen fixtures under cases/common/err/ cover the express-validator error
envelope specifically. The rewrite replaces express-validator with
hand-rolled shaping in app/lib/errors.server.ts, making the 4xx envelope the
highest-drift-risk surface in the migration; it previously had no baseline
coverage at all.

stratify() alternates the rarest shape bucket with the most common one.
Ordering rarest-first alone starved the dominant GWDM 2.0 shape out of a
small sample; the alternating order spans both.

Five datasets carrying named-individual contact emails are excluded on
data-protection grounds, each with its reason recorded in index.json. The
addresses remaining are role or organisational mailboxes already published
on the Gateway, plus fictional placeholders.

The script carries no explanatory comments, per house style. What a reader
needs — the two-hash scheme, the envelope rules, what the sample is and is
not, and which validator each error fixture trips — is in
tests/data/regression-fixtures/README.md.
Lands the React Router v7 build toolchain on the GAT-9016 integration branch
so that everything above it can have green CI: package.json + lockfile,
tsconfig, vite and react-router config, Dockerfiles, public assets, a minimal
app/root.tsx shell, an empty app/routes.ts, an ESLint 9 flat config, and CI
wiring.

React Router cannot build without a root module, and routes.ts hard-fails on
any entry whose file is missing, so the toolchain has to land as one floor
before any application code. The root.tsx here is a deliberate stub — Layout,
Outlet and ErrorBoundary only, no loader, no middleware, no ~/lib import. The
real one arrives with the UI shell: it dynamically imports auth.server,
retention.server and schema.server, which Vite resolves statically at build
time, so it cannot land before the background-jobs PR.

Three fixes beyond the toolchain itself:

- npm start bound port 3000, not 3001. react-router-serve defaults to 3000 and
  does not read .env at runtime (dotenv is loaded by vite.config.ts, which only
  runs at build/dev time). The chart routes targetPort 3001 and the old Express
  service defaulted to 3001, so shipping this as-is would have deployed a
  container listening on the wrong port. The start script now defaults PORT to
  3001, still overridable from the environment.

- env.example on the POC branch points SCHEMA_LOCATION at HDRUK/schemata; dev
  correctly uses HDRUK/schemata-2. CI copies this file, so the POC value would
  have pointed schema loading at the wrong repo. Kept dev's value and carried
  over the POC additions.

- The Express service called require('dotenv').config() at startup
  (src/app.js:15), so .env was read by the running server. In the rewrite
  dotenv is only invoked from vite.config.ts, which does not run under
  react-router-serve, so a built server saw none of its configuration: with
  SCHEMA_LOCATION unset the schema loader falls back to a relative path, tries
  to open /available.json, and every schema-backed endpoint 500s. npm start now
  uses node --env-file-if-exists, so a deployment with no .env file
  (Kubernetes, where config arrives as real environment variables) is
  unaffected. This does not remove the need to populate deployment.yaml, which
  currently sets no environment variables at all.

CI gains Typecheck, Lint and Build steps, and runs the suite against a started
server. Its branch filters match feat/GAT-* so that every PR in the migration
stack is covered regardless of which ticket it lands under.
Ports the ten app/lib modules that make up the translation engine (layers
L0-L4) verbatim from poc/GAT-XXXX: TTL cache, dataset cache, diff, error
shaping, audit, schema/AJV, templates, translation graph, translate, and the
re-export barrel.

The set is closed under its own dependencies — every relative import resolves
to another module in the set — so it compiles and typechecks standalone with
no stubs and no forward references. Internal edges are exactly:
schema->ttlCache, templates->ttlCache, graph->templates,
translation->{schema,templates,graph}, traser->{schema,translation}.

Nothing is reachable over HTTP: no app/routes file and no routes.ts entry.
That keeps this review about engine semantics rather than HTTP contracts.

Engine exercised directly without HTTP. All four schema families load
(19 schemas, 0 failures). Single-hop HDRUK 2.1.2 -> GWDM 1.0 translates clean.
Multi-hop chains genuinely route via GWDM: HDRUK 4.0.0 -> SchemaOrg
GoogleRecommended is 3 nodes, HDRUK 2.1.2 -> CRUK 1.0.0 is 5. getPath to an
unreachable node returns a 400 object rather than throwing.

Two things the plan expected that turned out otherwise, both recorded rather
than worked around:

- The build does not exercise Vite's ssr.external handling of the CJS-only
  packages. With nothing importing app/lib, build/server/index.js is byte-for
  -byte the scaffolding bundle and contains no ajv or jsonata at all. That
  config is first exercised by the API routes PR. The CJS interop is instead
  verified here at runtime via vite-node, which does load the modules through
  the SSR pipeline.

- validateMetadata mutates its input. The documented "rewrite structuredClones
  before validating" divergence is not implemented anywhere on the POC branch:
  the sole structuredClone is inside findMatchingSchemas, which needs it to
  probe candidate schemas. The regression harness must not build its
  comparison predicate around a divergence that may not exist.

The ported files keep the comments they arrived with. Nothing was added.
@calmacx
calmacx force-pushed the feat/GAT-9591-api-routes branch from 3bfa760 to c478de7 Compare September 17, 2026 10:06
@calmacx
calmacx force-pushed the feat/GAT-9591-api-contract-fixes branch from aed7d07 to 45ea12e Compare September 17, 2026 10:06
@calmacx
calmacx removed this pull request from stack #131 September 17, 2026 10:54
@calmacx
calmacx added this pull request to stack #133 September 17, 2026 10:54
cache.server.ts and diff.server.ts have no counterpart in the Express
service being migrated: src/ has no dataset cache, and nothing under
src/routes reads or writes a data/ directory. They exist only to serve
the admin tooling built on the POC branch.

WS1 was rescoped to the Express migration, its parity proof and the docs
page, so both modules move to WS4 PR01 along with the endpoints and pages
that call them. After this commit nothing in app/ or tests/ imports
either one, so the cut is clean rather than an untangling.

diff.server.ts had exactly one consumer, benchmark.server.ts, which is
itself WS4.

Refs: upgrade-plans/07-strip-admin-surface-from-ws1.md
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.
/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
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.
@calmacx
calmacx force-pushed the feat/GAT-9591-api-routes branch from c478de7 to 39a726d Compare September 21, 2026 09:36
@calmacx
calmacx force-pushed the feat/GAT-9591-api-contract-fixes branch from 45ea12e to 1b6791b Compare September 21, 2026 09:36
@calmacx
calmacx force-pushed the feat/GAT-9591-api-routes branch from 39a726d to 6a7a699 Compare September 21, 2026 14:12
@calmacx

calmacx commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded — folded into the API routes PR. The five contract fixes (/status, the express-validator errors[] envelope, validate_input/validate_output coercion, extra type checking, and /get/map returning 200 for a multi-hop route) now ship as a commit on feat/GAT-9591-api-routes rather than as a separate PR.

This PR only existed because #128 was already open when the divergences were found, and its rationale was the production fixture replay — which is being removed from the repo. Closing rather than rebasing.

@calmacx calmacx closed this Sep 21, 2026
@calmacx
calmacx removed this pull request from stack #133 September 22, 2026 08:33

This branch was successfully deployed

1 active deployment
dev — 1b6791ba Deployed Sep 21, 2026 by calmacx via testing #698
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant