Refactored the generator into explicit abstractions and typed the JavaScript surface - #7
Andrzej-Swietek wants to merge 17 commits into
Conversation
pkurcx
left a comment
There was a problem hiding this comment.
Thanks, this is a careful refactor and the structure is a clear improvement. I built both this head and its base commit (1f1944e) and ran every fixture in test/fixtures through both CLIs: generated files, stdout, stderr and exit codes are identical for all 73, except the disclosed duplicate-schema-name wording. That covers the ten newly pinned fixtures too, which the new snapshots alone cannot prove. Also confirmed: fmt/clippy/cargo test clean (305), typecheck/lint/coverage/ava clean (345), and all ten commits compile standalone.
Two things need fixing before merge, and one rebase hazard:
- Runtime enums are declared but not exported.
index.d.tsnow promisesInputFormatandResponseTypeas runtime constants;lib/index.jsandlib/browser.jsexport onlygenerate,GenerateError,EmitTarget.InputFormat.Yaml(shown in the node-api docs) type-checks and throws at runtime. Under the oldconst enumit was inlined and worked. Inline comment below. - Inline-object depth is charged twice per level in
schema/mod.rs, roughly halving the nesting cap. Inline comment below. - Rebase onto main.
mainhas moved (v0.6.0, #8): 20 files conflict. #8 also added aLayoutconst-enum rewrite toscripts/patch-types.mjs, which this PR deletes, so that rewrite has to be ported intopatch-types.tsor it silently disappears. The ten new snapshot dirs need regenerating under the newmodel.ts/rest/<tag>.rest.tsnames.
Smaller behaviour changes worth listing in the description, since they are not covered by the byte-identical claim:
- JSON inputs with a repeated key were last-wins on the base and now fail with
E_INPUT_INVALID(no subcode, so a duplicate schema name in JSON never getsduplicate-schema-name). - A repeated key under
pathschanges code fromE_POLICY_VIOLATIONtoE_INPUT_INVALID, not only its message. default_groupnow returnsDefaultfortags: [""]where the base fell through to the path segment.- Re-export lines (
export type { A as X, ... } from) now wrap at 100 columns.
Non-blocking: a few why-comments dropped in e60917d are worth restoring (the Params-suffix collision rationale in plan/naming/fixed.rs, the verbatim-brace-instead-of-panic note in emit/angular/request.rs, the alias-collision note in emit/model/emit_ts_models.rs). The Emitter trait has one impl per target and no second implementation planned, so a plain function would carry less indirection; fine to keep if you prefer the shape.
| Yaml = 'yaml' | ||
| } | ||
| export type InputFormat = 'json' | 'yaml'; | ||
| export declare const InputFormat: { |
There was a problem hiding this comment.
This declares a runtime value, but lib/index.js and lib/browser.js do not export InputFormat (or ResponseType). Verified on this head:
node -e "console.log(require('./lib/index.js').InputFormat)" # undefined
A consumer writing inputFormat: InputFormat.Yaml, as website/src/content/docs/reference/node-api.md shows, compiles under strict + isolatedModules and throws at runtime. The old const enum was inlined to 'yaml', so this is a regression rather than the strictly-more-permissive change the description states.
Fix: export frozen mirrors the way EmitTarget already is, re-export them from browser.d.ts, and widen the allow-list in __test__/package.spec.ts (public surface is a fixed allow-list), which currently pins the export set and would have caught this if the enums had been added there.
| )?))) | ||
| } | ||
| Some("object") => Ok(SchemaType::InlineObject { | ||
| properties: normalize_properties(schema, walk.item())?, |
There was a problem hiding this comment.
Depth is now charged twice per inline-object level: walk.item() here, then walk.property(name) inside normalize_properties before normalize_type runs check_depth. The base charged one unit (normalize_properties(.., depth + 1, ..) and then normalize_schema_raw(.., depth, ..) per property), so the effective cap for nested inline objects roughly halves. Arrays, compositions and maps are unaffected. The existing depth test uses arrays only, so it does not catch this. Passing walk instead of walk.item() restores the old accounting and matches walk.rs's contract that each constructor descends exactly one level.
| /// measured by re-serialising the node tree. A source this cannot parse | ||
| /// passes, leaving the typed parse to report the error. | ||
| fn check_anchor_expansion(source: &str, display_path: &Rc<str>) -> Result<(), Diagnostic> { | ||
| let Ok(value) = serde_yml::from_str::<serde_yml::Value>(source) else { |
There was a problem hiding this comment.
Returning Ok when the Value parse fails opens a gap: serde_yml 0.0.12's Value rejects duplicate mapping keys, while the typed parse accepts them last-wins inside the serde_json::Value-typed fields (example, enum). A document with one repeated key inside example plus a fanned-out anchor skips this guard and decodes; the base rejected it (mislabelled, but fatal). serde_yml's own repetition limit still stops large bombs, so the impact is bounded, but the guard is no longer total. Options: fail on a duplicate-key Value error, or fall back to a cheap alias-count check when the Value parse fails.
| /// under `components.schemas` carries the `duplicate-schema-name` subcode so | ||
| /// consumers can route on it; anything else is a plain decode failure. | ||
| fn decode_failure(message: &str, display_path: &Rc<str>) -> Diagnostic { | ||
| if message.contains(DUPLICATE_KEY) && message.contains(SCHEMAS_FIELD_PATH) { |
There was a problem hiding this comment.
contains("components.schemas") also matches nested paths, so components.schemas.Pet.properties: duplicate key 'id' and a repeated discriminator.mapping key are still reported as duplicate-schema-name with "Each schema name must be declared once." Same behaviour as the base, but the description presents the misrouting as fixed. Matching the prefix components.schemas: would make it exact.
Related: for JSON input the same defect surfaces as plain E_INPUT_INVALID with no subcode (serde_json carries no field path), so the subcode is format-dependent. Worth a line in the description at least.
| # duplicate-mapping-key rejection, which the duplicate-schema-name and | ||
| # mapping-expansion-exceeded diagnostics both read out of serde_yml errors. | ||
| # Pinned: 0.0.13 swapped its YAML backend and dropped the line/column suffix | ||
| # from decode errors, which every `E_INPUT_INVALID` message forwards verbatim. |
There was a problem hiding this comment.
Checked 0.0.13: it drops the line/column suffix and also the field.path: prefix on decode errors. decode_failure above depends on that prefix to route duplicate-schema-name, so the pin has two reasons now; worth naming both here.
| .tags() | ||
| .first() | ||
| .map(String::as_str) | ||
| .or_else(|| ctx.lookup_indexed("pathSegments", 0)) |
There was a problem hiding this comment.
or_else only fires when tags() is empty, so tags: [""] yields Default here where the base fell through to the path segment (Pets for /pets). Tags are copied unfiltered from the spec. Low frequency, but a behaviour change with no test; filtering the tag before or_else restores the old result.
| const first = await generate({ inputPath: fixture(name), emit: [...DEFAULT_EMIT] }); | ||
| const second = await generate({ inputPath: fixture(name), emit: [...DEFAULT_EMIT] }); | ||
| const third = await generate({ inputPath: fixture(name), emit: [...DEFAULT_EMIT] }); | ||
| const [first, second, third] = await Promise.all( |
There was a problem hiding this comment.
Running the three generations concurrently weakens what this test checks: state leaking from run 1 into run 2 is only observable when they run in sequence, and a separate concurrency test already exists further down. Suggest keeping these sequential.
| const binding = await load(); | ||
| const [prepared, binding] = await Promise.all([ | ||
| prepareOptions(options, unreachableFetch), | ||
| load(), |
There was a problem hiding this comment.
prepareOptions does no I/O in the browser (URL inputs are rejected earlier), so Promise.all gains nothing here, and it changes behaviour slightly: invalid options now trigger the wasm download before failing, and if both reject the first to settle wins. Sequential was simpler and had the stricter ordering. Non-blocking.
| [ | ||
| EMIT_TARGET_UNION, | ||
| 'export declare const EmitTarget: {', | ||
| " readonly Models: 'models';", |
There was a problem hiding this comment.
Heads-up for the rebase: main (#8) added a Layout const-enum rewrite to scripts/patch-types.mjs, which this branch deletes. Git will resolve that as a modify/delete conflict and the rewrite is gone unless it is ported here as a fourth pattern.
…ed must-use coverage
…h iterator chains
…ents onto one line
Summary
The generator was correct but hard to change safely: no traits, diagnostics
threaded as
&mutthrough 22 signatures, schema depth counted by conventionat each call site, four files past 400 lines. Two checks were wrong: a
repeated key under
pathsreported as a duplicate schema name, and thefixture list
regen-snapshotsread had drifted fromtest/fixtures/.Nothing type-checked the published JavaScript or the tests.
Behaviour changes
Generated output is unchanged. These are not covered by that claim:
pathsnow fails asE_INPUT_INVALID, notE_POLICY_VIOLATION— caught while decoding, before the policy pass.serde_jsoncarries no field path, so in JSON a duplicate schema name gets no
duplicate-schema-namesubcode; YAML still routes it.duplicate-schema-namenow matchescomponents.schemas:exactly, so anested path like
Pet.propertiesis a plain decode failure.Valueparse failed on aduplicate key.
rest/.rest.tsandrest//index.ts.InputFormatandResponseTypestop beingconst enumand are exportedas runtime constants from both entries.
build:debugnow runspostbuild.Verified
131 pre-existing snapshots unchanged; CLI output identical across 21
invocations against the base;
templates/,browser.d.tsandnative.jsbyte-identical to
main. 325 Rust tests, 398 ava, clippy and threetypecheck projects clean. YAML throughput 1.6–2.2x, JSON unchanged — the win
is the removed double parse.
One open question:
rest-util-operations.spec.tsassertsbound.request({}, { injector })whilestandalone-proof.tscarries@ts-expect-error — the bound .request() takes no options.templates/isleft as #8 wrote it; the test casts.