Skip to content

Harden the ddi-emitter path: allowlist, error handling, examples, lossless pass-through (#10, #11, #13, #14) - #16

Merged
jstet merged 3 commits into
mainfrom
issue-10-11-13-14-sidecar
Sep 25, 2026
Merged

jstet merged 3 commits into
mainfrom
issue-10-11-13-14-sidecar

Conversation

@jstet

@jstet jstet commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Closes #10, closes #11, closes #13, closes #14.

Summary

#10: enforce formtransform's allowlist. buildDdiXml converts any input it gets; the allowlist lives in XLSValidator.validateSubset. ddi-emitter/server.mjs now calls validateSubset(…, { target: 'ddi' }) first. rank, geopoint and selects without choices now get a 400 with the library's message instead of plausible-looking but wrong DDI. A form of only notes (empty <dataDscr>) is also a 400 now; it used to be a 200 with an empty element. All 9 built-in examples pass the check.

#11: error detail and 503. /api/convert/xlsform-to-ddi now includes the library's message in the 400 response ("…question "a"…"). A sidecar that is down or failing now gives a logged 503. Before, it gave a 400. converter.ErrConverterUnavailable tells server-side failures apart from input errors.

#13: examples. /api/examples returns 503 while the sidecar is down; before, it returned 200 [], and /{type} returned 404. A failure is remembered for 30s, so requests during an outage don't each rerun every conversion while holding the lock. An example the converter rejects is logged and left out; the other examples are still served.

#14: lossless pass-through and a real equivalence test.

  • ddi_client.go copies the <dataDscr> children as a token stream instead of decoding them into DDIVar/DDIVarGrp and encoding them again. Only the namespace and the files attributes are removed; files points at the <fileDscr> that isn't in the fragment.
  • With formtransform v0.1.7 the struct round-trip happened not to lose anything for the tested inputs. This change protects against formtransform emitting elements qwacback doesn't model.
  • scripts/equivalence-test.mjs now compares the full normalized <var>/<varGrp> content, not just the shape. It has 4 new cases: a note before a question, hint/guidance_hint, relevant/required, and special characters.
  • New .github/workflows/test.yml runs go test ./... with NATS, the published worker and the sidecar up, and fails if any test skips. It then runs the equivalence test against a qwacback built from this branch.

CONVERSION_API.md documents the 400/503 behavior, the pass-through, and that hint/guidance_hint are currently dropped (#12, needs a formtransform fix).

Verification

  • go vet ./... is clean.
  • With the sidecar, NATS and schematron-worker:v0.1.7 running locally: go test ./... -v gave 111 passed, 0 skipped, 0 failed.
  • The new client, route and examples tests use a fake sidecar, so they also run in the Dockerfile's go test.
  • Equivalence test against this branch: 17/17 identical.
  • I checked the stricter comparison with a stand-in formtransform that adds one <ivuInstr>: 3 cases fail. The old shape-only check passed them.
  • Not run yet: test.yml on GitHub Actions. I ran its steps by hand on my machine.

test.yml doesn't include the pin check from #15, because that script lands there. Add the step once both are merged.

🤖 Generated with Claude Code

jstet and others added 3 commits September 25, 2026 16:37
…errors (#10, #11, #14)

ddi-emitter: run XLSValidator.validateSubset(…, { target: 'ddi' }) before
buildDdiXml. buildDdiXml alone converts anything, so `rank`, `geopoint`
and selects without choices came out as plausible but wrong DDI. Their
messages now come back as a 400.

ddi_client.go:
- Cut the <dataDscr> children out of formtransform's <codeBook> as a token
  stream, not by decoding into DDIVar/DDIVarGrp and marshalling again. The
  structs dropped everything they don't model (notes, valrng, universe,
  xml:lang, …) and reordered varGrps before vars. Only the DDI namespace
  and the dangling `files` IDREFs are removed.
- An empty <dataDscr> (a form of only notes) is an input error, not a 200
  with an empty element.
- ErrConverterUnavailable marks sidecar outages, bad status codes and
  malformed responses. All other errors describe the input and carry the
  library's message without a prefix.

routes.go: the convert endpoint passes input errors on as the 400 message,
as #3 asked for, and answers 503 (logged) when the sidecar is unavailable.

Unit tests for the client and the route use a fake sidecar, so they run
without docker-compose.

Closes #10
Closes #11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#13)

When populate failed, /api/examples answered 200 [] and
/api/examples/{type} 404, nothing was logged, and every request took the
exclusive lock and re-ran all conversions against the dead sidecar.

- GetAll/GetByType return an error while the sidecar is unavailable; both
  routes answer 503.
- populate stops at the first ErrConverterUnavailable and the failure is
  remembered for 30s, so requests during an outage don't hammer the
  sidecar or queue behind each other.
- An example the converter rejects is logged and left out instead of
  emptying the whole list.

Closes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scripts/equivalence-test.mjs only compared var/group shape (counts, IDs,
intrvl, responseDomainType), so lost text, categories or instructions
couldn't fail it. It now compares every <var>/<varGrp> after normalizing
serializer differences only: whitespace, self-closing tags, entity
spelling, `files` and namespace declarations. Checked with a shim that adds
one <ivuInstr>: 3 cases fail, where the shape check passed.

- New cases: note before a question (a note alone is now a 400),
  hint/guidance_hint (#12), relevant/required, special characters.
- Defaults to the formtransform that ddi-emitter pins, not a /tmp path.
- QWACBACK_DELAY_MS overrides the request spacing.

17/17 identical against this branch.

New .github/workflows/test.yml runs `go test ./...` with NATS, the
published schematron-worker and ddi-emitter up, fails on any skipped test,
then runs the equivalence test against a qwacback built from the branch.
Until now the integration and sidecar tests skipped everywhere, including
the Dockerfile's `go test`.

Closes #14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment