From e3bcc2bf1b368c48c6d5c17918f6742c6acbdde5 Mon Sep 17 00:00:00 2001
From: pkurc
Date: Fri, 22 May 2026 16:39:17 +0200
Subject: [PATCH 1/9] init
---
.bg-shell/manifest.json | 1 +
.cargo/config.toml | 5 +
.github/renovate.json | 25 +
.github/workflows/CI.yml | 359 +
.github/workflows/docs.yml | 59 +
.gitignore | 245 +
.oxfmtrc.json | 21 +
Cargo.lock | 610 ++
Cargo.toml | 44 +
LICENSE | 2 +-
README.md | 74 +-
__test__/.gitignore | 3 +
__test__/angular-consumer/package.json | 10 +
.../angular-consumer/src/base-path-proof.ts | 16 +
.../angular-consumer/src/consumer-proof.ts | 65 +
.../src/discriminator-proof.ts | 38 +
.../angular-consumer/src/emit-target-proof.ts | 22 +
.../src/form-non-json-proof.ts | 154 +
.../binary-field-rejects-string.ts | 22 +
.../src/negative-proof/negative.ts | 15 +
.../angular-consumer/src/service-proof.ts | 126 +
.../tsconfig.bench-large.json | 4 +
.../angular-consumer/tsconfig.consumer.json | 4 +
.../tsconfig.discriminator.json | 4 +
.../tsconfig.emit-target.json | 9 +
.../tsconfig.form-non-json.json | 4 +
__test__/angular-consumer/tsconfig.json | 16 +
.../tsconfig.negative-binary-field.json | 7 +
.../angular-consumer/tsconfig.negative.json | 4 +
.../angular-consumer/tsconfig.service.json | 4 +
__test__/browser.spec.ts | 35 +
__test__/cli-parse.spec.ts | 827 ++
__test__/cli.spec.ts | 789 ++
__test__/diagnostic-codes-drift.spec.ts | 80 +
__test__/fetch-input.spec.ts | 380 +
__test__/generate.snapshot.spec.ts | 471 +
__test__/generate.spec.ts | 1556 ++++
__test__/package.json | 3 +
__test__/package.spec.ts | 326 +
__test__/rest-util-base-path.spec.ts | 98 +
...operties-boolean.openapi.yaml.failure.json | 6 +
...properties-false.openapi.yaml.success.json | 22 +
.../model.generated.ts | 3 +
...ional-properties.openapi.yaml.success.json | 25 +
.../model.generated.ts | 21 +
.../rest/pet.rest.generated.ts | 16 +
...llof-composition.openapi.yaml.success.json | 47 +
.../model.generated.ts | 15 +
.../rest/adopter.rest.generated.ts | 23 +
.../anchor-fanout.openapi.yaml.failure.json | 6 +
.../anchor-modest.openapi.yaml.success.json | 22 +
.../model.generated.ts | 28 +
.../bench-large.openapi.yaml.success.json | 40 +
.../model.generated.ts | 359 +
.../rest/resource1.rest.generated.ts | 170 +
.../rest/resource2.rest.generated.ts | 170 +
.../rest/resource3.rest.generated.ts | 170 +
.../rest/resource4.rest.generated.ts | 170 +
.../rest/resource5.rest.generated.ts | 170 +
.../rest/resource6.rest.generated.ts | 170 +
...content-type-xml.openapi.yaml.failure.json | 6 +
...dy-multi-content.openapi.yaml.failure.json | 6 +
...t-composed-field.openapi.yaml.failure.json | 6 +
...art-mixed-fields.openapi.yaml.success.json | 37 +
.../rest/pet.rest.generated.ts | 38 +
...rt-nested-object.openapi.yaml.failure.json | 6 +
...ipart-non-object.openapi.yaml.failure.json | 6 +
...part-open-schema.openapi.yaml.failure.json | 6 +
...-to-named-object.openapi.yaml.success.json | 25 +
.../model.generated.ts | 4 +
.../rest/upload.rest.generated.ts | 31 +
...ded-binary-field.openapi.yaml.failure.json | 14 +
...ed-nested-object.openapi.yaml.failure.json | 6 +
...scalar-and-array.openapi.yaml.success.json | 22 +
.../rest/search.rest.generated.ts | 31 +
.../circular-allof.openapi.yaml.success.json | 22 +
.../model.generated.ts | 24 +
...eep-nested-allof.openapi.yaml.failure.json | 6 +
...eprecated-fields.openapi.yaml.success.json | 25 +
.../model.generated.ts | 15 +
.../rest/pet.rest.generated.ts | 29 +
...criminated-union.openapi.yaml.success.json | 22 +
.../model.generated.ts | 11 +
...criminator-allof.openapi.yaml.success.json | 22 +
.../model.generated.ts | 15 +
...ing-external-ref.openapi.yaml.failure.json | 6 +
...iminator-mapping.openapi.yaml.success.json | 22 +
.../model.generated.ts | 9 +
...missing-property.openapi.yaml.failure.json | 6 +
.../empty-parameter.openapi.yaml.failure.json | 6 +
.../empty-shapes.openapi.yaml.success.json | 22 +
.../model.generated.ts | 13 +
.../external-ref.openapi.yaml.failure.json | 6 +
.../field-collision.openapi.yaml.failure.json | 6 +
.../header-param.openapi.yaml.success.json | 22 +
.../rest/pet.rest.generated.ts | 25 +
.../inline-model.openapi.yaml.success.json | 22 +
.../model.generated.ts | 17 +
...inline-parameter.openapi.yaml.failure.json | 6 +
...nvalid-enum-type.openapi.yaml.failure.json | 6 +
...valid-enum-value.openapi.json.failure.json | 6 +
...doc-descriptions.openapi.yaml.success.json | 25 +
.../model.generated.ts | 19 +
.../rest/pet.rest.generated.ts | 29 +
.../large-enum.openapi.yaml.success.json | 22 +
.../model.generated.ts | 56 +
...ti-tag-operation.openapi.yaml.success.json | 25 +
.../model.generated.ts | 5 +
.../rest/pet.rest.generated.ts | 16 +
.../multi-warning.openapi.yaml.success.json | 40 +
.../model.generated.ts | 3 +
.../rest/pet.rest.generated.ts | 16 +
.../nullable-oneof.openapi.yaml.success.json | 22 +
.../model.generated.ts | 16 +
...ullable-optional.openapi.yaml.success.json | 22 +
.../model.generated.ts | 7 +
...nyof-composition.openapi.json.success.json | 36 +
.../model.generated.ts | 35 +
.../rest/adoption-request.rest.generated.ts | 24 +
.../rest/pet.rest.generated.ts | 16 +
...nyof-composition.openapi.yaml.success.json | 36 +
.../model.generated.ts | 35 +
.../rest/adoption-request.rest.generated.ts | 24 +
.../rest/pet.rest.generated.ts | 16 +
...petstore-minimal.openapi.json.success.json | 25 +
.../model.generated.ts | 1 +
.../rest/pet.rest.generated.ts | 15 +
...petstore-minimal.openapi.yaml.success.json | 25 +
.../model.generated.ts | 1 +
.../rest/pet.rest.generated.ts | 15 +
.../petstore-rich.openapi.json.success.json | 25 +
.../model.generated.ts | 24 +
.../rest/pet.rest.generated.ts | 48 +
...napi.yaml.invalid-mapped-type.failure.json | 6 +
.../petstore-rich.openapi.yaml.success.json | 25 +
.../model.generated.ts | 24 +
.../rest/pet.rest.generated.ts | 48 +
.../recursive-model.openapi.yaml.success.json | 22 +
.../model.generated.ts | 14 +
.../recursive-oneof.openapi.yaml.success.json | 22 +
.../model.generated.ts | 5 +
...erved-prop-names.openapi.yaml.success.json | 22 +
.../model.generated.ts | 8 +
...e-204-no-content.openapi.yaml.success.json | 22 +
.../rest/util.rest.generated.ts | 15 +
...nse-blob-via-pdf.openapi.yaml.success.json | 22 +
.../rest/report.rest.generated.ts | 22 +
...default-fallback.openapi.yaml.success.json | 25 +
.../model.generated.ts | 7 +
.../rest/thing.rest.generated.ts | 16 +
...nse-octet-stream.openapi.yaml.success.json | 22 +
.../rest/blob.rest.generated.ts | 22 +
...nse-problem-json.openapi.yaml.success.json | 22 +
.../rest/error.rest.generated.ts | 24 +
...t-via-text-plain.openapi.yaml.success.json | 22 +
.../rest/note.rest.generated.ts | 22 +
...security-schemes.openapi.yaml.success.json | 25 +
.../model.generated.ts | 5 +
.../rest/pet.rest.generated.ts | 16 +
...ntry-composition.openapi.yaml.success.json | 25 +
.../model.generated.ts | 11 +
.../rest/animal.rest.generated.ts | 23 +
.../generate-native/static-template.json | 10 +
.../static-template/rest.model.ts | 78 +
.../static-template/rest.util.ts | 325 +
.../string-formats.openapi.yaml.success.json | 58 +
.../model.generated.ts | 8 +
...ed-path-template.openapi.yaml.failure.json | 6 +
.../unsupported-root.yaml.failure.json | 6 +
...pported-semantic.openapi.yaml.failure.json | 6 +
__test__/tsconfig.json | 11 +
benchmark/bench-budgets.json | 10 +
benchmark/bench.ts | 196 +
benchmark/package.json | 3 +
benchmark/tsconfig.json | 10 +
bin/.bg-shell/manifest.json | 1 +
bin/lib/parse.js | 451 +
bin/openapi-ng.js | 378 +
browser.js | 61 +
build.rs | 47 +
index.d.ts | 363 +
index.d.ts.in | 146 +
lib/config.js | 10 +
lib/error-marker.json | 3 +
lib/fetch-input.js | 292 +
lib/generate-error.js | 25 +
lib/index.js | 52 +
lib/wrapper-core.js | 342 +
native.js | 596 ++
package.json | 160 +
pnpm-lock.yaml | 7670 +++++++++++++++++
pnpm-workspace.yaml | 12 +
proptest-regressions/plan/naming.txt | 8 +
rustfmt.toml | 3 +
scripts/check-version-not-placeholder.mjs | 14 +
scripts/patch-types.mjs | 303 +
scripts/regen-snapshots.mjs | 381 +
scripts/smoke-wasm.mjs | 55 +
src/bindings.rs | 336 +
src/emit/angular/imports.rs | 263 +
src/emit/angular/mod.rs | 91 +
src/emit/angular/request.rs | 871 ++
src/emit/angular/service.rs | 363 +
src/emit/mod.rs | 200 +
src/emit/model/emit_ts_models.rs | 224 +
src/emit/model/mod.rs | 168 +
src/emit/typescript.rs | 611 ++
src/emit/typescript_tests.rs | 453 +
src/error.rs | 321 +
src/io/mod.rs | 1 +
src/io/writer.rs | 216 +
src/ir/canonical.rs | 320 +
src/ir/identifier.rs | 18 +
src/ir/mod.rs | 9 +
src/ir/normalize/mod.rs | 109 +
src/ir/normalize/operations.rs | 1584 ++++
src/ir/normalize/schema.rs | 701 ++
src/ir/normalize/semantic.rs | 472 +
src/ir/normalize/tests.rs | 471 +
src/ir/schema.rs | 102 +
src/ir/tests.rs | 216 +
src/lib.rs | 39 +
src/main.rs | 42 +
src/options.rs | 653 ++
src/parse/input.rs | 835 ++
src/parse/mod.rs | 6 +
src/parse/openapi_model.rs | 219 +
src/parse/policy.rs | 326 +
src/pipeline.rs | 499 ++
src/plan/artifact_plan.rs | 724 ++
src/plan/mod.rs | 57 +
src/plan/naming/case.rs | 180 +
src/plan/naming/config.rs | 79 +
src/plan/naming/context.rs | 174 +
src/plan/naming/defaults.rs | 117 +
src/plan/naming/engine.rs | 217 +
src/plan/naming/legacy.rs | 168 +
src/plan/naming/mod.rs | 212 +
src/plan/naming/parse_spec.rs | 90 +
src/plan/naming/template.rs | 172 +
src/plan/services.rs | 894 ++
src/result.rs | 69 +
src/test_support.rs | 257 +
templates/angular/rest.model.ts | 78 +
templates/angular/rest.util.ts | 325 +
...additional-properties-boolean.openapi.yaml | 13 +
.../additional-properties-false.openapi.yaml | 21 +
.../additional-properties.openapi.yaml | 71 +
test/fixtures/allof-composition.openapi.yaml | 63 +
test/fixtures/anchor-fanout.openapi.yaml | 528 ++
test/fixtures/anchor-modest.openapi.yaml | 39 +
test/fixtures/bench-large.openapi.yaml | 1897 ++++
test/fixtures/bench-multi-tag.openapi.yaml | 2757 ++++++
.../body-content-type-xml.openapi.yaml | 17 +
test/fixtures/body-multi-content.openapi.yaml | 19 +
...body-multipart-composed-field.openapi.yaml | 23 +
.../body-multipart-mixed-fields.openapi.yaml | 31 +
.../body-multipart-nested-object.openapi.yaml | 23 +
.../body-multipart-non-object.openapi.yaml | 18 +
.../body-multipart-open-schema.openapi.yaml | 21 +
...multipart-ref-to-named-object.openapi.yaml | 30 +
.../body-urlencoded-binary-field.openapi.yaml | 20 +
...body-urlencoded-nested-object.openapi.yaml | 23 +
...y-urlencoded-scalar-and-array.openapi.yaml | 26 +
test/fixtures/circular-allof.openapi.yaml | 57 +
.../consumer-forms-and-non-json.openapi.yaml | 86 +
test/fixtures/cookie-param.openapi.yaml | 19 +
test/fixtures/deep-nested-allof.openapi.yaml | 222 +
test/fixtures/deprecated-fields.openapi.yaml | 50 +
.../fixtures/discriminated-union.openapi.yaml | 33 +
.../fixtures/discriminator-allof.openapi.yaml | 39 +
...iminator-mapping-external-ref.openapi.yaml | 24 +
.../discriminator-mapping.openapi.yaml | 24 +
...iscriminator-missing-property.openapi.yaml | 20 +
.../duplicate-operation-id.openapi.yaml | 17 +
.../duplicate-schema-name.openapi.yaml | 13 +
test/fixtures/empty-parameter.openapi.yaml | 32 +
test/fixtures/empty-shapes.openapi.yaml | 33 +
test/fixtures/errors-typed.openapi.yaml | 95 +
test/fixtures/external-ref.openapi.yaml | 16 +
test/fixtures/field-collision.openapi.yaml | 32 +
test/fixtures/header-param.openapi.yaml | 19 +
test/fixtures/inline-model.openapi.yaml | 56 +
test/fixtures/inline-parameter.openapi.yaml | 36 +
test/fixtures/invalid-enum-type.openapi.yaml | 11 +
test/fixtures/invalid-enum-value.openapi.json | 16 +
test/fixtures/jsdoc-descriptions.openapi.yaml | 51 +
test/fixtures/large-enum.openapi.yaml | 72 +
test/fixtures/malformed.yaml | 11 +
test/fixtures/missing-tag.openapi.yaml | 13 +
.../fixtures/multi-tag-operation.openapi.yaml | 31 +
test/fixtures/multi-warning.openapi.yaml | 41 +
test/fixtures/nullable-oneof.openapi.yaml | 46 +
test/fixtures/nullable-optional.openapi.yaml | 25 +
.../oneof-anyof-composition.openapi.json | 163 +
.../oneof-anyof-composition.openapi.yaml | 111 +
test/fixtures/petstore-minimal.openapi.json | 27 +
test/fixtures/petstore-minimal.openapi.yaml | 17 +
test/fixtures/petstore-rich.openapi.json | 172 +
test/fixtures/petstore-rich.openapi.yaml | 123 +
test/fixtures/recursive-model.openapi.yaml | 37 +
test/fixtures/recursive-oneof.openapi.yaml | 31 +
.../fixtures/reserved-prop-names.openapi.yaml | 30 +
.../response-204-no-content.openapi.yaml | 10 +
.../response-blob-via-pdf.openapi.yaml | 14 +
.../response-default-fallback.openapi.yaml | 22 +
.../response-octet-stream.openapi.yaml | 18 +
.../response-problem-json.openapi.yaml | 18 +
.../response-text-via-text-plain.openapi.yaml | 14 +
test/fixtures/security-schemes.openapi.yaml | 40 +
.../single-entry-composition.openapi.yaml | 47 +
test/fixtures/string-formats.openapi.yaml | 33 +
.../unbalanced-path-template.openapi.yaml | 23 +
test/fixtures/unsupported-root.yaml | 8 +
.../unsupported-semantic.openapi.yaml | 34 +
test/fixtures/unsupported-trace.openapi.yaml | 13 +
test/fixtures/verb-prefix.openapi.yaml | 30 +
test/fixtures/warning-then-fatal.openapi.yaml | 39 +
tsconfig.json | 14 +
website/.gitignore | 27 +
website/astro.config.mjs | 57 +
website/package.json | 22 +
website/pnpm-lock.yaml | 3993 +++++++++
website/src/content.config.ts | 7 +
website/src/content/docs/getting-started.md | 68 +
website/src/content/docs/guides/angular.md | 380 +
website/src/content/docs/guides/cli.md | 56 +
.../src/content/docs/guides/configuration.md | 318 +
website/src/content/docs/index.mdx | 59 +
.../src/content/docs/reference/diagnostics.md | 111 +
.../src/content/docs/reference/environment.md | 67 +
.../src/content/docs/reference/limitations.md | 97 +
.../src/content/docs/reference/node-api.md | 203 +
website/src/content/docs/reference/runtime.md | 61 +
website/tsconfig.json | 5 +
website/wrangler.jsonc | 9 +
336 files changed, 50271 insertions(+), 2 deletions(-)
create mode 100644 .bg-shell/manifest.json
create mode 100644 .cargo/config.toml
create mode 100644 .github/renovate.json
create mode 100644 .github/workflows/CI.yml
create mode 100644 .github/workflows/docs.yml
create mode 100644 .gitignore
create mode 100644 .oxfmtrc.json
create mode 100644 Cargo.lock
create mode 100644 Cargo.toml
create mode 100644 __test__/.gitignore
create mode 100644 __test__/angular-consumer/package.json
create mode 100644 __test__/angular-consumer/src/base-path-proof.ts
create mode 100644 __test__/angular-consumer/src/consumer-proof.ts
create mode 100644 __test__/angular-consumer/src/discriminator-proof.ts
create mode 100644 __test__/angular-consumer/src/emit-target-proof.ts
create mode 100644 __test__/angular-consumer/src/form-non-json-proof.ts
create mode 100644 __test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts
create mode 100644 __test__/angular-consumer/src/negative-proof/negative.ts
create mode 100644 __test__/angular-consumer/src/service-proof.ts
create mode 100644 __test__/angular-consumer/tsconfig.bench-large.json
create mode 100644 __test__/angular-consumer/tsconfig.consumer.json
create mode 100644 __test__/angular-consumer/tsconfig.discriminator.json
create mode 100644 __test__/angular-consumer/tsconfig.emit-target.json
create mode 100644 __test__/angular-consumer/tsconfig.form-non-json.json
create mode 100644 __test__/angular-consumer/tsconfig.json
create mode 100644 __test__/angular-consumer/tsconfig.negative-binary-field.json
create mode 100644 __test__/angular-consumer/tsconfig.negative.json
create mode 100644 __test__/angular-consumer/tsconfig.service.json
create mode 100644 __test__/browser.spec.ts
create mode 100644 __test__/cli-parse.spec.ts
create mode 100644 __test__/cli.spec.ts
create mode 100644 __test__/diagnostic-codes-drift.spec.ts
create mode 100644 __test__/fetch-input.spec.ts
create mode 100644 __test__/generate.snapshot.spec.ts
create mode 100644 __test__/generate.spec.ts
create mode 100644 __test__/package.json
create mode 100644 __test__/package.spec.ts
create mode 100644 __test__/rest-util-base-path.spec.ts
create mode 100644 __test__/snapshots/generate-native/additional-properties-boolean.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/additional-properties-false.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/additional-properties-false.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/additional-properties.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/additional-properties.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/additional-properties.openapi.yaml/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/allof-composition.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/allof-composition.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/allof-composition.openapi.yaml/rest/adopter.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/anchor-fanout.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/anchor-modest.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/anchor-modest.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/bench-large.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/bench-large.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource1.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource2.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource3.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource4.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource5.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource6.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/body-content-type-xml.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/body-multi-content.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/body-multipart-composed-field.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/body-multipart-nested-object.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/body-multipart-non-object.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/body-multipart-open-schema.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/rest/upload.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/body-urlencoded-binary-field.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/body-urlencoded-nested-object.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml/rest/search.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/circular-allof.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/circular-allof.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/deep-nested-allof.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/deprecated-fields.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/deprecated-fields.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/deprecated-fields.openapi.yaml/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/discriminated-union.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/discriminated-union.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/discriminator-allof.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/discriminator-allof.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/discriminator-mapping-external-ref.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/discriminator-mapping.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/discriminator-mapping.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/discriminator-missing-property.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/empty-parameter.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/empty-shapes.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/empty-shapes.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/external-ref.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/field-collision.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/header-param.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/header-param.openapi.yaml/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/inline-model.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/inline-model.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/inline-parameter.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/invalid-enum-type.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/invalid-enum-value.openapi.json.failure.json
create mode 100644 __test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/large-enum.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/large-enum.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/multi-tag-operation.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/multi-warning.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/multi-warning.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/multi-warning.openapi.yaml/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/nullable-oneof.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/nullable-oneof.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/nullable-optional.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/nullable-optional.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/oneof-anyof-composition.openapi.json.success.json
create mode 100644 __test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/adoption-request.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/adoption-request.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/petstore-minimal.openapi.json.success.json
create mode 100644 __test__/snapshots/generate-native/petstore-minimal.openapi.json/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/petstore-minimal.openapi.json/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/petstore-minimal.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/petstore-minimal.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/petstore-minimal.openapi.yaml/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.json.success.json
create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.json/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.json/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.invalid-mapped-type.failure.json
create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/recursive-model.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/recursive-model.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/recursive-oneof.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/recursive-oneof.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/reserved-prop-names.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/reserved-prop-names.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/response-204-no-content.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/response-204-no-content.openapi.yaml/rest/util.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml/rest/report.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/response-default-fallback.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/response-default-fallback.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/response-default-fallback.openapi.yaml/rest/thing.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/response-octet-stream.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/response-octet-stream.openapi.yaml/rest/blob.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/response-problem-json.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/response-problem-json.openapi.yaml/rest/error.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml/rest/note.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/security-schemes.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/security-schemes.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/security-schemes.openapi.yaml/rest/pet.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/single-entry-composition.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/single-entry-composition.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/single-entry-composition.openapi.yaml/rest/animal.rest.generated.ts
create mode 100644 __test__/snapshots/generate-native/static-template.json
create mode 100644 __test__/snapshots/generate-native/static-template/rest.model.ts
create mode 100644 __test__/snapshots/generate-native/static-template/rest.util.ts
create mode 100644 __test__/snapshots/generate-native/string-formats.openapi.yaml.success.json
create mode 100644 __test__/snapshots/generate-native/string-formats.openapi.yaml/model.generated.ts
create mode 100644 __test__/snapshots/generate-native/unbalanced-path-template.openapi.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/unsupported-root.yaml.failure.json
create mode 100644 __test__/snapshots/generate-native/unsupported-semantic.openapi.yaml.failure.json
create mode 100644 __test__/tsconfig.json
create mode 100644 benchmark/bench-budgets.json
create mode 100644 benchmark/bench.ts
create mode 100644 benchmark/package.json
create mode 100644 benchmark/tsconfig.json
create mode 100644 bin/.bg-shell/manifest.json
create mode 100644 bin/lib/parse.js
create mode 100644 bin/openapi-ng.js
create mode 100644 browser.js
create mode 100644 build.rs
create mode 100644 index.d.ts
create mode 100644 index.d.ts.in
create mode 100644 lib/config.js
create mode 100644 lib/error-marker.json
create mode 100644 lib/fetch-input.js
create mode 100644 lib/generate-error.js
create mode 100644 lib/index.js
create mode 100644 lib/wrapper-core.js
create mode 100644 native.js
create mode 100644 package.json
create mode 100644 pnpm-lock.yaml
create mode 100644 pnpm-workspace.yaml
create mode 100644 proptest-regressions/plan/naming.txt
create mode 100644 rustfmt.toml
create mode 100644 scripts/check-version-not-placeholder.mjs
create mode 100644 scripts/patch-types.mjs
create mode 100644 scripts/regen-snapshots.mjs
create mode 100644 scripts/smoke-wasm.mjs
create mode 100644 src/bindings.rs
create mode 100644 src/emit/angular/imports.rs
create mode 100644 src/emit/angular/mod.rs
create mode 100644 src/emit/angular/request.rs
create mode 100644 src/emit/angular/service.rs
create mode 100644 src/emit/mod.rs
create mode 100644 src/emit/model/emit_ts_models.rs
create mode 100644 src/emit/model/mod.rs
create mode 100644 src/emit/typescript.rs
create mode 100644 src/emit/typescript_tests.rs
create mode 100644 src/error.rs
create mode 100644 src/io/mod.rs
create mode 100644 src/io/writer.rs
create mode 100644 src/ir/canonical.rs
create mode 100644 src/ir/identifier.rs
create mode 100644 src/ir/mod.rs
create mode 100644 src/ir/normalize/mod.rs
create mode 100644 src/ir/normalize/operations.rs
create mode 100644 src/ir/normalize/schema.rs
create mode 100644 src/ir/normalize/semantic.rs
create mode 100644 src/ir/normalize/tests.rs
create mode 100644 src/ir/schema.rs
create mode 100644 src/ir/tests.rs
create mode 100644 src/lib.rs
create mode 100644 src/main.rs
create mode 100644 src/options.rs
create mode 100644 src/parse/input.rs
create mode 100644 src/parse/mod.rs
create mode 100644 src/parse/openapi_model.rs
create mode 100644 src/parse/policy.rs
create mode 100644 src/pipeline.rs
create mode 100644 src/plan/artifact_plan.rs
create mode 100644 src/plan/mod.rs
create mode 100644 src/plan/naming/case.rs
create mode 100644 src/plan/naming/config.rs
create mode 100644 src/plan/naming/context.rs
create mode 100644 src/plan/naming/defaults.rs
create mode 100644 src/plan/naming/engine.rs
create mode 100644 src/plan/naming/legacy.rs
create mode 100644 src/plan/naming/mod.rs
create mode 100644 src/plan/naming/parse_spec.rs
create mode 100644 src/plan/naming/template.rs
create mode 100644 src/plan/services.rs
create mode 100644 src/result.rs
create mode 100644 src/test_support.rs
create mode 100644 templates/angular/rest.model.ts
create mode 100644 templates/angular/rest.util.ts
create mode 100644 test/fixtures/additional-properties-boolean.openapi.yaml
create mode 100644 test/fixtures/additional-properties-false.openapi.yaml
create mode 100644 test/fixtures/additional-properties.openapi.yaml
create mode 100644 test/fixtures/allof-composition.openapi.yaml
create mode 100644 test/fixtures/anchor-fanout.openapi.yaml
create mode 100644 test/fixtures/anchor-modest.openapi.yaml
create mode 100644 test/fixtures/bench-large.openapi.yaml
create mode 100644 test/fixtures/bench-multi-tag.openapi.yaml
create mode 100644 test/fixtures/body-content-type-xml.openapi.yaml
create mode 100644 test/fixtures/body-multi-content.openapi.yaml
create mode 100644 test/fixtures/body-multipart-composed-field.openapi.yaml
create mode 100644 test/fixtures/body-multipart-mixed-fields.openapi.yaml
create mode 100644 test/fixtures/body-multipart-nested-object.openapi.yaml
create mode 100644 test/fixtures/body-multipart-non-object.openapi.yaml
create mode 100644 test/fixtures/body-multipart-open-schema.openapi.yaml
create mode 100644 test/fixtures/body-multipart-ref-to-named-object.openapi.yaml
create mode 100644 test/fixtures/body-urlencoded-binary-field.openapi.yaml
create mode 100644 test/fixtures/body-urlencoded-nested-object.openapi.yaml
create mode 100644 test/fixtures/body-urlencoded-scalar-and-array.openapi.yaml
create mode 100644 test/fixtures/circular-allof.openapi.yaml
create mode 100644 test/fixtures/consumer-forms-and-non-json.openapi.yaml
create mode 100644 test/fixtures/cookie-param.openapi.yaml
create mode 100644 test/fixtures/deep-nested-allof.openapi.yaml
create mode 100644 test/fixtures/deprecated-fields.openapi.yaml
create mode 100644 test/fixtures/discriminated-union.openapi.yaml
create mode 100644 test/fixtures/discriminator-allof.openapi.yaml
create mode 100644 test/fixtures/discriminator-mapping-external-ref.openapi.yaml
create mode 100644 test/fixtures/discriminator-mapping.openapi.yaml
create mode 100644 test/fixtures/discriminator-missing-property.openapi.yaml
create mode 100644 test/fixtures/duplicate-operation-id.openapi.yaml
create mode 100644 test/fixtures/duplicate-schema-name.openapi.yaml
create mode 100644 test/fixtures/empty-parameter.openapi.yaml
create mode 100644 test/fixtures/empty-shapes.openapi.yaml
create mode 100644 test/fixtures/errors-typed.openapi.yaml
create mode 100644 test/fixtures/external-ref.openapi.yaml
create mode 100644 test/fixtures/field-collision.openapi.yaml
create mode 100644 test/fixtures/header-param.openapi.yaml
create mode 100644 test/fixtures/inline-model.openapi.yaml
create mode 100644 test/fixtures/inline-parameter.openapi.yaml
create mode 100644 test/fixtures/invalid-enum-type.openapi.yaml
create mode 100644 test/fixtures/invalid-enum-value.openapi.json
create mode 100644 test/fixtures/jsdoc-descriptions.openapi.yaml
create mode 100644 test/fixtures/large-enum.openapi.yaml
create mode 100644 test/fixtures/malformed.yaml
create mode 100644 test/fixtures/missing-tag.openapi.yaml
create mode 100644 test/fixtures/multi-tag-operation.openapi.yaml
create mode 100644 test/fixtures/multi-warning.openapi.yaml
create mode 100644 test/fixtures/nullable-oneof.openapi.yaml
create mode 100644 test/fixtures/nullable-optional.openapi.yaml
create mode 100644 test/fixtures/oneof-anyof-composition.openapi.json
create mode 100644 test/fixtures/oneof-anyof-composition.openapi.yaml
create mode 100644 test/fixtures/petstore-minimal.openapi.json
create mode 100644 test/fixtures/petstore-minimal.openapi.yaml
create mode 100644 test/fixtures/petstore-rich.openapi.json
create mode 100644 test/fixtures/petstore-rich.openapi.yaml
create mode 100644 test/fixtures/recursive-model.openapi.yaml
create mode 100644 test/fixtures/recursive-oneof.openapi.yaml
create mode 100644 test/fixtures/reserved-prop-names.openapi.yaml
create mode 100644 test/fixtures/response-204-no-content.openapi.yaml
create mode 100644 test/fixtures/response-blob-via-pdf.openapi.yaml
create mode 100644 test/fixtures/response-default-fallback.openapi.yaml
create mode 100644 test/fixtures/response-octet-stream.openapi.yaml
create mode 100644 test/fixtures/response-problem-json.openapi.yaml
create mode 100644 test/fixtures/response-text-via-text-plain.openapi.yaml
create mode 100644 test/fixtures/security-schemes.openapi.yaml
create mode 100644 test/fixtures/single-entry-composition.openapi.yaml
create mode 100644 test/fixtures/string-formats.openapi.yaml
create mode 100644 test/fixtures/unbalanced-path-template.openapi.yaml
create mode 100644 test/fixtures/unsupported-root.yaml
create mode 100644 test/fixtures/unsupported-semantic.openapi.yaml
create mode 100644 test/fixtures/unsupported-trace.openapi.yaml
create mode 100644 test/fixtures/verb-prefix.openapi.yaml
create mode 100644 test/fixtures/warning-then-fatal.openapi.yaml
create mode 100644 tsconfig.json
create mode 100644 website/.gitignore
create mode 100644 website/astro.config.mjs
create mode 100644 website/package.json
create mode 100644 website/pnpm-lock.yaml
create mode 100644 website/src/content.config.ts
create mode 100644 website/src/content/docs/getting-started.md
create mode 100644 website/src/content/docs/guides/angular.md
create mode 100644 website/src/content/docs/guides/cli.md
create mode 100644 website/src/content/docs/guides/configuration.md
create mode 100644 website/src/content/docs/index.mdx
create mode 100644 website/src/content/docs/reference/diagnostics.md
create mode 100644 website/src/content/docs/reference/environment.md
create mode 100644 website/src/content/docs/reference/limitations.md
create mode 100644 website/src/content/docs/reference/node-api.md
create mode 100644 website/src/content/docs/reference/runtime.md
create mode 100644 website/tsconfig.json
create mode 100644 website/wrangler.jsonc
diff --git a/.bg-shell/manifest.json b/.bg-shell/manifest.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/.bg-shell/manifest.json
@@ -0,0 +1 @@
+[]
diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..7e766c3
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,5 @@
+[target.x86_64-pc-windows-msvc]
+rustflags = ["-C", "target-feature=+crt-static"]
+
+[target.i686-pc-windows-msvc]
+rustflags = ["-C", "target-feature=+crt-static"]
diff --git a/.github/renovate.json b/.github/renovate.json
new file mode 100644
index 0000000..3e1053e
--- /dev/null
+++ b/.github/renovate.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": [
+ "config:base",
+ "group:allNonMajor",
+ ":preserveSemverRanges",
+ ":disablePeerDependencies"
+ ],
+ "labels": ["dependencies"],
+ "packageRules": [
+ {
+ "matchPackageNames": ["@napi/cli", "napi", "napi-build", "napi-derive"],
+ "addLabels": ["napi-rs"],
+ "groupName": "napi-rs"
+ },
+ {
+ "matchPackagePatterns": ["^eslint", "^@typescript-eslint"],
+ "groupName": "linter"
+ }
+ ],
+ "commitMessagePrefix": "chore: ",
+ "commitMessageAction": "bump up",
+ "commitMessageTopic": "{{depName}} version",
+ "ignoreDeps": []
+}
diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
new file mode 100644
index 0000000..f05c941
--- /dev/null
+++ b/.github/workflows/CI.yml
@@ -0,0 +1,359 @@
+name: CI
+env:
+ DEBUG: napi:*
+ APP_NAME: openapi-ng
+ MACOSX_DEPLOYMENT_TARGET: '10.13'
+ CARGO_INCREMENTAL: '1'
+permissions:
+ contents: write
+ id-token: write
+'on':
+ push:
+ branches:
+ - main
+ tags-ignore:
+ - '**'
+ paths-ignore:
+ - '**/*.md'
+ - LICENSE
+ - '**/*.gitignore'
+ - .editorconfig
+ - docs/**
+ pull_request: null
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: setup pnpm
+ uses: pnpm/action-setup@v5
+ - name: Setup node
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: 'pnpm'
+ - name: Install
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy, rustfmt
+ - name: Install dependencies
+ run: pnpm install
+ - name: ESLint
+ run: pnpm lint
+ - name: Cargo fmt
+ run: cargo fmt -- --check
+ - name: Clippy
+ run: cargo clippy
+ - name: Cargo test
+ run: cargo test --all-targets
+ test-rust-cross-os:
+ name: cargo test (${{ matrix.os }})
+ strategy:
+ fail-fast: false
+ matrix:
+ os:
+ - macos-latest
+ - windows-latest
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v6
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ - name: Cache cargo
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ matrix.os }}-cargo-test
+ - name: Cargo test
+ run: cargo test --all-targets
+ build:
+ strategy:
+ fail-fast: false
+ matrix:
+ settings:
+ - host: macos-latest
+ target: x86_64-apple-darwin
+ build: pnpm build --target x86_64-apple-darwin
+ - host: windows-latest
+ build: pnpm build --target x86_64-pc-windows-msvc
+ target: x86_64-pc-windows-msvc
+ - host: ubuntu-latest
+ target: x86_64-unknown-linux-gnu
+ build: pnpm build --target x86_64-unknown-linux-gnu --use-napi-cross
+ - host: macos-latest
+ target: aarch64-apple-darwin
+ build: pnpm build --target aarch64-apple-darwin
+ - host: ubuntu-latest
+ target: aarch64-unknown-linux-gnu
+ build: pnpm build --target aarch64-unknown-linux-gnu --use-napi-cross
+ - host: windows-latest
+ target: aarch64-pc-windows-msvc
+ build: pnpm build --target aarch64-pc-windows-msvc
+ - host: ubuntu-latest
+ target: wasm32-wasip1-threads
+ build: pnpm build --target wasm32-wasip1-threads
+ name: stable - ${{ matrix.settings.target }} - node@20
+ runs-on: ${{ matrix.settings.host }}
+ steps:
+ - uses: actions/checkout@v6
+ - name: setup pnpm
+ uses: pnpm/action-setup@v5
+ - name: Setup node
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: pnpm
+ - name: Install
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: stable
+ targets: ${{ matrix.settings.target }}
+ - name: Cache cargo
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ ~/.napi-rs
+ .cargo-cache
+ target/
+ key: ${{ matrix.settings.target }}-cargo-${{ matrix.settings.host }}
+ - uses: mlugg/setup-zig@v2
+ if: ${{ contains(matrix.settings.target, 'musl') }}
+ with:
+ version: 0.15.2
+ - name: Install cargo-zigbuild
+ uses: taiki-e/install-action@v2
+ if: ${{ contains(matrix.settings.target, 'musl') }}
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ with:
+ tool: cargo-zigbuild
+ - name: Setup toolchain
+ run: ${{ matrix.settings.setup }}
+ if: ${{ matrix.settings.setup }}
+ shell: bash
+ - name: Install dependencies
+ run: pnpm install
+ - name: Setup node x86
+ uses: actions/setup-node@v6
+ if: matrix.settings.target == 'i686-pc-windows-msvc'
+ with:
+ node-version: 22
+ cache: pnpm
+ architecture: x86
+ - name: Build
+ run: ${{ matrix.settings.build }}
+ shell: bash
+ - name: Restore hand-written browser.js
+ if: matrix.settings.target == 'wasm32-wasip1-threads'
+ run: git restore browser.js
+ shell: bash
+ - name: Upload artifact
+ uses: actions/upload-artifact@v7
+ if: matrix.settings.target != 'wasm32-wasip1-threads'
+ with:
+ name: bindings-${{ matrix.settings.target }}
+ path: '*.node'
+ if-no-files-found: error
+ - name: Upload artifact
+ uses: actions/upload-artifact@v7
+ if: matrix.settings.target == 'wasm32-wasip1-threads'
+ with:
+ name: bindings-${{ matrix.settings.target }}
+ path: '*.wasm'
+ if-no-files-found: error
+ test-macOS-windows-binding:
+ name: Test bindings on ${{ matrix.settings.target }} - node@${{ matrix.node }}
+ needs:
+ - build
+ strategy:
+ fail-fast: false
+ matrix:
+ settings:
+ - host: windows-latest
+ target: x86_64-pc-windows-msvc
+ architecture: x64
+ - host: macos-latest
+ target: x86_64-apple-darwin
+ architecture: x64
+ - host: macos-latest
+ target: aarch64-apple-darwin
+ architecture: arm64
+ node:
+ - '20'
+ - '22'
+ runs-on: ${{ matrix.settings.host }}
+ steps:
+ - uses: actions/checkout@v6
+ - name: setup pnpm
+ uses: pnpm/action-setup@v5
+ - name: Setup node
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ matrix.node }}
+ cache: pnpm
+ architecture: ${{ matrix.settings.architecture }}
+ - name: Install dependencies
+ run: pnpm install
+ - name: Download artifacts
+ uses: actions/download-artifact@v8
+ with:
+ name: bindings-${{ matrix.settings.target }}
+ path: .
+ - name: List packages
+ run: ls -R .
+ shell: bash
+ - name: Test bindings
+ run: pnpm test
+ test-linux-binding:
+ name: Test ${{ matrix.target }} - node@${{ matrix.node }}
+ needs:
+ - build
+ strategy:
+ fail-fast: false
+ matrix:
+ target:
+ - x86_64-unknown-linux-gnu
+ - aarch64-unknown-linux-gnu
+ node:
+ - '20'
+ - '22'
+ runs-on: ${{ contains(matrix.target, 'aarch64') && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
+ steps:
+ - uses: actions/checkout@v6
+ - name: setup pnpm
+ uses: pnpm/action-setup@v5
+ - name: Setup node
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ matrix.node }}
+ cache: pnpm
+ - name: Output docker params
+ id: docker
+ run: |
+ node -e "
+ if ('${{ matrix.target }}'.startsWith('aarch64')) {
+ console.log('PLATFORM=linux/arm64')
+ } else if ('${{ matrix.target }}'.startsWith('armv7')) {
+ console.log('PLATFORM=linux/arm/v7')
+ } else {
+ console.log('PLATFORM=linux/amd64')
+ }
+ " >> $GITHUB_OUTPUT
+ node -e "
+ if ('${{ matrix.target }}'.endsWith('-musl')) {
+ console.log('IMAGE=node:${{ matrix.node }}-alpine')
+ } else {
+ console.log('IMAGE=node:${{ matrix.node }}-slim')
+ }
+ " >> $GITHUB_OUTPUT
+ echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
+ # use --force to download the all platform/arch dependencies
+ - name: Install dependencies
+ run: pnpm install --force
+ - name: Download artifacts
+ uses: actions/download-artifact@v8
+ with:
+ name: bindings-${{ matrix.target }}
+ path: .
+ - name: List packages
+ run: ls -R .
+ shell: bash
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v4
+ if: ${{ contains(matrix.target, 'armv7') }}
+ with:
+ platforms: all
+ - run: docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
+ if: ${{ contains(matrix.target, 'armv7') }}
+ - name: Test bindings
+ uses: tj-actions/docker-run@v2
+ with:
+ image: ${{ steps.docker.outputs.IMAGE }}
+ name: test-binding
+ options: -v ${{ steps.docker.outputs.PNPM_STORE_PATH }}:${{ steps.docker.outputs.PNPM_STORE_PATH }} -v ${{ github.workspace }}:${{ github.workspace }} -w ${{ github.workspace }} --platform ${{ steps.docker.outputs.PLATFORM }}
+ args: npm run test
+ test-wasi:
+ name: Test WASI bindings
+ needs:
+ - build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: setup pnpm
+ uses: pnpm/action-setup@v5
+ - name: Setup node
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: pnpm
+ - name: Install dependencies
+ run: pnpm install
+ - name: Download wasm artifact
+ uses: actions/download-artifact@v8
+ with:
+ name: bindings-wasm32-wasip1-threads
+ path: .
+ - name: List packages
+ run: ls -R .
+ shell: bash
+ - name: Test wasi bindings
+ run: pnpm test
+ publish:
+ name: Publish
+ runs-on: ubuntu-latest
+ needs:
+ - lint
+ - test-rust-cross-os
+ - test-macOS-windows-binding
+ - test-linux-binding
+ - test-wasi
+ steps:
+ - uses: actions/checkout@v6
+ - name: setup pnpm
+ uses: pnpm/action-setup@v5
+ - name: Setup node
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: pnpm
+ - name: Install dependencies
+ run: pnpm install
+ - name: Download all artifacts
+ uses: actions/download-artifact@v8
+ with:
+ path: artifacts
+ - name: create npm dirs
+ run: pnpm napi create-npm-dirs
+ - name: Move artifacts
+ run: pnpm artifacts
+ - name: List packages
+ run: ls -R ./npm
+ shell: bash
+ - name: Publish
+ run: |
+ npm config set provenance true
+ if git log -1 --pretty=%B | grep "^v\?[0-9]\+\.[0-9]\+\.[0-9]\+$";
+ then
+ echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc
+ npm publish --access public
+ elif git log -1 --pretty=%B | grep "^v\?[0-9]\+\.[0-9]\+\.[0-9]\+";
+ then
+ echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc
+ npm publish --tag next --access public
+ else
+ echo "Not a release, skipping publish"
+ fi
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..a412456
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,59 @@
+name: Docs
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'website/**'
+ - '.github/workflows/docs.yml'
+ pull_request:
+ paths:
+ - 'website/**'
+ - '.github/workflows/docs.yml'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ name: Build and deploy
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ deployments: write
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v6
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v5
+ - name: Setup Node
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: pnpm
+ cache-dependency-path: website/pnpm-lock.yaml
+ - name: Install dependencies
+ working-directory: website
+ run: pnpm install --frozen-lockfile
+ - name: Build site
+ working-directory: website
+ run: pnpm build
+ - name: Deploy to production
+ if: github.event_name == 'push'
+ uses: cloudflare/wrangler-action@v3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ gitHubToken: ${{ secrets.GITHUB_TOKEN }}
+ workingDirectory: website
+ command: deploy
+ - name: Upload preview version
+ if: github.event_name == 'pull_request'
+ uses: cloudflare/wrangler-action@v3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ gitHubToken: ${{ secrets.GITHUB_TOKEN }}
+ workingDirectory: website
+ command: versions upload --tag pr-${{ github.event.pull_request.number }} --message "PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..02dccf8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,245 @@
+### Created by https://www.gitignore.io
+
+### IntelliJ ###
+.idea
+
+### Rust ###
+# Generated by Cargo
+# will have compiled files and executables
+debug/
+target/
+
+# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
+# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
+#Cargo.lock
+
+# These are backup files generated by rustfmt
+**/*.rs.bk
+
+# MSVC Windows builds of rustc generate these, which store debugging information
+*.pdb
+
+### Created by https://www.gitignore.io
+### Node ###
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional stylelint cache
+.stylelintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+.temp
+.cache
+
+# Docusaurus cache and generated files
+.docusaurus
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+*.node
+*.wasm
+
+# napi-rs auto-generates these WASI loader shims when the build matrix
+# includes a wasm32-* target. They are regenerated per build and should
+# not be tracked.
+openapi-ng.wasi.cjs
+openapi-ng.wasi-browser.js
+wasi-worker.mjs
+wasi-worker-browser.mjs
+
+### Node Patch ###
+# Serverless Webpack directories
+.webpack/
+
+# Optional stylelint cache
+.stylelintcache
+
+# SvelteKit build / generate output
+.svelte-kit
+
+### Created by https://www.gitignore.io
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+.planning/
+
+# ── GSD baseline (auto-generated) ──
+.gsd/activity/
+.gsd/forensics/
+.gsd/runtime/
+.gsd/worktrees/
+.gsd/parallel/
+.gsd/auto.lock
+.gsd/metrics.json
+.gsd/completed-units.json
+.gsd/STATE.md
+.gsd/gsd.db
+.gsd/DISCUSSION-MANIFEST.json
+.gsd/milestones/**/*-CONTINUE.md
+.gsd/milestones/**/continue.md
+Thumbs.db
+*.swp
+*.swo
+*~
+.idea/
+.vscode/
+*.code-workspace
+.env.*
+!.env.example
+.next/
+dist/
+build/
+__pycache__/
+*.pyc
+.venv/
+venv/
+vendor/
+coverage/
+tmp/
+
+*.bak
+
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
new file mode 100644
index 0000000..e236480
--- /dev/null
+++ b/.oxfmtrc.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
+ "printWidth": 90,
+ "semi": true,
+ "trailingComma": "all",
+ "singleQuote": true,
+ "arrowParens": "avoid",
+ "sortPackageJson": true,
+ "ignorePatterns": [
+ "target",
+ ".yarn",
+ "/*.js",
+ "/*.mjs",
+ "/*.cjs",
+ "/index.js",
+ "index.d.ts",
+ "pnpm-lock.yaml",
+ "/test/fixtures/malformed.yaml",
+ "__test__/snapshots/generate-native"
+ ]
+}
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..ad66444
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,610 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "convert_case"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "ctor"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98"
+dependencies = [
+ "ctor-proc-macro",
+ "dtor",
+]
+
+[[package]]
+name = "ctor-proc-macro"
+version = "0.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
+
+[[package]]
+name = "dtor"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4"
+dependencies = [
+ "dtor-proc-macro",
+]
+
+[[package]]
+name = "dtor-proc-macro"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libloading"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
+name = "libyml"
+version = "0.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980"
+dependencies = [
+ "anyhow",
+ "version_check",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "napi"
+version = "3.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb7848c221fb7bb789e02f01875287ebb1e078b92a6566a34de01ef8806e7c2b"
+dependencies = [
+ "bitflags",
+ "ctor",
+ "futures",
+ "napi-build",
+ "napi-sys",
+ "nohash-hasher",
+ "rustc-hash",
+]
+
+[[package]]
+name = "napi-build"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1"
+
+[[package]]
+name = "napi-derive"
+version = "3.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60867ff9a6f76e82350e0c3420cb0736f5866091b61d7d8a024baa54b0ec17dd"
+dependencies = [
+ "convert_case",
+ "ctor",
+ "napi-derive-backend",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "napi-derive-backend"
+version = "5.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0864cf6a82e2cfb69067374b64c9253d7e910e5b34db833ed7495dda56ccb18"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "semver",
+ "syn",
+]
+
+[[package]]
+name = "napi-sys"
+version = "3.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eb602b84d7c1edae45e50bbf1374696548f36ae179dfa667f577e384bb90c2b"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "nohash-hasher"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "openapi_ng"
+version = "0.0.0"
+dependencies = [
+ "indexmap",
+ "napi",
+ "napi-build",
+ "napi-derive",
+ "proptest",
+ "regex",
+ "serde",
+ "serde_json",
+ "serde_yml",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "proptest"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+dependencies = [
+ "bitflags",
+ "num-traits",
+ "rand",
+ "rand_chacha",
+ "rand_xorshift",
+ "regex-syntax",
+ "unarray",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "rand_xorshift"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "semver"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_yml"
+version = "0.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "libyml",
+ "memchr",
+ "ryu",
+ "serde",
+ "version_check",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unarray"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..ddcb276
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,44 @@
+[package]
+edition = "2024"
+name = "openapi_ng"
+version = "0.0.0"
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+indexmap = { version = "2", features = ["serde"] }
+napi = "3.0.0"
+napi-derive = "3.0.0"
+regex = "1"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+serde_yml = "0.0.12"
+
+[build-dependencies]
+napi-build = "2"
+
+[dev-dependencies]
+proptest = { version = "1.7", default-features = false, features = ["std"] }
+
+[lints.clippy]
+redundant_clone = "warn"
+missing_const_for_fn = "warn"
+manual_let_else = "warn"
+map_unwrap_or = "warn"
+option_if_let_else = "warn"
+unnested_or_patterns = "warn"
+cast_possible_truncation = "warn"
+single_match_else = "warn"
+redundant_closure_for_method_calls = "warn"
+
+[profile.release]
+# Build-time codegen tool: prioritise binary size (cold path, distributed
+# as napi prebuilds). `opt-level = "s"` keeps the inliner conservative
+# enough that overall runtime stays within noise of "z" / "3"; combined
+# with LTO + 1 codegen unit + strip, this trims roughly 10% off the
+# .node bundle vs the prior opt-level = "3" default.
+opt-level = "s"
+lto = true
+codegen-units = 1
+strip = "symbols"
diff --git a/LICENSE b/LICENSE
index b663eca..1c05aa8 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2026 AVSystem
+Copyright (c) 2026 pkurcx
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 5c51b2c..5680ab1 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,73 @@
-# openapi-ng
\ No newline at end of file
+# openapi-ng
+
+Generate TypeScript models and Angular services from OpenAPI 3.x specs — fast, deterministic, Rust-powered.
+
+**[Documentation →](https://docs.openapi-ng.dev)** · [Getting started](https://docs.openapi-ng.dev/getting-started/) · [Angular guide](https://docs.openapi-ng.dev/guides/angular/) · [Node API](https://docs.openapi-ng.dev/reference/node-api/) · [Diagnostics](https://docs.openapi-ng.dev/reference/diagnostics/)
+
+## Why openapi-ng
+
+- **Rust-powered codegen.** The engine is a native binary loaded via [NAPI-RS](https://napi.rs). The same input always produces identical output.
+- **Angular-first output.** Each operation ships with three flavors — `.observable()`, `.resource()`, `.request()` — matching Angular's current HTTP primitives.
+- **Strict OpenAPI subset.** A focused 3.x slice with clear diagnostics. No silent misgeneration; see [Assumptions & limitations](https://docs.openapi-ng.dev/reference/limitations/) for the accepted shape.
+- **Configurable naming.** Tune method names and service grouping with template + regex rules, via YAML, JSON, or TypeScript config.
+
+## Install
+
+```bash
+pnpm add -D @avsystem/openapi-ng
+```
+
+Requires Node.js >= 18. Pre-built binaries for macOS, Linux, and Windows (x64 / ARM64); a WASI fallback covers other targets. See [Runtime & platforms](https://docs.openapi-ng.dev/reference/runtime/).
+
+## Quickstart
+
+```bash
+openapi-ng generate --input petstore.openapi.yaml --output ./generated
+```
+
+```
+✓ Generated 4 files from Petstore (3.0.3)
+ 1 path · 1 operation · 1 schema
+
+ model.generated.ts
+ rest.model.ts
+ rest.util.ts
+ rest/pet.rest.generated.ts
+```
+
+Wire a generated service into a component:
+
+```ts
+import { Component, inject } from '@angular/core';
+import { PetRest } from './generated/rest/pet.rest.generated';
+
+@Component({ /* ... */ })
+export class PetList {
+ readonly #pets = inject(PetRest);
+
+ // Signal-based, reactive, with a default value while loading.
+ readonly list = this.#pets.listPets.resource({ defaultValue: [] });
+}
+```
+
+Full walkthrough on [docs.openapi-ng.dev/getting-started](https://docs.openapi-ng.dev/getting-started/).
+
+## Development
+
+```bash
+pnpm install
+pnpm build # release build (Rust + NAPI)
+pnpm build:debug # debug build (faster compile)
+pnpm test # Node integration tests (AVA)
+cargo test # Rust unit tests
+pnpm lint # oxlint
+pnpm format # oxfmt + rustfmt + taplo
+```
+
+Rust changes require a rebuild (`pnpm build` or `pnpm build:debug`) before tests reflect them.
+
+Bug reports and PRs welcome at [github.com/AVSystem/openapi-ng](https://github.com/AVSystem/openapi-ng).
+
+## License
+
+[MIT](./LICENSE) © 2026 pkurcx
diff --git a/__test__/.gitignore b/__test__/.gitignore
new file mode 100644
index 0000000..e82b313
--- /dev/null
+++ b/__test__/.gitignore
@@ -0,0 +1,3 @@
+**/generated
+**/generated-formats
+**/__snapshot_compile__
diff --git a/__test__/angular-consumer/package.json b/__test__/angular-consumer/package.json
new file mode 100644
index 0000000..c2ff7a4
--- /dev/null
+++ b/__test__/angular-consumer/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@avsystem/openapi-ng-angular-consumer-proof",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@angular/common": "21.2.5",
+ "@angular/core": "21.2.5",
+ "rxjs": "7.8.2"
+ }
+}
diff --git a/__test__/angular-consumer/src/base-path-proof.ts b/__test__/angular-consumer/src/base-path-proof.ts
new file mode 100644
index 0000000..f9ba238
--- /dev/null
+++ b/__test__/angular-consumer/src/base-path-proof.ts
@@ -0,0 +1,16 @@
+import type { EnvironmentProviders, InjectionToken } from '@angular/core';
+import { OPENAPI_NG_BASE_PATH, provideOpenapiNg } from '../generated/rest.util';
+
+declare function expectType(value: T): void;
+
+expectType>(OPENAPI_NG_BASE_PATH);
+
+expectType(
+ provideOpenapiNg({ basePath: 'https://api.example.com' }),
+);
+
+// @ts-expect-error — basePath is required
+provideOpenapiNg({});
+
+// @ts-expect-error — basePath must be a string
+provideOpenapiNg({ basePath: 123 });
diff --git a/__test__/angular-consumer/src/consumer-proof.ts b/__test__/angular-consumer/src/consumer-proof.ts
new file mode 100644
index 0000000..6ee5403
--- /dev/null
+++ b/__test__/angular-consumer/src/consumer-proof.ts
@@ -0,0 +1,65 @@
+import type { HttpClient } from '@angular/common/http';
+
+import type {
+ AdoptionDecision,
+ AdoptionRequest,
+ ContactEmail,
+ ContactPhone,
+ PetUnion,
+ PetUnionList,
+} from '../generated/model.generated';
+
+declare const http: HttpClient;
+
+declare function expectType(value: T): void;
+
+const emailContact: ContactEmail = { email: 'owner@example.com' };
+const phoneContact: ContactPhone = { phone: '+48123456789' };
+const preferredPet: PetUnion = {
+ id: 'pet-123',
+ lives: 7,
+};
+
+const listUnionPets: PetUnionList = [preferredPet];
+
+const nullableFreeRequest: AdoptionRequest = {
+ applicantName: 'Jordan Example',
+ contact: emailContact,
+ preferredPet,
+ notes: 'Has a fenced yard',
+ referralCode: 'SPRING-2026',
+};
+
+const applicantWithEmail: AdoptionRequest = {
+ applicantName: 'Taylor Example',
+ contact: emailContact,
+ preferredPet,
+ notes: 'Works from home',
+ referralCode: 'FRIEND',
+};
+
+const applicantWithPhone: AdoptionRequest = {
+ applicantName: 'Morgan Example',
+ contact: phoneContact,
+};
+
+const approvedDecision: AdoptionDecision = {
+ approved: true,
+ matchedPet: preferredPet,
+ reviewerNote: 'Bring carrier to pickup',
+};
+
+const pendingDecision: AdoptionDecision = {
+ approved: false,
+};
+
+expectType(applicantWithPhone.contact);
+expectType(applicantWithEmail.preferredPet);
+expectType(approvedDecision.reviewerNote);
+expectType(approvedDecision.matchedPet);
+expectType(listUnionPets);
+
+void http;
+void nullableFreeRequest;
+void approvedDecision;
+void pendingDecision;
diff --git a/__test__/angular-consumer/src/discriminator-proof.ts b/__test__/angular-consumer/src/discriminator-proof.ts
new file mode 100644
index 0000000..a2f1aa1
--- /dev/null
+++ b/__test__/angular-consumer/src/discriminator-proof.ts
@@ -0,0 +1,38 @@
+import type { Cat, Dog, PetUnion } from '../generated/model.generated';
+
+declare const pet: PetUnion;
+
+// These narrowing blocks only type-check if Cat.kind = 'cat' and Dog.kind = 'dog'
+// (literal types). Without the discriminator narrowing the compiler cannot
+// prove that pet.lives / pet.breed are accessible inside the if block.
+
+if (pet.kind === 'cat') {
+ const lives: number = pet.lives;
+ void lives;
+}
+
+if (pet.kind === 'dog') {
+ const breed: string = pet.breed;
+ void breed;
+}
+
+// Exhaustiveness check — both branches must be accounted for.
+function assertNever(x: never): never {
+ throw new Error(`Unexpected value: ${x}`);
+}
+
+function describePet(p: PetUnion): string {
+ switch (p.kind) {
+ case 'cat':
+ return `cat with ${p.lives} lives`;
+ case 'dog':
+ return `dog of breed ${p.breed}`;
+ default:
+ return assertNever(p);
+ }
+}
+
+declare const cat: Cat;
+declare const dog: Dog;
+void describePet(cat);
+void describePet(dog);
diff --git a/__test__/angular-consumer/src/emit-target-proof.ts b/__test__/angular-consumer/src/emit-target-proof.ts
new file mode 100644
index 0000000..e19cd60
--- /dev/null
+++ b/__test__/angular-consumer/src/emit-target-proof.ts
@@ -0,0 +1,22 @@
+// Type-level proof that `EmitTarget` survives single-file transpilation
+// under `isolatedModules`. Compiles against the published `index.d.ts`
+// (mapped to `@avsystem/openapi-ng` via tsconfig.emit-target.json's `paths`), so
+// any regression that re-introduces `const enum EmitTarget` — which TS
+// rejects when an isolatedModules consumer imports it across a module
+// boundary — fails this gate before reaching downstream users.
+
+import { EmitTarget } from '@avsystem/openapi-ng';
+import type { EmitTarget as EmitTargetType } from '@avsystem/openapi-ng';
+
+// Must be assignable from plain string literals (the runtime contract).
+const a: EmitTargetType = 'models';
+const b: EmitTargetType = 'angular';
+
+// And from the ambient frozen-const's named properties.
+const c: EmitTargetType = EmitTarget.Models;
+const d: EmitTargetType = EmitTarget.Angular;
+
+void a;
+void b;
+void c;
+void d;
diff --git a/__test__/angular-consumer/src/form-non-json-proof.ts b/__test__/angular-consumer/src/form-non-json-proof.ts
new file mode 100644
index 0000000..629f0f6
--- /dev/null
+++ b/__test__/angular-consumer/src/form-non-json-proof.ts
@@ -0,0 +1,154 @@
+// Compile-time proofs for the request bodies and non-JSON responses
+// surfaced by Phase 7. Lives next to service-proof.ts (the petstore-rich
+// JSON proof) and compiles against a separate combined fixture
+// (`consumer-forms-and-non-json.openapi.yaml`) generated into
+// `__test__/angular-consumer/generated/` by the matching ava test.
+//
+// Each block asserts:
+// 1. The request type accepts the right field shapes (Blob | File,
+// number[], etc.).
+// 2. `.observable(...)` and `.resource(...)` carry the right Response
+// generic through to `Observable` / `HttpResourceRef<...>`.
+//
+// A regression that collapses any of these to `any` or rejects a valid
+// call-site shape fails this file under `tsc --noEmit`.
+
+import type { HttpResourceRef } from '@angular/common/http';
+import type { Observable } from 'rxjs';
+
+import type { CommonRequest } from '../generated/rest.model';
+
+import type { BinaryRest } from '../generated/rest/binary.rest.generated';
+import type { ConfigRest } from '../generated/rest/config.rest.generated';
+import type {
+ DownloadInvoicePdfParams,
+ InvoiceRest,
+} from '../generated/rest/invoice.rest.generated';
+import type {
+ PetRest,
+ UpdatePetAvatarParams,
+} from '../generated/rest/pet.rest.generated';
+import type {
+ SearchRest,
+ SubmitFormParams,
+} from '../generated/rest/search.rest.generated';
+
+declare const petSvc: PetRest;
+declare const searchSvc: SearchRest;
+declare const invoiceSvc: InvoiceRest;
+declare const configSvc: ConfigRest;
+declare const binarySvc: BinaryRest;
+
+declare function expectType(value: T): void;
+
+/**
+ * multipart/form-data — mixed-field body.
+ *
+ * Asserts:
+ * - Binary field accepts `Blob | File`.
+ * - Repeated binary field accepts `(Blob | File)[]`.
+ * - Scalar array accepts `number[]` (the spec types it as integer).
+ * - Operation observable resolves the inline `{ id?: string }` shape.
+ */
+const multipartRequest: UpdatePetAvatarParams = {
+ petId: 'p-1',
+ status: 'available',
+ tagIds: [1, 2, 3],
+ avatar: new Blob(['avatar-bytes']),
+ galleries: [new Blob(['g1']), new File(['g2'], 'g2.png')],
+};
+
+const multipartObservable = petSvc.updatePetAvatar.observable(multipartRequest);
+const multipartResource = petSvc.updatePetAvatar.resource(() => multipartRequest);
+
+expectType>(multipartObservable);
+expectType>(multipartResource);
+
+/**
+ * application/x-www-form-urlencoded — scalar + scalar array.
+ *
+ * Asserts that the generated request interface keeps the same JS-side
+ * shape as a JSON body (no `Blob | File` leakage from the multipart
+ * branch) and that the response generic survives.
+ */
+const urlencodedRequest: SubmitFormParams = {
+ status: 'pending',
+ tagIds: [3, 4],
+};
+
+const urlencodedObservable = searchSvc.submitForm.observable(urlencodedRequest);
+expectType>(urlencodedObservable);
+
+/**
+ * application/pdf response — classified as `Blob` by the default
+ * response-kind classifier.
+ *
+ * Asserts:
+ * - `.observable(...)` and `.resource(...)` carry `Blob` through.
+ * - `.request(...)` returns a CommonRequest (compile-only check).
+ * - `defaultValue` removes the `| undefined` from the resource ref.
+ * - `parse` projects the raw Blob to a caller-defined result type.
+ */
+const blobRequest = invoiceSvc.downloadInvoicePdf.request({ invoiceId: 'inv-42' });
+const blobObservable = invoiceSvc.downloadInvoicePdf.observable({ invoiceId: 'inv-42' });
+const blobResource = invoiceSvc.downloadInvoicePdf.resource(
+ (): DownloadInvoicePdfParams => ({ invoiceId: 'inv-42' }),
+);
+const blobResourceWithDefault = invoiceSvc.downloadInvoicePdf.resource(
+ (): DownloadInvoicePdfParams => ({ invoiceId: 'inv-42' }),
+ { defaultValue: new Blob() },
+);
+const blobResourceWithParse = invoiceSvc.downloadInvoicePdf.resource(
+ (): DownloadInvoicePdfParams => ({ invoiceId: 'inv-42' }),
+ { parse: (raw: Blob): { size: number } => ({ size: raw.size }) },
+);
+
+expectType(blobRequest);
+expectType>(blobObservable);
+expectType>(blobResource);
+expectType>(blobResourceWithDefault);
+expectType>(blobResourceWithParse);
+
+/**
+ * text/plain response — classified as `string`.
+ *
+ * Same surface assertions as the blob block, instantiated for `string`.
+ */
+const textRequest = configSvc.getRawConfig.request();
+const textObservable = configSvc.getRawConfig.observable();
+const textResource = configSvc.getRawConfig.resource();
+const textResourceWithDefault = configSvc.getRawConfig.resource({
+ defaultValue: '',
+});
+const textResourceWithParse = configSvc.getRawConfig.resource({
+ parse: (raw: string): number => raw.length,
+});
+
+expectType(textRequest);
+expectType>(textObservable);
+expectType>(textResource);
+expectType>(textResourceWithDefault);
+expectType>(textResourceWithParse);
+
+/**
+ * application/octet-stream — overridden to `arrayBuffer` via the
+ * generator's `responseTypeMapping` option.
+ *
+ * Same surface assertions as the blob block, instantiated for
+ * `ArrayBuffer`.
+ */
+const arrayBufferRequest = binarySvc.fetchBlob.request();
+const arrayBufferObservable = binarySvc.fetchBlob.observable();
+const arrayBufferResource = binarySvc.fetchBlob.resource();
+const arrayBufferResourceWithDefault = binarySvc.fetchBlob.resource({
+ defaultValue: new ArrayBuffer(0),
+});
+const arrayBufferResourceWithParse = binarySvc.fetchBlob.resource({
+ parse: (raw: ArrayBuffer): number => raw.byteLength,
+});
+
+expectType(arrayBufferRequest);
+expectType>(arrayBufferObservable);
+expectType>(arrayBufferResource);
+expectType>(arrayBufferResourceWithDefault);
+expectType>(arrayBufferResourceWithParse);
diff --git a/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts b/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts
new file mode 100644
index 0000000..dfb85ed
--- /dev/null
+++ b/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts
@@ -0,0 +1,22 @@
+// This file is INTENDED TO FAIL TypeScript compilation.
+// It exists so the test suite catches type-soundness regressions on the
+// multipart form-body surface: the binary field's request-interface
+// type MUST stay `Blob | File`, never widen to `string`/`any`. If a
+// future change accidentally collapses the binary field type, this
+// assignment would succeed and tsc would exit 0 — causing the
+// negative-compile test to fail and alerting us.
+//
+// Expected error: TS2322 — `'string-not-blob'` (a literal string) is not
+// assignable to `Blob | File`.
+import type { UpdatePetAvatarParams } from '../../generated/rest/pet.rest.generated';
+
+// Construct an UpdatePetAvatarParams whose `avatar` field is a string,
+// not a Blob/File. Every other field carries a valid value so the
+// failure is unambiguously about the binary slot.
+export const shouldFail: UpdatePetAvatarParams = {
+ petId: 'p-1',
+ status: 'available',
+ tagIds: [],
+ avatar: 'string-not-blob',
+ galleries: [],
+};
diff --git a/__test__/angular-consumer/src/negative-proof/negative.ts b/__test__/angular-consumer/src/negative-proof/negative.ts
new file mode 100644
index 0000000..f20350b
--- /dev/null
+++ b/__test__/angular-consumer/src/negative-proof/negative.ts
@@ -0,0 +1,15 @@
+// This file is INTENDED TO FAIL TypeScript compilation.
+// It exists so the test suite catches type-soundness regressions
+// (e.g. if a future change accidentally collapses a tagged union to `any`).
+//
+// Expected error: TS2322 — the `kind` literal type 'dog' is not assignable to
+// 'cat', so assigning an object with `kind: 'dog'` to a Cat-typed slot fails.
+// If the union ever degrades to `any`, this assignment would succeed and tsc
+// would exit 0 — causing the negative-compile test to fail and alerting us.
+import type { Cat } from '../../generated/model.generated';
+
+// Construct an object whose `kind` discriminant is 'dog', not 'cat'.
+// This is structurally compatible with Cat except for the literal type on `kind`.
+const dogKind = { kind: 'dog' as const, lives: 9 };
+
+export const shouldFail: Cat = dogKind;
diff --git a/__test__/angular-consumer/src/service-proof.ts b/__test__/angular-consumer/src/service-proof.ts
new file mode 100644
index 0000000..6ddc5a1
--- /dev/null
+++ b/__test__/angular-consumer/src/service-proof.ts
@@ -0,0 +1,126 @@
+import type { PetRest, UpdatePetParams } from '../generated/rest/pet.rest.generated';
+import type {
+ HttpEvent,
+ HttpResourceRef,
+ HttpResponse,
+} from '@angular/common/http';
+import { Pet, PetList } from '../generated/model.generated.ts';
+import { Observable } from 'rxjs';
+import type {
+ RequestFnVoid,
+ ZeroArgRequestFnVoid,
+} from '../generated/rest.util';
+
+declare const service: PetRest;
+
+declare function expectType(value: T): void;
+
+/**
+ * listPets
+ * */
+
+const listPetsRequest = service.listPets.request();
+const listPetsObservable = service.listPets.observable();
+const listPetsResource = service.listPets.resource();
+
+const listPetsResourceDefaultValue = service.listPets.resource({
+ defaultValue: [],
+});
+const listPetsResourceParse = service.listPets.resource({
+ parse: raw => raw.length,
+});
+const listPetsResourceParseDefaultValue = service.listPets.resource({
+ parse: raw => raw.length,
+ defaultValue: 42,
+});
+
+const listPetsObservableResponse = service.listPets.observable({
+ observe: 'response',
+});
+const listPetsObservableEvents = service.listPets.observable({
+ observe: 'events',
+ reportProgress: true,
+});
+
+expectType(listPetsRequest.url);
+expectType>(listPetsResource);
+expectType>(listPetsObservable);
+expectType>(listPetsResourceDefaultValue);
+expectType>(listPetsResourceParse);
+expectType>(listPetsResourceParseDefaultValue);
+expectType>>(listPetsObservableResponse);
+expectType>>(listPetsObservableEvents);
+
+/**
+ * updatePet
+ * */
+
+const request: UpdatePetParams = {
+ petId: 'id',
+ body: {
+ status: 'available',
+ tagIds: [],
+ },
+};
+const defaultPet: Pet = {
+ id: 'id',
+ name: 'name',
+ status: 'available',
+ tags: [],
+};
+const updatePetRequest = service.updatePet.request(request);
+const updatePetObservable = service.updatePet.observable(request);
+const updatePetResource = service.updatePet.resource(() => request);
+
+const updatePetResourceDefaultValue = service.updatePet.resource(() => request, {
+ defaultValue: defaultPet,
+});
+const updatePetResourceParse = service.updatePet.resource(() => request, {
+ parse: raw => raw.tags.length,
+});
+const updatePetResourceParseDefaultValue = service.updatePet.resource(
+ () => request,
+ { parse: raw => raw.tags.length, defaultValue: 42 },
+);
+
+const updatePetObservableResponse = service.updatePet.observable(request, {
+ observe: 'response',
+});
+const updatePetObservableEvents = service.updatePet.observable(request, {
+ observe: 'events',
+ reportProgress: true,
+});
+
+expectType(updatePetRequest.url);
+expectType>(updatePetObservable);
+expectType>(updatePetResource);
+expectType>(updatePetResourceDefaultValue);
+expectType>(updatePetResourceParse);
+expectType>(updatePetResourceParseDefaultValue);
+expectType>>(updatePetObservableResponse);
+expectType>>(updatePetObservableEvents);
+
+// Synthetic proofs for the void variants. petstore-rich has no 204-returning
+// operations; hand-declare instances against the interfaces exported from
+// rest.util so the overload set is still asserted by the tsc gate.
+declare const zeroArgVoidFactory: ZeroArgRequestFnVoid;
+declare const requestVoidFactory: RequestFnVoid<{ id: string }>;
+
+expectType>(zeroArgVoidFactory.observable());
+expectType>>(
+ zeroArgVoidFactory.observable({ observe: 'response' }),
+);
+expectType>>(
+ zeroArgVoidFactory.observable({ observe: 'events' }),
+);
+
+expectType>(requestVoidFactory.observable({ id: 'x' }));
+expectType>>(
+ requestVoidFactory.observable({ id: 'x' }, { observe: 'response' }),
+);
+expectType>>(
+ requestVoidFactory.observable(
+ { id: 'x' },
+ { observe: 'events', reportProgress: true },
+ ),
+);
diff --git a/__test__/angular-consumer/tsconfig.bench-large.json b/__test__/angular-consumer/tsconfig.bench-large.json
new file mode 100644
index 0000000..268edbb
--- /dev/null
+++ b/__test__/angular-consumer/tsconfig.bench-large.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["generated/**/*.ts"]
+}
diff --git a/__test__/angular-consumer/tsconfig.consumer.json b/__test__/angular-consumer/tsconfig.consumer.json
new file mode 100644
index 0000000..f5d28a1
--- /dev/null
+++ b/__test__/angular-consumer/tsconfig.consumer.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src/consumer-proof.ts", "generated/**/*.ts"]
+}
diff --git a/__test__/angular-consumer/tsconfig.discriminator.json b/__test__/angular-consumer/tsconfig.discriminator.json
new file mode 100644
index 0000000..0d79ca7
--- /dev/null
+++ b/__test__/angular-consumer/tsconfig.discriminator.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src/discriminator-proof.ts", "generated/**/*.ts"]
+}
diff --git a/__test__/angular-consumer/tsconfig.emit-target.json b/__test__/angular-consumer/tsconfig.emit-target.json
new file mode 100644
index 0000000..e4df36c
--- /dev/null
+++ b/__test__/angular-consumer/tsconfig.emit-target.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "paths": {
+ "@avsystem/openapi-ng": ["../../index.d.ts"]
+ }
+ },
+ "include": ["src/emit-target-proof.ts"]
+}
diff --git a/__test__/angular-consumer/tsconfig.form-non-json.json b/__test__/angular-consumer/tsconfig.form-non-json.json
new file mode 100644
index 0000000..b62e381
--- /dev/null
+++ b/__test__/angular-consumer/tsconfig.form-non-json.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src/form-non-json-proof.ts", "generated/**/*.ts"]
+}
diff --git a/__test__/angular-consumer/tsconfig.json b/__test__/angular-consumer/tsconfig.json
new file mode 100644
index 0000000..bb3a59f
--- /dev/null
+++ b/__test__/angular-consumer/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "module": "preserve",
+ "moduleResolution": "bundler",
+ "target": "es2024",
+ "lib": ["ES2022", "DOM"],
+ "strict": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "esModuleInterop": true,
+ "allowImportingTsExtensions": true
+ }
+}
diff --git a/__test__/angular-consumer/tsconfig.negative-binary-field.json b/__test__/angular-consumer/tsconfig.negative-binary-field.json
new file mode 100644
index 0000000..b7494a1
--- /dev/null
+++ b/__test__/angular-consumer/tsconfig.negative-binary-field.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.json",
+ "include": [
+ "src/negative-proof/binary-field-rejects-string.ts",
+ "generated/**/*.ts"
+ ]
+}
diff --git a/__test__/angular-consumer/tsconfig.negative.json b/__test__/angular-consumer/tsconfig.negative.json
new file mode 100644
index 0000000..caffcb8
--- /dev/null
+++ b/__test__/angular-consumer/tsconfig.negative.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src/negative-proof/negative.ts", "generated/**/*.ts"]
+}
diff --git a/__test__/angular-consumer/tsconfig.service.json b/__test__/angular-consumer/tsconfig.service.json
new file mode 100644
index 0000000..dbf73e4
--- /dev/null
+++ b/__test__/angular-consumer/tsconfig.service.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src/service-proof.ts", "src/base-path-proof.ts", "generated/**/*.ts"]
+}
diff --git a/__test__/browser.spec.ts b/__test__/browser.spec.ts
new file mode 100644
index 0000000..40905cd
--- /dev/null
+++ b/__test__/browser.spec.ts
@@ -0,0 +1,35 @@
+import test from 'ava';
+import { createRequire } from 'node:module';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.join(__dirname, '..');
+const require = createRequire(import.meta.url);
+
+const browserEntry = require(path.join(repoRoot, 'browser.js')) as {
+ generate: (options?: unknown) => Promise;
+ GenerateError: {
+ isGenerateError: (value: unknown) => boolean;
+ new (payload?: unknown): Error & { code?: string; message: string };
+ };
+ EmitTarget: { Models: string; Angular: string };
+};
+
+test('browser generate throws a GenerateError, not a plain Error', async t => {
+ const { generate, GenerateError } = browserEntry;
+ const err = await t.throwsAsync(async () => {
+ await generate({ inputPath: 'x', emit: ['models'] });
+ });
+ t.true(GenerateError.isGenerateError(err));
+ t.is((err as { code?: string } | undefined)?.code, 'E_UNSUPPORTED_RUNTIME');
+ // Be lenient on the message — the test originally expected /browser/i but
+ // the new wrapper says "browser/runtime context".
+ t.regex(err?.message ?? '', /browser|runtime/i);
+});
+
+test('browser entry exports EmitTarget mirror', t => {
+ t.truthy(browserEntry.EmitTarget);
+ t.is(browserEntry.EmitTarget.Models, 'models');
+ t.is(browserEntry.EmitTarget.Angular, 'angular');
+});
diff --git a/__test__/cli-parse.spec.ts b/__test__/cli-parse.spec.ts
new file mode 100644
index 0000000..f97dc3d
--- /dev/null
+++ b/__test__/cli-parse.spec.ts
@@ -0,0 +1,827 @@
+import test from 'ava';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.join(__dirname, '..');
+const require = createRequire(import.meta.url);
+
+type ParseModule = {
+ parseMappedType(value: string): {
+ schema: string;
+ import: string;
+ type: string;
+ alias?: string;
+ };
+ discoverConfigPath(startDir: string): string | null;
+ loadConfigFile(configPath: string): Promise>;
+ normalizeMappedTypes(items: unknown): unknown[] | null;
+ normalizeEmit(value: unknown): string[] | null;
+ mergeConfig(
+ fileConfig: Record,
+ cliFlags: Record,
+ ): Record;
+ parseArgs(argv: string[]): Record;
+ DEFAULT_EMIT: readonly string[];
+ CONFIG_FILENAMES: readonly string[];
+};
+
+const parse = require(path.join(repoRoot, 'bin', 'lib', 'parse.js')) as ParseModule;
+
+async function withTempDir(run: (dir: string) => void | Promise): Promise {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-ng-parse-'));
+ try {
+ await run(dir);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+// ── parseMappedType ──────────────────────────────────────────────────────────
+
+test('parseMappedType accepts 3-part schema:import:type', t => {
+ t.deepEqual(parse.parseMappedType('PetId:@demo/types:ExternalPetId'), {
+ schema: 'PetId',
+ import: '@demo/types',
+ type: 'ExternalPetId',
+ alias: undefined,
+ });
+});
+
+test('parseMappedType accepts 4-part schema:import:type:alias', t => {
+ t.deepEqual(parse.parseMappedType('PetId:@demo/types:ExternalPetId:Alias'), {
+ schema: 'PetId',
+ import: '@demo/types',
+ type: 'ExternalPetId',
+ alias: 'Alias',
+ });
+});
+
+test('parseMappedType rejects fewer than 3 parts with config-file hint', t => {
+ const error = t.throws(() => parse.parseMappedType('PetId:OnlyOne'));
+ t.true(error?.message.includes('Invalid --mapped-type'));
+ t.true(error?.message.includes('mappedTypes:'));
+ t.true(error?.message.includes('config file'));
+});
+
+test('parseMappedType rejects more than 4 parts (Windows-style absolute paths)', t => {
+ const error = t.throws(() =>
+ parse.parseMappedType('PetId:C:\\Users\\x\\types:ExternalPetId:Alias'),
+ );
+ t.true(error?.message.includes('Invalid --mapped-type'));
+ t.true(error?.message.includes('Windows absolute paths'));
+});
+
+test('parseMappedType rejects empty segments', t => {
+ const error = t.throws(() => parse.parseMappedType('PetId::ExternalPetId'));
+ t.true(error?.message.includes('Invalid --mapped-type'));
+});
+
+// ── normalizeMappedTypes ─────────────────────────────────────────────────────
+
+test('normalizeMappedTypes returns null for non-arrays', t => {
+ t.is(parse.normalizeMappedTypes(undefined), null);
+ t.is(parse.normalizeMappedTypes(null), null);
+ t.is(parse.normalizeMappedTypes('PetId:@demo:Type'), null);
+ t.is(parse.normalizeMappedTypes({}), null);
+});
+
+test('normalizeMappedTypes passes the schema/import/type/alias shape through unchanged', t => {
+ t.deepEqual(
+ parse.normalizeMappedTypes([
+ { schema: 'PetId', import: '@demo', type: 'ExternalPetId', alias: 'Alias' },
+ ]),
+ [
+ {
+ schema: 'PetId',
+ import: '@demo',
+ type: 'ExternalPetId',
+ alias: 'Alias',
+ },
+ ],
+ );
+});
+
+test('normalizeMappedTypes leaves missing alias as undefined (not pulled from typeAlias)', t => {
+ t.deepEqual(
+ parse.normalizeMappedTypes([
+ { schema: 'PetId', import: '@demo', type: 'ExternalPetId' },
+ ]),
+ [
+ {
+ schema: 'PetId',
+ import: '@demo',
+ type: 'ExternalPetId',
+ alias: undefined,
+ },
+ ],
+ );
+});
+
+// ── normalizeEmit ────────────────────────────────────────────────────────────
+
+test('normalizeEmit returns null for null/undefined', t => {
+ t.is(parse.normalizeEmit(null), null);
+ t.is(parse.normalizeEmit(undefined), null);
+});
+
+test('normalizeEmit accepts YAML array form', t => {
+ t.deepEqual(parse.normalizeEmit(['models', 'angular']), ['models', 'angular']);
+ t.deepEqual(parse.normalizeEmit(['angular']), ['angular']);
+});
+
+test('normalizeEmit accepts CLI comma-separated string', t => {
+ t.deepEqual(parse.normalizeEmit('models,angular'), ['models', 'angular']);
+});
+
+test('normalizeEmit trims whitespace and drops empties', t => {
+ t.deepEqual(parse.normalizeEmit(' models , angular , '), ['models', 'angular']);
+});
+
+test('normalizeEmit dedupes while preserving order', t => {
+ t.deepEqual(parse.normalizeEmit('models,angular,models'), ['models', 'angular']);
+});
+
+test('normalizeEmit rejects unknown targets', t => {
+ const err = t.throws(() => parse.normalizeEmit(['models', 'react-query']));
+ t.true(err?.message.includes("Unknown emit target: 'react-query'"));
+ t.true(err?.message.includes("'models'"));
+ t.true(err?.message.includes("'angular'"));
+});
+
+test('normalizeEmit rejects non-array, non-string values', t => {
+ const err = t.throws(() => parse.normalizeEmit({ angular: true }));
+ t.true(err?.message.includes('Invalid emit value'));
+});
+
+// ── mergeConfig ──────────────────────────────────────────────────────────────
+
+test('mergeConfig: cli flags override file config', t => {
+ const merged = parse.mergeConfig(
+ { input: 'file-input.yaml', output: 'file-out', emit: ['models', 'angular'] },
+ { inputPath: 'cli-input.yaml', outputPath: 'cli-out', emit: ['models'] },
+ );
+ t.is(merged.inputPath, 'cli-input.yaml');
+ t.is(merged.outputPath, 'cli-out');
+ t.deepEqual(merged.emit, ['models']);
+});
+
+test('mergeConfig: file config fills gaps when cli flag absent', t => {
+ const merged = parse.mergeConfig(
+ { input: 'file-input.yaml', emit: ['models', 'angular'] },
+ { inputPath: null, outputPath: null, emit: null },
+ );
+ t.is(merged.inputPath, 'file-input.yaml');
+ t.deepEqual(merged.emit, ['models', 'angular']);
+});
+
+test('mergeConfig: defaults emit to models+angular when both file and cli absent', t => {
+ const merged = parse.mergeConfig({}, {});
+ t.is(merged.inputPath, null);
+ t.is(merged.outputPath, null);
+ t.is(merged.verbose, false);
+ t.deepEqual(merged.emit, ['models', 'angular']);
+ t.is(merged.mappedTypes, null);
+});
+
+test('mergeConfig: cli emit wins over file emit', t => {
+ const merged = parse.mergeConfig({ emit: ['models'] }, { emit: ['angular'] });
+ t.deepEqual(merged.emit, ['angular']);
+});
+
+test('mergeConfig: cli mappedTypes wins over file mappedTypes', t => {
+ const merged = parse.mergeConfig(
+ {
+ mappedTypes: [{ schema: 'A', import: 'a', type: 'A' }],
+ },
+ {
+ mappedTypes: [{ schema: 'B', import: 'b', type: 'B', alias: undefined }],
+ },
+ );
+ t.deepEqual(merged.mappedTypes, [
+ { schema: 'B', import: 'b', type: 'B', alias: undefined },
+ ]);
+});
+
+test('mergeConfig: file mappedTypes flow through when no cli override', t => {
+ const merged = parse.mergeConfig(
+ { mappedTypes: [{ schema: 'PetId', import: '@demo', type: 'ExternalPetId' }] },
+ { mappedTypes: null },
+ );
+ t.deepEqual(merged.mappedTypes, [
+ {
+ schema: 'PetId',
+ import: '@demo',
+ type: 'ExternalPetId',
+ alias: undefined,
+ },
+ ]);
+});
+
+test('mergeConfig: file responseTypeMapping flows through to merged config', t => {
+ const merged = parse.mergeConfig(
+ {
+ responseTypeMapping: [
+ { contentType: 'application/octet-stream', responseType: 'blob' },
+ { contentType: 'text/csv', responseType: 'text' },
+ ],
+ },
+ {},
+ );
+ t.deepEqual(merged.responseTypeMapping, [
+ { contentType: 'application/octet-stream', responseType: 'blob' },
+ { contentType: 'text/csv', responseType: 'text' },
+ ]);
+});
+
+test('mergeConfig: responseTypeMapping is null when file omits it', t => {
+ const merged = parse.mergeConfig({}, {});
+ t.is(merged.responseTypeMapping, null);
+});
+
+// ── parseArgs ────────────────────────────────────────────────────────────────
+
+test('parseArgs returns kind=help for empty argv, --help, -h', t => {
+ t.is((parse.parseArgs([]) as { kind: string }).kind, 'help');
+ t.is((parse.parseArgs(['--help']) as { kind: string }).kind, 'help');
+ t.is((parse.parseArgs(['-h']) as { kind: string }).kind, 'help');
+});
+
+test('parseArgs flags empty argv as non-explicit help (drives exit 2)', t => {
+ // Bare `openapi-ng` and explicit `--help` both classify as help, but the
+ // caller needs to distinguish them so CI scripts can catch a missing
+ // subcommand. The `explicit` field is that signal.
+ t.is((parse.parseArgs([]) as { explicit: boolean }).explicit, false);
+ t.is((parse.parseArgs(['--help']) as { explicit: boolean }).explicit, true);
+ t.is((parse.parseArgs(['-h']) as { explicit: boolean }).explicit, true);
+ t.is((parse.parseArgs(['generate', '--help']) as { explicit: boolean }).explicit, true);
+ t.is((parse.parseArgs(['init', '--help']) as { explicit: boolean }).explicit, true);
+});
+
+test('parseArgs returns kind=init for `init`', t => {
+ t.deepEqual(parse.parseArgs(['init']), { kind: 'init', format: 'yaml' });
+});
+
+test('parseArgs init defaults format to yaml when --format is absent', t => {
+ t.like(parse.parseArgs(['init']), { kind: 'init', format: 'yaml' });
+});
+
+test('parseArgs init accepts --format yaml', t => {
+ t.like(parse.parseArgs(['init', '--format', 'yaml']), { kind: 'init', format: 'yaml' });
+});
+
+test('parseArgs init accepts --format json', t => {
+ t.like(parse.parseArgs(['init', '--format', 'json']), { kind: 'init', format: 'json' });
+});
+
+test('parseArgs init accepts --format ts', t => {
+ t.like(parse.parseArgs(['init', '--format', 'ts']), { kind: 'init', format: 'ts' });
+});
+
+test('parseArgs init accepts --format js', t => {
+ t.like(parse.parseArgs(['init', '--format', 'js']), { kind: 'init', format: 'js' });
+});
+
+test('parseArgs init rejects unknown --format value', t => {
+ const err = t.throws(() => parse.parseArgs(['init', '--format', 'zsh']));
+ t.regex(err!.message, /--format/);
+ t.regex(err!.message, /yaml.*json.*ts.*js/);
+});
+
+test('parseArgs init: --format requires a value', t => {
+ const err = t.throws(() => parse.parseArgs(['init', '--format']));
+ t.regex(err!.message, /--format requires a value/);
+});
+
+test('parseArgs init: --format must immediately precede a non-flag value', t => {
+ const err = t.throws(() => parse.parseArgs(['init', '--format', '--help']));
+ t.regex(err!.message, /--format requires a value/);
+});
+
+test('parseArgs throws for unsupported command', t => {
+ const error = t.throws(() => parse.parseArgs(['unknown-cmd']));
+ t.true(error?.message.includes('Unsupported command'));
+});
+
+test('parseArgs throws for unsupported argument', t => {
+ const error = t.throws(() => parse.parseArgs(['generate', '--bogus']));
+ t.true(error?.message.includes('Unsupported argument'));
+});
+
+test('parseArgs collects --input / --output (and short forms)', t => {
+ const long = parse.parseArgs(['generate', '--input', 'a.yaml', '--output', 'out']);
+ t.like(long, { kind: 'generate', inputPath: 'a.yaml', outputPath: 'out' });
+
+ const short = parse.parseArgs(['generate', '-i', 'b.yaml', '-o', 'out2']);
+ t.like(short, { kind: 'generate', inputPath: 'b.yaml', outputPath: 'out2' });
+});
+
+test('parseArgs: --emit accepts comma-separated list', t => {
+ const result = parse.parseArgs(['generate', '--emit', 'models,angular']);
+ t.deepEqual(result.emit, ['models', 'angular']);
+});
+
+test('parseArgs: --emit is repeatable, entries dedupe', t => {
+ const result = parse.parseArgs([
+ 'generate',
+ '--emit',
+ 'models',
+ '--emit',
+ 'angular,models',
+ ]);
+ t.deepEqual(result.emit, ['models', 'angular']);
+});
+
+test('parseArgs: --emit rejects unknown targets at parse time', t => {
+ const err = t.throws(() =>
+ parse.parseArgs(['generate', '--emit', 'models,react-query']),
+ );
+ t.true(err?.message.includes("Unknown emit target: 'react-query'"));
+});
+
+test('parseArgs: --verbose flag', t => {
+ const present = parse.parseArgs(['generate', '--verbose']);
+ t.is(present.verbose, true);
+ const absent = parse.parseArgs(['generate']);
+ t.is(absent.verbose, null);
+});
+
+test('parseArgs: emit absent yields null (not empty array)', t => {
+ const result = parse.parseArgs(['generate', '--input', 'a.yaml']);
+ t.is(result.emit, null);
+});
+
+test('parseArgs collects multiple --mapped-type into an array', t => {
+ const result = parse.parseArgs([
+ 'generate',
+ '--mapped-type',
+ 'A:@demo:AType',
+ '--mapped-type',
+ 'B:@demo:BType:BAlias',
+ ]);
+ t.deepEqual(result.mappedTypes, [
+ { schema: 'A', import: '@demo', type: 'AType', alias: undefined },
+ { schema: 'B', import: '@demo', type: 'BType', alias: 'BAlias' },
+ ]);
+});
+
+test('parseArgs: --mapped-type absent yields null (not empty array)', t => {
+ const result = parse.parseArgs(['generate', '--input', 'a.yaml']);
+ t.is(result.mappedTypes, null);
+});
+
+test('parseArgs extracts global --config before command', t => {
+ const result = parse.parseArgs([
+ '--config',
+ '/tmp/cfg.yaml',
+ 'generate',
+ '--input',
+ 'a.yaml',
+ ]);
+ t.like(result, { kind: 'generate', configPath: '/tmp/cfg.yaml', inputPath: 'a.yaml' });
+});
+
+test('parseArgs extracts global -c after command (interleaved)', t => {
+ const result = parse.parseArgs([
+ 'generate',
+ '-c',
+ '/tmp/cfg.yaml',
+ '--input',
+ 'a.yaml',
+ ]);
+ t.like(result, { kind: 'generate', configPath: '/tmp/cfg.yaml', inputPath: 'a.yaml' });
+});
+
+// ── Flag value validation (no silent token consumption) ─────────────────────
+
+test('parseArgs: --config errors when next token is another flag', t => {
+ const err = t.throws(() => parse.parseArgs(['--config', '--input', 'spec.yaml']));
+ t.regex(err!.message, /--config requires a value/);
+});
+
+test('parseArgs: -c errors when next token is another flag', t => {
+ const err = t.throws(() => parse.parseArgs(['-c', '--input', 'spec.yaml']));
+ t.regex(err!.message, /--config requires a value/);
+});
+
+test('parseArgs: --input errors when next token is another flag', t => {
+ const err = t.throws(() => parse.parseArgs(['generate', '--input', '--output', 'out']));
+ t.regex(err!.message, /--input requires a value/);
+});
+
+test('parseArgs: -i errors when next token is another flag', t => {
+ const err = t.throws(() => parse.parseArgs(['generate', '-i', '--output', 'out']));
+ t.regex(err!.message, /--input requires a value/);
+});
+
+test('parseArgs: --output errors when next token is missing', t => {
+ const err = t.throws(() => parse.parseArgs(['generate', '--output']));
+ t.regex(err!.message, /--output requires a value/);
+});
+
+test('parseArgs: -o errors when next token starts with --', t => {
+ const err = t.throws(() => parse.parseArgs(['generate', '-o', '--verbose']));
+ t.regex(err!.message, /--output requires a value/);
+});
+
+test('parseArgs: --emit errors when next token is another flag', t => {
+ const err = t.throws(() =>
+ parse.parseArgs(['generate', '--emit', '--input', 'spec.yaml']),
+ );
+ t.regex(err!.message, /--emit requires a value/);
+});
+
+test('parseArgs: --emit errors when next token is missing', t => {
+ const err = t.throws(() => parse.parseArgs(['generate', '--emit']));
+ t.regex(err!.message, /--emit requires a value/);
+});
+
+test('parseArgs: --mapped-type errors when next token starts with --', t => {
+ const err = t.throws(() =>
+ parse.parseArgs(['generate', '--mapped-type', '--input', 'spec.yaml']),
+ );
+ t.regex(err!.message, /--mapped-type requires a value/);
+});
+
+test('parseArgs: --mapped-type errors when next token is missing', t => {
+ const err = t.throws(() => parse.parseArgs(['generate', '--mapped-type']));
+ t.regex(err!.message, /--mapped-type requires a value/);
+});
+
+test('mergeConfig forwards file-config naming.methodName to the merged result', t => {
+ const merged = parse.mergeConfig(
+ { naming: { methodName: '{operationId}' } },
+ { kind: 'generate', emit: null, mappedTypes: null },
+ );
+ t.deepEqual(merged.naming, { methodName: '{operationId}' });
+});
+
+test('mergeConfig rejects naming.parse when not a RegExp (preserves YAML/JSON safety)', t => {
+ const fileConfig = {
+ naming: { methodName: { parse: 'literal-string-from-yaml' } },
+ };
+ const err = t.throws(() => parse.mergeConfig(fileConfig, {}));
+ t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID');
+ t.regex(err!.message, /parse.*RegExp/i);
+});
+
+test('mergeConfig accepts RegExp on naming.parse (JS/TS config path)', t => {
+ const fileConfig = {
+ naming: {
+ methodName: {
+ from: '{operationId}',
+ parse: /^[^_]+_(?.+)$/,
+ format: '{capture.rest}',
+ case: 'camel',
+ },
+ },
+ };
+ const merged = parse.mergeConfig(fileConfig, {}) as {
+ naming?: { methodName?: { parse?: RegExp } };
+ };
+ t.true(merged.naming?.methodName?.parse instanceof RegExp);
+ t.is(merged.naming?.methodName?.parse?.source, '^[^_]+_(?.+)$');
+});
+
+test('mergeConfig rejects non-RegExp parse (e.g. string from YAML)', t => {
+ const fileConfig = {
+ naming: {
+ methodName: {
+ from: '{operationId}',
+ parse: '^[^_]+_(?.+)$', // a string, as YAML would deliver
+ },
+ },
+ };
+ const err = t.throws(() => parse.mergeConfig(fileConfig, {}));
+ t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID');
+ t.regex(err!.message, /parse.*RegExp/i);
+});
+
+// ── DEFAULT_EMIT ─────────────────────────────────────────────────────────────
+
+test('DEFAULT_EMIT is models+angular', t => {
+ t.deepEqual([...parse.DEFAULT_EMIT], ['models', 'angular']);
+});
+
+// ── discoverConfigPath ───────────────────────────────────────────────────────
+
+test('discoverConfigPath returns null when no config exists in tree', async t => {
+ await withTempDir(async dir => {
+ // tmpdir parents may have a config; isolate by using an even-deeper temp.
+ const sub = fs.mkdtempSync(path.join(dir, 'isolated-'));
+ // We can only assert null if the result is null OR walks past dir.
+ // Make a strict check: if a config IS found, it must not be inside dir.
+ const found = parse.discoverConfigPath(sub);
+ if (found !== null) {
+ t.false(found.startsWith(dir), `unexpected config inside isolated dir: ${found}`);
+ } else {
+ t.pass();
+ }
+ });
+});
+
+test('discoverConfigPath finds .openapi-ng.yaml in startDir', async t => {
+ await withTempDir(async dir => {
+ const target = path.join(dir, '.openapi-ng.yaml');
+ fs.writeFileSync(target, 'input: a.yaml\n', 'utf8');
+ t.is(parse.discoverConfigPath(dir), target);
+ });
+});
+
+test('discoverConfigPath prefers .yaml over .json when both present', async t => {
+ await withTempDir(async dir => {
+ fs.writeFileSync(path.join(dir, '.openapi-ng.yaml'), 'input: a\n', 'utf8');
+ fs.writeFileSync(path.join(dir, '.openapi-ng.json'), '{"input":"b"}', 'utf8');
+ t.is(parse.discoverConfigPath(dir), path.join(dir, '.openapi-ng.yaml'));
+ });
+});
+
+test('discoverConfigPath falls back to .openapi-ng.json when .yaml absent', async t => {
+ await withTempDir(async dir => {
+ const target = path.join(dir, '.openapi-ng.json');
+ fs.writeFileSync(target, '{"input":"a.yaml"}', 'utf8');
+ t.is(parse.discoverConfigPath(dir), target);
+ });
+});
+
+test('discoverConfigPath walks up the directory tree', async t => {
+ await withTempDir(async dir => {
+ const target = path.join(dir, '.openapi-ng.yaml');
+ fs.writeFileSync(target, 'input: a\n', 'utf8');
+ const nested = path.join(dir, 'a', 'b', 'c');
+ fs.mkdirSync(nested, { recursive: true });
+ t.is(parse.discoverConfigPath(nested), target);
+ });
+});
+
+test('discoverConfigPath finds openapi-ng.config.ts in startDir', async t => {
+ await withTempDir(async dir => {
+ const target = path.join(dir, 'openapi-ng.config.ts');
+ fs.writeFileSync(target, 'export default {}\n', 'utf8');
+ t.is(parse.discoverConfigPath(dir), target);
+ });
+});
+
+test('discoverConfigPath finds openapi-ng.config.mts in startDir', async t => {
+ await withTempDir(async dir => {
+ const target = path.join(dir, 'openapi-ng.config.mts');
+ fs.writeFileSync(target, 'export default {}\n', 'utf8');
+ t.is(parse.discoverConfigPath(dir), target);
+ });
+});
+
+test('discoverConfigPath finds openapi-ng.config.cts in startDir', async t => {
+ await withTempDir(async dir => {
+ const target = path.join(dir, 'openapi-ng.config.cts');
+ fs.writeFileSync(target, 'module.exports = {}\n', 'utf8');
+ t.is(parse.discoverConfigPath(dir), target);
+ });
+});
+
+test('discoverConfigPath finds openapi-ng.config.mjs in startDir', async t => {
+ await withTempDir(async dir => {
+ const target = path.join(dir, 'openapi-ng.config.mjs');
+ fs.writeFileSync(target, 'export default {}\n', 'utf8');
+ t.is(parse.discoverConfigPath(dir), target);
+ });
+});
+
+test('discoverConfigPath finds openapi-ng.config.js in startDir', async t => {
+ await withTempDir(async dir => {
+ const target = path.join(dir, 'openapi-ng.config.js');
+ fs.writeFileSync(target, 'module.exports = {}\n', 'utf8');
+ t.is(parse.discoverConfigPath(dir), target);
+ });
+});
+
+test('discoverConfigPath finds openapi-ng.config.cjs in startDir', async t => {
+ await withTempDir(async dir => {
+ const target = path.join(dir, 'openapi-ng.config.cjs');
+ fs.writeFileSync(target, 'module.exports = {}\n', 'utf8');
+ t.is(parse.discoverConfigPath(dir), target);
+ });
+});
+
+test('discoverConfigPath prefers openapi-ng.config.ts over plain .js when both present', async t => {
+ await withTempDir(async dir => {
+ fs.writeFileSync(
+ path.join(dir, 'openapi-ng.config.ts'),
+ 'export default {}\n',
+ 'utf8',
+ );
+ fs.writeFileSync(
+ path.join(dir, 'openapi-ng.config.js'),
+ 'module.exports = {}\n',
+ 'utf8',
+ );
+ t.is(parse.discoverConfigPath(dir), path.join(dir, 'openapi-ng.config.ts'));
+ });
+});
+
+test('discoverConfigPath prefers openapi-ng.config.js over legacy .openapi-ng.yaml', async t => {
+ await withTempDir(async dir => {
+ fs.writeFileSync(
+ path.join(dir, 'openapi-ng.config.js'),
+ 'module.exports = {}\n',
+ 'utf8',
+ );
+ fs.writeFileSync(path.join(dir, '.openapi-ng.yaml'), 'input: a\n', 'utf8');
+ t.is(parse.discoverConfigPath(dir), path.join(dir, 'openapi-ng.config.js'));
+ });
+});
+
+test('discoverConfigPath walks up to find openapi-ng.config.ts in parent', async t => {
+ await withTempDir(async dir => {
+ const target = path.join(dir, 'openapi-ng.config.ts');
+ fs.writeFileSync(target, 'export default {}\n', 'utf8');
+ const nested = path.join(dir, 'a', 'b', 'c');
+ fs.mkdirSync(nested, { recursive: true });
+ t.is(parse.discoverConfigPath(nested), target);
+ });
+});
+
+// ── loadConfigFile ───────────────────────────────────────────────────────────
+
+test('loadConfigFile parses YAML files with array-form emit', async t => {
+ await withTempDir(async dir => {
+ const p = path.join(dir, '.openapi-ng.yaml');
+ fs.writeFileSync(p, 'input: a.yaml\nemit:\n - models\n - angular\n', 'utf8');
+ t.deepEqual(await parse.loadConfigFile(p), {
+ input: 'a.yaml',
+ emit: ['models', 'angular'],
+ });
+ });
+});
+
+test('loadConfigFile parses JSON files', async t => {
+ await withTempDir(async dir => {
+ const p = path.join(dir, '.openapi-ng.json');
+ fs.writeFileSync(p, '{"input":"a.yaml","emit":["models","angular"]}', 'utf8');
+ t.deepEqual(await parse.loadConfigFile(p), {
+ input: 'a.yaml',
+ emit: ['models', 'angular'],
+ });
+ });
+});
+
+test('loadConfigFile returns {} for empty YAML', async t => {
+ await withTempDir(async dir => {
+ const p = path.join(dir, '.openapi-ng.yaml');
+ fs.writeFileSync(p, '', 'utf8');
+ t.deepEqual(await parse.loadConfigFile(p), {});
+ });
+});
+
+test('loadConfigFile is case-insensitive on extension (.JSON treated as json)', async t => {
+ await withTempDir(async dir => {
+ const p = path.join(dir, '.openapi-ng.JSON');
+ fs.writeFileSync(p, '{"input":"a.yaml"}', 'utf8');
+ t.deepEqual(await parse.loadConfigFile(p), { input: 'a.yaml' });
+ });
+});
+
+test('loadConfigFile loads .cjs with module.exports = object', async t => {
+ await withTempDir(async dir => {
+ const p = path.join(dir, 'openapi-ng.config.cjs');
+ fs.writeFileSync(p, "module.exports = { input: 'a.yaml', output: 'o' };\n", 'utf8');
+ t.deepEqual(await parse.loadConfigFile(p), { input: 'a.yaml', output: 'o' });
+ });
+});
+
+test('loadConfigFile loads .mjs with export default object', async t => {
+ await withTempDir(async dir => {
+ const p = path.join(dir, 'openapi-ng.config.mjs');
+ fs.writeFileSync(p, "export default { input: 'a.yaml', output: 'o' };\n", 'utf8');
+ t.deepEqual(await parse.loadConfigFile(p), { input: 'a.yaml', output: 'o' });
+ });
+});
+
+test('loadConfigFile loads .js (CJS) with module.exports', async t => {
+ await withTempDir(async dir => {
+ // A .js file in a tmpdir with no package.json defaults to CJS under
+ // Node's resolution rules — `module.exports = ...` is the right form.
+ const p = path.join(dir, 'openapi-ng.config.js');
+ fs.writeFileSync(p, "module.exports = { input: 'a.yaml', output: 'o' };\n", 'utf8');
+ t.deepEqual(await parse.loadConfigFile(p), { input: 'a.yaml', output: 'o' });
+ });
+});
+
+test('loadConfigFile awaits an async-function default export', async t => {
+ await withTempDir(async dir => {
+ const p = path.join(dir, 'openapi-ng.config.mjs');
+ fs.writeFileSync(
+ p,
+ "export default async () => ({ input: 'a.yaml', output: 'o' });\n",
+ 'utf8',
+ );
+ t.deepEqual(await parse.loadConfigFile(p), { input: 'a.yaml', output: 'o' });
+ });
+});
+
+test('loadConfigFile rejects a JS file with no default export', async t => {
+ await withTempDir(async dir => {
+ const p = path.join(dir, 'openapi-ng.config.mjs');
+ // No default export — just a named export.
+ fs.writeFileSync(p, 'export const x = 1;\n', 'utf8');
+ const err = await t.throwsAsync(() => parse.loadConfigFile(p));
+ t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID');
+ t.regex(err!.message, /no default export/i);
+ });
+});
+
+test('loadConfigFile rejects a JS file whose default export is null', async t => {
+ await withTempDir(async dir => {
+ const p = path.join(dir, 'openapi-ng.config.mjs');
+ fs.writeFileSync(p, 'export default null;\n', 'utf8');
+ const err = await t.throwsAsync(() => parse.loadConfigFile(p));
+ t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID');
+ t.regex(err!.message, /default export must be an object/i);
+ });
+});
+
+test('loadConfigFile rejects a JS file whose default export is a primitive', async t => {
+ await withTempDir(async dir => {
+ const p = path.join(dir, 'openapi-ng.config.mjs');
+ fs.writeFileSync(p, 'export default 42;\n', 'utf8');
+ const err = await t.throwsAsync(() => parse.loadConfigFile(p));
+ t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID');
+ t.regex(err!.message, /default export must be an object/i);
+ });
+});
+
+test('loadConfigFile rejects a JS file whose default export is an array', async t => {
+ await withTempDir(async dir => {
+ const p = path.join(dir, 'openapi-ng.config.mjs');
+ fs.writeFileSync(p, "export default ['models'];\n", 'utf8');
+ const err = await t.throwsAsync(() => parse.loadConfigFile(p));
+ t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID');
+ t.regex(err!.message, /default export must be an object/i);
+ });
+});
+
+test('loadConfigFile wraps module-load errors as E_INPUT_INVALID', async t => {
+ await withTempDir(async dir => {
+ const p = path.join(dir, 'openapi-ng.config.mjs');
+ // Syntax error at module top — Node's loader throws during import.
+ fs.writeFileSync(p, 'export default { broken\n', 'utf8');
+ const err = await t.throwsAsync(() => parse.loadConfigFile(p));
+ t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID');
+ t.regex(err!.message, /Failed to load config file/i);
+ });
+});
+
+test('loadConfigFile maps ERR_UNKNOWN_FILE_EXTENSION on .cts to a version hint', async t => {
+ // Two-outcome contract: on Node ≥ 22.6 native TS stripping handles
+ // the .cts file and the load succeeds; on Node < 22.6 the loader
+ // throws ERR_UNKNOWN_FILE_EXTENSION and our mapping branch turns it
+ // into a friendly E_INPUT_INVALID. Inside the AVA worker the
+ // @oxc-node/core/register hook handles TS files itself, so the
+ // success branch fires; in production with a vanilla Node binary
+ // the branch chosen depends on the running version. Either outcome
+ // proves we never surface a generic "Failed to load" wrap for a
+ // .cts file when the underlying error is ERR_UNKNOWN_FILE_EXTENSION.
+ await withTempDir(async dir => {
+ const p = path.join(dir, 'openapi-ng.config.cts');
+ fs.writeFileSync(
+ p,
+ "const cfg: { input: string } = { input: 'a.yaml' };\nmodule.exports = cfg;\n",
+ 'utf8',
+ );
+ try {
+ const result = await parse.loadConfigFile(p);
+ // Modern Node — strip-types worked.
+ t.deepEqual(result, { input: 'a.yaml' });
+ } catch (err) {
+ const e = err as NodeJS.ErrnoException;
+ t.is(e.code, 'E_INPUT_INVALID');
+ t.regex(e.message, /TypeScript config files require Node 22\.6\+/i);
+ t.regex(e.message, /--experimental-strip-types|23\.6/i);
+ }
+ });
+});
+
+// Smoke test: only meaningful on Node ≥ 22.6 where native TS stripping
+// is available. Skipped otherwise so old-Node CI runners stay green.
+// Uses .mts with `export default` syntax — strip-friendly because
+// Node's --experimental-strip-types only removes type annotations and
+// leaves valid ESM; no transpilation of TS-specific syntax needed.
+const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number);
+const tsNativeAvailable = nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 6);
+
+(tsNativeAvailable ? test : test.skip)(
+ 'loadConfigFile loads .mts with native TS stripping (Node ≥ 22.6)',
+ async t => {
+ await withTempDir(async dir => {
+ const p = path.join(dir, 'openapi-ng.config.mts');
+ fs.writeFileSync(
+ p,
+ "interface C { input: string }\nexport default { input: 'a.yaml' } satisfies C;\n",
+ 'utf8',
+ );
+ t.deepEqual(await parse.loadConfigFile(p), { input: 'a.yaml' });
+ });
+ },
+);
diff --git a/__test__/cli.spec.ts b/__test__/cli.spec.ts
new file mode 100644
index 0000000..66f16eb
--- /dev/null
+++ b/__test__/cli.spec.ts
@@ -0,0 +1,789 @@
+import test from 'ava';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.join(__dirname, '..');
+const fixture = (name: string) => path.join(repoRoot, 'test', 'fixtures', name);
+const cliPath = path.join(repoRoot, 'bin', 'openapi-ng.js');
+
+function runCli(
+ args: string[],
+ cwd = repoRoot,
+ options: { env?: NodeJS.ProcessEnv } = {},
+) {
+ return spawnSync(process.execPath, [cliPath, ...args], {
+ cwd,
+ encoding: 'utf8',
+ ...(options.env !== undefined ? { env: options.env } : {}),
+ });
+}
+
+function withTempDir(run: (dir: string) => void) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-ng-cli-'));
+ try {
+ run(dir);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+// ── Success: stdout format ──────────────────────────────────────────────────
+
+test('cli generate prints human-readable summary with title and file list', t => {
+ withTempDir(outputPath => {
+ const result = runCli([
+ 'generate',
+ '--input',
+ fixture('petstore-rich.openapi.yaml'),
+ '--output',
+ outputPath,
+ ]);
+
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ t.true(result.stdout.includes('Generated 4 files from Petstore Rich'));
+ t.true(result.stdout.includes('2 paths'));
+ t.true(result.stdout.includes('3 operations'));
+ t.true(result.stdout.includes('6 schemas'));
+ t.true(result.stdout.includes('model.generated.ts'));
+ t.true(result.stdout.includes('rest.model.ts'));
+ t.true(result.stdout.includes('rest.util.ts'));
+ t.true(result.stdout.includes('rest/pet.rest.generated.ts'));
+ });
+});
+
+test('cli generate prints summary for JSON fixture (JSON/YAML determinism)', t => {
+ withTempDir(yamlOut => {
+ withTempDir(jsonOut => {
+ const yamlResult = runCli([
+ 'generate',
+ '--input',
+ fixture('petstore-rich.openapi.yaml'),
+ '--output',
+ yamlOut,
+ ]);
+ const jsonResult = runCli([
+ 'generate',
+ '--input',
+ fixture('petstore-rich.openapi.json'),
+ '--output',
+ jsonOut,
+ ]);
+ t.is(yamlResult.status, 0);
+ t.is(jsonResult.status, 0);
+ // Both summaries describe the same spec
+ t.true(yamlResult.stdout.includes('Petstore Rich'));
+ t.true(jsonResult.stdout.includes('Petstore Rich'));
+ });
+ });
+});
+
+test('cli generate prints summary without --output (in-memory, no file writing)', t => {
+ withTempDir(tmpDir => {
+ const result = runCli(
+ ['generate', '--input', fixture('petstore-minimal.openapi.yaml')],
+ tmpDir,
+ );
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ t.true(result.stdout.includes('Generated'));
+ t.true(result.stdout.includes('Petstore Minimal'));
+ // No files should be written since --output was not provided
+ t.deepEqual(fs.readdirSync(tmpDir), []);
+ });
+});
+
+// ── Success: files on disk ──────────────────────────────────────────────────
+
+test('cli generate writes model and service files to --output dir', t => {
+ withTempDir(outputPath => {
+ const result = runCli([
+ 'generate',
+ '--input',
+ fixture('petstore-rich.openapi.yaml'),
+ '--output',
+ outputPath,
+ ]);
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ t.true(fs.existsSync(path.join(outputPath, 'model.generated.ts')));
+ t.true(fs.existsSync(path.join(outputPath, 'rest.model.ts')));
+ t.true(fs.existsSync(path.join(outputPath, 'rest.util.ts')));
+ t.true(fs.existsSync(path.join(outputPath, 'rest', 'pet.rest.generated.ts')));
+ });
+});
+
+test('cli generate --emit angular auto-includes models (with warning under --verbose)', t => {
+ withTempDir(outputPath => {
+ const result = runCli([
+ 'generate',
+ '--input',
+ fixture('petstore-minimal.openapi.yaml'),
+ '--output',
+ outputPath,
+ '--emit',
+ 'angular',
+ '--verbose',
+ ]);
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ t.true(fs.existsSync(path.join(outputPath, 'model.generated.ts')));
+ t.true(result.stdout.includes("Auto-included 'models'"));
+ t.true(result.stdout.includes('E_INVALID_OPTION'));
+ });
+});
+
+test('cli generate --mapped-type replaces schema with import', t => {
+ withTempDir(outputPath => {
+ const result = runCli([
+ 'generate',
+ '--input',
+ fixture('petstore-rich.openapi.yaml'),
+ '--output',
+ outputPath,
+ '--mapped-type',
+ 'PetId:@demo/types:ExternalPetId',
+ ]);
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ const modelContents = fs.readFileSync(
+ path.join(outputPath, 'model.generated.ts'),
+ 'utf8',
+ );
+ t.true(modelContents.includes("import type { ExternalPetId } from '@demo/types'"));
+ t.true(modelContents.includes('ExternalPetId'));
+ t.false(modelContents.includes('export type PetId = string;'));
+ });
+});
+
+test('cli generate writes 3 artifacts for fixture without operations', t => {
+ withTempDir(outputPath => {
+ const result = runCli([
+ 'generate',
+ '--input',
+ fixture('empty-shapes.openapi.yaml'),
+ '--output',
+ outputPath,
+ ]);
+ t.is(result.status, 0);
+ t.true(fs.existsSync(path.join(outputPath, 'model.generated.ts')));
+ t.true(fs.existsSync(path.join(outputPath, 'rest.model.ts')));
+ t.true(fs.existsSync(path.join(outputPath, 'rest.util.ts')));
+ t.false(fs.existsSync(path.join(outputPath, 'rest')));
+ });
+});
+
+// ── Verbose: warnings ──────────────────────────────────────────────────────
+
+test('cli generate suppresses warnings without --verbose', t => {
+ // cookie-param emits a non-fatal warning (cookies aren't surfaced in the
+ // generated service contract — browsers manage cookies via the cookie
+ // store). header-param used to share this behaviour but headers are now
+ // first-class.
+ const result = runCli(['generate', '--input', fixture('cookie-param.openapi.yaml')]);
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ t.false(result.stdout.includes('Warnings'));
+ t.false(result.stdout.includes('E_UNSUPPORTED_SEMANTIC'));
+});
+
+test('cli generate --verbose prints warnings with code and operationId', t => {
+ const result = runCli([
+ 'generate',
+ '--input',
+ fixture('cookie-param.openapi.yaml'),
+ '--verbose',
+ ]);
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ t.true(result.stdout.includes('Warnings (1):'));
+ t.true(result.stdout.includes('[E_UNSUPPORTED_SEMANTIC]'));
+ t.true(result.stdout.includes("operationId 'listPets'"));
+ t.true(result.stdout.includes("'sessionId'"));
+ t.true(result.stdout.includes("'cookie'"));
+});
+
+// ── Errors: stderr format & exit code ──────────────────────────────────────
+
+test('cli generate exits 1 with human-readable error for unsupported semantic', t => {
+ const result = runCli([
+ 'generate',
+ '--input',
+ fixture('unsupported-semantic.openapi.yaml'),
+ ]);
+ t.is(result.status, 1);
+ t.is(result.stdout, '');
+ t.true(result.stderr.includes('E_UNSUPPORTED_SEMANTIC'));
+ t.true(result.stderr.includes('unsupported-semantic.openapi.yaml'));
+});
+
+test('cli generate exits 1 with human-readable error for malformed YAML', t => {
+ const result = runCli(['generate', '--input', fixture('malformed.yaml')]);
+ t.is(result.status, 1);
+ t.is(result.stdout, '');
+ t.true(result.stderr.length > 0);
+});
+
+test('cli generate exits 1 with human-readable error for unsupported root shape', t => {
+ const result = runCli(['generate', '--input', fixture('unsupported-root.yaml')]);
+ t.is(result.status, 1);
+ t.is(result.stdout, '');
+ t.true(result.stderr.includes('E_INPUT_INVALID'));
+ t.true(result.stderr.includes('decode'));
+});
+
+// ── Argument parsing errors ─────────────────────────────────────────────────
+
+test('cli generate exits 1 with readable error when --input is missing', t => {
+ const result = runCli(['generate']);
+ t.is(result.status, 1);
+ t.is(result.stdout, '');
+ t.true(result.stderr.includes('E_INVALID_OPTION'));
+ t.true(result.stderr.includes('--input'));
+});
+
+test('cli generate exits 1 with readable error for unknown argument', t => {
+ const result = runCli([
+ 'generate',
+ '--input',
+ fixture('petstore-minimal.openapi.yaml'),
+ '--bogus',
+ ]);
+ t.is(result.status, 1);
+ t.is(result.stdout, '');
+ t.true(result.stderr.includes('E_INVALID_OPTION'));
+});
+
+test('cli generate exits 1 with readable error for malformed --mapped-type', t => {
+ const result = runCli([
+ 'generate',
+ '--input',
+ fixture('petstore-minimal.openapi.yaml'),
+ '--mapped-type',
+ 'bad-format',
+ ]);
+ t.is(result.status, 1);
+ t.is(result.stdout, '');
+ t.true(result.stderr.includes('E_INVALID_OPTION'));
+});
+
+test('cli generate exits 1 with helpful error for --mapped-type with too many colons', t => {
+ // Windows-style absolute import paths like C:\some\path collide with the
+ // colon-delimited CLI surface. The error message must point users at the
+ // YAML/JSON config file as the supported workaround (C1).
+ const result = runCli([
+ 'generate',
+ '--input',
+ fixture('petstore-minimal.openapi.yaml'),
+ '--mapped-type',
+ 'PetId:C:\\Users\\x\\types:ExternalPetId:Alias',
+ ]);
+ t.is(result.status, 1);
+ t.is(result.stdout, '');
+ t.true(result.stderr.includes('Invalid --mapped-type'));
+ t.true(result.stderr.includes('mappedTypes:'));
+ t.true(result.stderr.includes('config file'));
+});
+
+test('cli generate exits 1 with readable error for --mapped-type with empty segment', t => {
+ const result = runCli([
+ 'generate',
+ '--input',
+ fixture('petstore-minimal.openapi.yaml'),
+ '--mapped-type',
+ 'PetId::ExternalPetId',
+ ]);
+ t.is(result.status, 1);
+ t.is(result.stdout, '');
+ t.true(result.stderr.includes('Invalid --mapped-type'));
+});
+
+test('cli prints usage for --help', t => {
+ const result = runCli(['--help']);
+ t.is(result.status, 0);
+ t.true(result.stdout.includes('Usage'));
+ t.true(result.stdout.includes('--input'));
+});
+
+test('cli with no args prints help to stdout and exits 2', t => {
+ // Bare `openapi-ng` should not silently succeed: CI scripts like
+ // `openapi-ng generate ... && next-step` would otherwise run `next-step`
+ // if the `generate` argv got eaten. Exit 2 is the conventional code for
+ // a usage error (matches GNU getopt, argparse, etc.).
+ const result = runCli([]);
+ t.is(result.status, 2);
+ t.regex(result.stdout, /Usage:/);
+});
+
+test('cli --help still exits 0', t => {
+ // Pin the existing behaviour so the "no args" change does not bleed
+ // into the explicit-help path.
+ const result = runCli(['--help']);
+ t.is(result.status, 0);
+});
+
+test('cli --version prints the package version and exits 0', t => {
+ const result = runCli(['--version']);
+ t.is(result.status, 0);
+ t.regex(result.stdout, /^\d+\.\d+\.\d+/);
+ t.is(result.stderr, '');
+});
+
+test('cli generate --help prints subcommand-specific usage with every flag', t => {
+ const result = runCli(['generate', '--help']);
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ // Mentions the subcommand by name so users know they got per-subcommand help.
+ t.true(result.stdout.toLowerCase().includes('generate'));
+ // Every documented flag must appear in the per-subcommand help.
+ for (const flag of [
+ '--input',
+ '--output',
+ '--emit',
+ '--mapped-type',
+ '--verbose',
+ '--config',
+ '--help',
+ ]) {
+ t.true(
+ result.stdout.includes(flag),
+ `generate --help must list ${flag}; got:\n${result.stdout}`,
+ );
+ }
+});
+
+test('cli generate -h is accepted as an alias for --help', t => {
+ const result = runCli(['generate', '-h']);
+ t.is(result.status, 0);
+ t.true(result.stdout.includes('--input'));
+});
+
+test('cli --help output includes an Examples section', t => {
+ const result = runCli(['--help']);
+ t.is(result.status, 0);
+ t.regex(result.stdout, /Examples:/);
+ t.regex(result.stdout, /openapi-ng init/);
+ t.regex(result.stdout, /openapi-ng generate/);
+});
+
+test('cli generate --help output includes an Examples section', t => {
+ const result = runCli(['generate', '--help']);
+ t.is(result.status, 0);
+ t.regex(result.stdout, /Examples:/);
+ t.regex(result.stdout, /openapi-ng generate/);
+});
+
+test('cli init --help prints subcommand-specific usage', t => {
+ const result = runCli(['init', '--help']);
+ t.is(result.status, 0);
+ t.true(result.stdout.toLowerCase().includes('init'));
+});
+
+test('cli prints usage for unknown command', t => {
+ const result = runCli(['unknown-command']);
+ t.is(result.status, 1);
+ t.is(result.stdout, '');
+ t.true(result.stderr.includes('E_INVALID_OPTION'));
+});
+
+// ── init command ──────────────────────────────────────────────────────────────
+
+test('cli init creates default config file in cwd', t => {
+ withTempDir(dir => {
+ const result = runCli(['init'], dir);
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ const configPath = path.join(dir, '.openapi-ng.yaml');
+ t.true(fs.existsSync(configPath));
+ const contents = fs.readFileSync(configPath, 'utf8');
+ t.true(contents.includes('input:'));
+ t.true(contents.includes('output:'));
+ t.true(contents.includes('emit:'));
+ });
+});
+
+test('cli init does not overwrite existing config file', t => {
+ withTempDir(dir => {
+ const configPath = path.join(dir, '.openapi-ng.yaml');
+ fs.writeFileSync(configPath, 'input: custom.yaml\n', 'utf8');
+ const result = runCli(['init'], dir);
+ t.not(result.status, 0);
+ t.regex(result.stdout + result.stderr, /Cannot init: existing config file/i);
+ const contents = fs.readFileSync(configPath, 'utf8');
+ t.is(contents, 'input: custom.yaml\n');
+ });
+});
+
+test('cli init --format yaml writes .openapi-ng.yaml (default behaviour preserved)', t => {
+ withTempDir(dir => {
+ const result = runCli(['init'], dir);
+ t.is(result.status, 0);
+ t.true(fs.existsSync(path.join(dir, '.openapi-ng.yaml')));
+ const contents = fs.readFileSync(path.join(dir, '.openapi-ng.yaml'), 'utf8');
+ t.regex(contents, /^# openapi-ng configuration/m);
+ t.regex(contents, /^input:/m);
+ });
+});
+
+test('cli init --format json writes .openapi-ng.json', t => {
+ withTempDir(dir => {
+ const result = runCli(['init', '--format', 'json'], dir);
+ t.is(result.status, 0);
+ const target = path.join(dir, '.openapi-ng.json');
+ t.true(fs.existsSync(target));
+ const parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
+ t.truthy(parsed.input);
+ t.truthy(parsed.output);
+ });
+});
+
+test('cli init --format ts writes openapi-ng.config.mts with defineConfig + RegExp example', t => {
+ withTempDir(dir => {
+ const result = runCli(['init', '--format', 'ts'], dir);
+ t.is(result.status, 0);
+ const target = path.join(dir, 'openapi-ng.config.mts');
+ t.true(fs.existsSync(target));
+ t.false(fs.existsSync(path.join(dir, 'openapi-ng.config.ts')));
+ const contents = fs.readFileSync(target, 'utf8');
+ t.regex(contents, /import \{ defineConfig \} from '@avsystem\/openapi-ng\/config'/);
+ t.regex(contents, /export default defineConfig\(\{/);
+ t.regex(contents, /parse: \/\^\[\^_\]\+_\(\?.+\)\$\//);
+ });
+});
+
+test('cli init --format js writes openapi-ng.config.mjs with JSDoc @type', t => {
+ withTempDir(dir => {
+ const result = runCli(['init', '--format', 'js'], dir);
+ t.is(result.status, 0);
+ const target = path.join(dir, 'openapi-ng.config.mjs');
+ t.false(fs.existsSync(path.join(dir, 'openapi-ng.config.js')));
+ t.true(fs.existsSync(target));
+ const contents = fs.readFileSync(target, 'utf8');
+ t.regex(contents, /@type \{import\('@avsystem\/openapi-ng'\)\.Config\}/);
+ });
+});
+
+test('cli init --format ts aborts when .openapi-ng.yaml exists (cross-format)', t => {
+ withTempDir(dir => {
+ fs.writeFileSync(path.join(dir, '.openapi-ng.yaml'), 'input: a\n', 'utf8');
+ const result = runCli(['init', '--format', 'ts'], dir);
+ t.not(result.status, 0);
+ t.regex(result.stdout + result.stderr, /existing config file/i);
+ t.regex(result.stdout + result.stderr, /\.openapi-ng\.yaml/);
+ t.false(fs.existsSync(path.join(dir, 'openapi-ng.config.mts')));
+ });
+});
+
+test('cli init --format yaml aborts when openapi-ng.config.ts exists (cross-format)', t => {
+ withTempDir(dir => {
+ fs.writeFileSync(
+ path.join(dir, 'openapi-ng.config.ts'),
+ 'export default {}\n',
+ 'utf8',
+ );
+ const result = runCli(['init', '--format', 'yaml'], dir);
+ t.not(result.status, 0);
+ t.regex(result.stdout + result.stderr, /openapi-ng\.config\.ts/);
+ });
+});
+
+test('cli init aborts on any of the eight discoverable config names', t => {
+ // Spot-check three at once: json, cjs, and mts each must block init.
+ const names = ['.openapi-ng.json', 'openapi-ng.config.cjs', 'openapi-ng.config.mts'];
+ for (const existing of names) {
+ withTempDir(dir => {
+ fs.writeFileSync(path.join(dir, existing), 'placeholder\n', 'utf8');
+ const result = runCli(['init', '--format', 'ts'], dir);
+ t.not(result.status, 0, `init should have aborted with ${existing} present`);
+ t.regex(result.stdout + result.stderr, new RegExp(existing.replace(/\./g, '\\.')));
+ });
+ }
+});
+
+test('cli init --format zsh errors with allow-list', t => {
+ withTempDir(dir => {
+ const result = runCli(['init', '--format', 'zsh'], dir);
+ t.not(result.status, 0);
+ t.regex(result.stderr, /--format/);
+ t.regex(result.stderr, /yaml.*json.*ts.*js/);
+ });
+});
+
+test('cli init --format js writes ESM template regardless of package.json#type', t => {
+ for (const pkgJson of ['{"type":"module"}', '{"type":"commonjs"}', '{"name":"x"}']) {
+ withTempDir(dir => {
+ fs.writeFileSync(path.join(dir, 'package.json'), pkgJson, 'utf8');
+ const result = runCli(['init', '--format', 'js'], dir);
+ t.is(result.status, 0);
+ t.true(fs.existsSync(path.join(dir, 'openapi-ng.config.mjs')));
+ const contents = fs.readFileSync(path.join(dir, 'openapi-ng.config.mjs'), 'utf8');
+ t.regex(contents, /^export default \{/m);
+ t.notRegex(contents, /module\.exports/);
+ });
+ }
+});
+
+// ── config file ───────────────────────────────────────────────────────────────
+
+test('cli generate reads input from config file', t => {
+ withTempDir(dir => {
+ fs.writeFileSync(
+ path.join(dir, '.openapi-ng.yaml'),
+ `input: ${fixture('petstore-minimal.openapi.yaml')}\n`,
+ 'utf8',
+ );
+ const result = runCli(['generate'], dir);
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ t.true(result.stdout.includes('Petstore Minimal'));
+ });
+});
+
+test('cli generate --input overrides config file input', t => {
+ withTempDir(dir => {
+ fs.writeFileSync(
+ path.join(dir, '.openapi-ng.yaml'),
+ 'input: nonexistent.yaml\n',
+ 'utf8',
+ );
+ const result = runCli(
+ ['generate', '--input', fixture('petstore-minimal.openapi.yaml')],
+ dir,
+ );
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ t.true(result.stdout.includes('Petstore Minimal'));
+ });
+});
+
+test('cli generate reads array-form emit from config file', t => {
+ withTempDir(dir => {
+ fs.writeFileSync(
+ path.join(dir, '.openapi-ng.yaml'),
+ [
+ `input: ${fixture('petstore-rich.openapi.yaml')}`,
+ `output: ${dir}`,
+ 'emit:',
+ ' - models',
+ ' - angular',
+ ].join('\n') + '\n',
+ 'utf8',
+ );
+ const result = runCli(['generate'], dir);
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ t.true(result.stdout.includes('model.generated.ts'));
+ t.true(fs.existsSync(path.join(dir, 'model.generated.ts')));
+ t.true(fs.existsSync(path.join(dir, 'rest', 'pet.rest.generated.ts')));
+ });
+});
+
+test('cli generate --config uses explicit config path', t => {
+ withTempDir(dir => {
+ const customConfig = path.join(dir, 'custom-config.yaml');
+ fs.writeFileSync(
+ customConfig,
+ `input: ${fixture('petstore-minimal.openapi.yaml')}\n`,
+ 'utf8',
+ );
+ const result = runCli(['generate', '--config', customConfig], dir);
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ t.true(result.stdout.includes('Petstore Minimal'));
+ });
+});
+
+test('cli generate reads mappedTypes from config file', t => {
+ withTempDir(dir => {
+ fs.writeFileSync(
+ path.join(dir, '.openapi-ng.yaml'),
+ [
+ `input: ${fixture('petstore-rich.openapi.yaml')}`,
+ `output: ${dir}`,
+ 'mappedTypes:',
+ ' - schema: PetId',
+ " import: '@demo/types'",
+ ' type: ExternalPetId',
+ ].join('\n') + '\n',
+ 'utf8',
+ );
+ const result = runCli(['generate'], dir);
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ const modelContents = fs.readFileSync(path.join(dir, 'model.generated.ts'), 'utf8');
+ t.true(modelContents.includes("import type { ExternalPetId } from '@demo/types'"));
+ t.false(modelContents.includes('export type PetId = string;'));
+ });
+});
+
+test('cli generate ignores absent config file', t => {
+ withTempDir(dir => {
+ const result = runCli(
+ ['generate', '--input', fixture('petstore-minimal.openapi.yaml')],
+ dir,
+ );
+ t.is(result.status, 0);
+ t.is(result.stderr, '');
+ t.true(result.stdout.includes('Petstore Minimal'));
+ });
+});
+
+test('cli generate reads input from openapi-ng.config.ts (end-to-end)', t => {
+ // Smoke test gated on native TS support; equivalent .mjs test below covers
+ // older Node so the contract is verified somewhere on every CI matrix entry.
+ const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number);
+ if (!(nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 6))) {
+ t.pass('skipped: Node native TS stripping requires 22.6+');
+ return;
+ }
+ withTempDir(dir => {
+ fs.writeFileSync(
+ path.join(dir, 'openapi-ng.config.ts'),
+ `import { defineConfig } from '${repoRoot.replace(/\\/g, '\\\\')}/lib/config.js';\n` +
+ `export default defineConfig({\n` +
+ ` input: ${JSON.stringify(fixture('petstore-rich.openapi.yaml'))},\n` +
+ ` output: ${JSON.stringify(path.join(dir, 'out'))},\n` +
+ `});\n`,
+ 'utf8',
+ );
+ const result = runCli(['generate'], dir);
+ t.is(result.status, 0, result.stderr);
+ t.true(fs.existsSync(path.join(dir, 'out', 'model.generated.ts')));
+ });
+});
+
+test('cli generate reads input from openapi-ng.config.mjs (end-to-end)', t => {
+ withTempDir(dir => {
+ fs.writeFileSync(
+ path.join(dir, 'openapi-ng.config.mjs'),
+ `export default {\n` +
+ ` input: ${JSON.stringify(fixture('petstore-rich.openapi.yaml'))},\n` +
+ ` output: ${JSON.stringify(path.join(dir, 'out'))},\n` +
+ `};\n`,
+ 'utf8',
+ );
+ const result = runCli(['generate'], dir);
+ t.is(result.status, 0, result.stderr);
+ t.true(fs.existsSync(path.join(dir, 'out', 'model.generated.ts')));
+ });
+});
+
+test('cli generate honours naming.parse RegExp from openapi-ng.config.mjs', t => {
+ // RegExp is the killer feature — we can express it now that JS/TS
+ // configs exist. The fixture's operationIds don't have an underscore
+ // prefix to strip, so the rule must fail gracefully and the fallback
+ // (the second rule, which always matches) provides the method name.
+ withTempDir(dir => {
+ fs.writeFileSync(
+ path.join(dir, 'openapi-ng.config.mjs'),
+ `export default {\n` +
+ ` input: ${JSON.stringify(fixture('petstore-rich.openapi.yaml'))},\n` +
+ ` output: ${JSON.stringify(path.join(dir, 'out'))},\n` +
+ ` naming: {\n` +
+ ` methodName: [\n` +
+ ` { from: '{operationId}', parse: /^[^_]+_(?.+)$/, format: '{capture.rest}', case: 'camel' },\n` +
+ ` '{operationId}',\n` +
+ ` ],\n` +
+ ` },\n` +
+ `};\n`,
+ 'utf8',
+ );
+ const result = runCli(['generate'], dir);
+ t.is(result.status, 0, result.stderr);
+ });
+});
+
+// ── Async-main rejection guard ────────────────────────────────────────────────
+
+test('cli generate emits a clean error (not "unhandled rejection") when --config path is missing', t => {
+ // Forces loadConfigFile to throw ENOENT. The inner try/catch in main
+ // catches this today; the top-level .catch is the defense-in-depth
+ // fallback for any future await in main that escapes a try/catch.
+ // Contract under either path: exit 1, single readable stderr line, no
+ // Node "[UnhandledPromiseRejection]" / multi-line stack dump.
+ const result = runCli([
+ 'generate',
+ '--config',
+ '/definitely/nonexistent/openapi-ng.yaml',
+ ]);
+ t.is(result.status, 1);
+ t.is(result.stdout, '');
+ t.true(result.stderr.length > 0, 'expected a readable error on stderr');
+ t.false(
+ result.stderr.includes('UnhandledPromiseRejection'),
+ `stderr leaked an unhandled-rejection header: ${result.stderr}`,
+ );
+ t.false(
+ result.stderr.includes('node:internal'),
+ `stderr leaked an internal Node stack frame: ${result.stderr}`,
+ );
+});
+
+test('cli --config errors with E_INPUT_INVALID and a clean message', t => {
+ const result = runCli(['generate', '--config', '/nonexistent/path-for-task-3-2.yaml']);
+ t.is(result.status, 1);
+ t.regex(result.stderr, /Error \[E_INPUT_INVALID\]/);
+ t.regex(result.stderr, /config file not found/i);
+ t.notRegex(result.stderr, /ENOENT/);
+});
+
+test('cli tags YAML parse errors in config as E_INPUT_INVALID', t => {
+ withTempDir(dir => {
+ fs.writeFileSync(path.join(dir, '.openapi-ng.yaml'), 'input: [unbalanced\n', 'utf8');
+ const result = runCli(['generate'], dir);
+ t.is(result.status, 1);
+ t.regex(result.stderr, /Error \[E_INPUT_INVALID\]/);
+ t.notRegex(result.stderr, /E_INVALID_OPTION/);
+ });
+});
+
+test('cli surfaces config-file emit errors with the structured formatter', t => {
+ withTempDir(dir => {
+ fs.writeFileSync(
+ path.join(dir, '.openapi-ng.yaml'),
+ 'input: x.yaml\nemit: [bogus]\n',
+ 'utf8',
+ );
+ const result = runCli(['generate'], dir);
+ t.is(result.status, 1);
+ t.regex(result.stderr, /Error \[E_INVALID_OPTION\]/);
+ t.regex(result.stderr, /'bogus'/);
+ });
+});
+
+// ── Lazy-load: native binding not loaded for non-generate commands ────────────
+
+test('--help works without loading native binding', t => {
+ const result = runCli(['--help'], repoRoot, {
+ env: { ...process.env, OPENAPI_NG_DISABLE_NATIVE_FOR_TEST: '1' },
+ });
+ t.is(result.status, 0);
+ t.true(result.stdout.includes('openapi-ng'));
+ t.is(result.stderr, '');
+});
+
+test('--version works without loading native binding', t => {
+ const result = runCli(['--version'], repoRoot, {
+ env: { ...process.env, OPENAPI_NG_DISABLE_NATIVE_FOR_TEST: '1' },
+ });
+ t.is(result.status, 0);
+ t.regex(result.stdout.trim(), /^\d+\.\d+\.\d+/);
+});
+
+test('cli --help mentions https URL input', t => {
+ const result = runCli(['--help']);
+ // Bare `openapi-ng` (no subcommand) is a usage error, exit 2 — but --help
+ // is explicit, exit 0.
+ t.is(result.status, 0);
+ t.true(result.stdout.includes('https://'));
+});
+
+test('cli generate --help describes --input accepting path or url', t => {
+ const result = runCli(['generate', '--help']);
+ t.is(result.status, 0);
+ t.true(result.stdout.includes('path|url'));
+});
diff --git a/__test__/diagnostic-codes-drift.spec.ts b/__test__/diagnostic-codes-drift.spec.ts
new file mode 100644
index 0000000..3217469
--- /dev/null
+++ b/__test__/diagnostic-codes-drift.spec.ts
@@ -0,0 +1,80 @@
+import test from 'ava';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { resolve, dirname } from 'node:path';
+import ts from 'typescript';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const dtsInPath = resolve(here, '..', 'index.d.ts.in');
+const diagnosticsPagePath = resolve(
+ here,
+ '..',
+ 'website',
+ 'src',
+ 'content',
+ 'docs',
+ 'reference',
+ 'diagnostics.md',
+);
+
+const dtsIn = readFileSync(dtsInPath, 'utf8');
+const diagnosticsPage = readFileSync(diagnosticsPagePath, 'utf8');
+
+// Pull every ```ts ... ``` fenced block out of the Starlight page and
+// concatenate them: the page intentionally splits `DiagnosticCode` and
+// `DiagnosticSubcode` across separate snippets. Joining lets us pass
+// the result to a single TS parse.
+function tsBlocksFromMarkdown(md: string): string {
+ const out: string[] = [];
+ const fence = /```ts\n([\s\S]*?)\n```/g;
+ let m: RegExpExecArray | null;
+ while ((m = fence.exec(md)) !== null) {
+ out.push(m[1]);
+ }
+ return out.join('\n\n');
+}
+
+// Parse the source as TypeScript and pull every string-literal member
+// of the union assigned to `type = ...`. AST-based to ride out
+// reformatting (comments, trailing commas in unions, alternative join
+// styles) that a regex would silently mis-handle.
+function extractUnion(source: string, name: string): Set {
+ const sf = ts.createSourceFile(
+ `${name}.ts`,
+ source,
+ ts.ScriptTarget.Latest,
+ /* setParentNodes */ true,
+ ts.ScriptKind.TS,
+ );
+ let found: ts.TypeAliasDeclaration | null = null;
+ sf.forEachChild(node => {
+ if (ts.isTypeAliasDeclaration(node) && node.name.text === name) {
+ found = node;
+ }
+ });
+ if (!found) throw new Error(`${name} type alias not found`);
+ const aliased = (found as ts.TypeAliasDeclaration).type;
+ const members: ts.TypeNode[] = ts.isUnionTypeNode(aliased) ? [...aliased.types] : [aliased];
+ const values = new Set();
+ for (const member of members) {
+ if (ts.isLiteralTypeNode(member) && ts.isStringLiteral(member.literal)) {
+ values.add(member.literal.text);
+ } else {
+ throw new Error(
+ `${name} union contains non-string-literal member: ${member.getText(sf)}`,
+ );
+ }
+ }
+ return values;
+}
+
+const docSource = tsBlocksFromMarkdown(diagnosticsPage);
+
+for (const name of ['DiagnosticCode', 'DiagnosticSubcode']) {
+ test(`${name}: Starlight diagnostics page matches index.d.ts.in`, t => {
+ const truth = extractUnion(dtsIn, name);
+ const documented = extractUnion(docSource, name);
+ t.true(truth.size > 0, `${name} extraction must be non-empty`);
+ t.deepEqual([...documented].sort(), [...truth].sort());
+ });
+}
diff --git a/__test__/fetch-input.spec.ts b/__test__/fetch-input.spec.ts
new file mode 100644
index 0000000..72b44d4
--- /dev/null
+++ b/__test__/fetch-input.spec.ts
@@ -0,0 +1,380 @@
+import test from 'ava';
+import {
+ fetchInput,
+ isUrl,
+ __setDnsLookupForTest,
+} from '../lib/fetch-input.js';
+
+// The SSRF guard added in lib/fetch-input.js performs a DNS lookup on
+// every non-IP-literal host. Ava runs tests concurrently within a file,
+// so a module-level DNS stub installed by one test can race with
+// another. Install a permissive default at module load that resolves
+// every host to a public address — the existing tests pass `fetchImpl`
+// to mock the network and never care about the DNS resolution. Tests
+// that need a different stub use `test.serial` (so they run sequentially
+// in declaration order, before any concurrent test) and restore the
+// default via `t.teardown`.
+type DnsLookup = (
+ host: string,
+ options?: { all?: boolean },
+) => Promise>;
+const PUBLIC_DNS_STUB: DnsLookup = async () => [{ address: '1.1.1.1', family: 4 }];
+__setDnsLookupForTest(PUBLIC_DNS_STUB);
+
+function stubDns(impl: DnsLookup) {
+ __setDnsLookupForTest(impl);
+}
+function restoreDns() {
+ __setDnsLookupForTest(PUBLIC_DNS_STUB);
+}
+
+function jsonResponse(
+ body: string,
+ status = 200,
+ headers: Record = {},
+): Response {
+ return new Response(body, {
+ status,
+ headers: { 'content-type': 'application/json', ...headers },
+ });
+}
+
+function yamlResponse(
+ body: string,
+ status = 200,
+ headers: Record = {},
+): Response {
+ return new Response(body, {
+ status,
+ headers: { 'content-type': 'application/yaml', ...headers },
+ });
+}
+
+function redirectResponse(location: string, status = 302): Response {
+ return new Response(null, { status, headers: { location } });
+}
+
+// --- isUrl ---
+
+test('isUrl returns true for https://, http://, and uppercase variants', t => {
+ t.true(isUrl('https://example.com/spec.yaml'));
+ t.true(isUrl('http://example.com/spec.yaml'));
+ t.true(isUrl('HTTPS://example.com/spec.yaml'));
+ t.true(isUrl('HTTP://example.com/spec.yaml'));
+});
+
+test('isUrl returns false for file paths', t => {
+ t.false(isUrl('./spec.yaml'));
+ t.false(isUrl('/absolute/path.yaml'));
+ t.false(isUrl('spec.yaml'));
+ t.false(isUrl(''));
+});
+
+// --- happy-path content-type detection ---
+
+test('fetchInput returns json format hint for application/json', async t => {
+ const fetchImpl = async () => jsonResponse('{"openapi":"3.0.3"}');
+ const out = await fetchInput('https://x/spec', { fetchImpl });
+ t.is(out.format, 'json');
+ t.is(out.contents, '{"openapi":"3.0.3"}');
+});
+
+test('fetchInput returns yaml format hint for application/yaml', async t => {
+ const fetchImpl = async () => yamlResponse('openapi: 3.0.3\n');
+ const out = await fetchInput('https://x/spec', { fetchImpl });
+ t.is(out.format, 'yaml');
+});
+
+test('fetchInput returns json hint for application/openapi+json (RFC 6839 +json suffix)', async t => {
+ const fetchImpl = async () =>
+ new Response('{}', { headers: { 'content-type': 'application/openapi+json' } });
+ const out = await fetchInput('https://x/spec', { fetchImpl });
+ t.is(out.format, 'json');
+});
+
+test('fetchInput returns yaml hint for application/vnd.oai.openapi+yaml', async t => {
+ const fetchImpl = async () =>
+ new Response('openapi: 3.0.3\n', {
+ headers: { 'content-type': 'application/vnd.oai.openapi+yaml' },
+ });
+ const out = await fetchInput('https://x/spec', { fetchImpl });
+ t.is(out.format, 'yaml');
+});
+
+test('fetchInput falls back to URL pathname extension when content-type is missing', async t => {
+ const fetchImpl = async () => new Response('openapi: 3.0.3\n');
+ const out = await fetchInput('https://x/api/openapi.yaml', { fetchImpl });
+ t.is(out.format, 'yaml');
+});
+
+test('fetchInput returns null format when no content-type and no URL extension', async t => {
+ const fetchImpl = async () => new Response('openapi: 3.0.3\n');
+ const out = await fetchInput('https://x/api/openapi', { fetchImpl });
+ t.is(out.format, null);
+});
+
+test('fetchInput strips charset parameter from content-type', async t => {
+ const fetchImpl = async () =>
+ new Response('{}', {
+ headers: { 'content-type': 'application/json; charset=utf-8' },
+ });
+ const out = await fetchInput('https://x/spec', { fetchImpl });
+ t.is(out.format, 'json');
+});
+
+// --- scheme enforcement ---
+
+test('fetchInput rejects http:// URLs', async t => {
+ const err = await t.throwsAsync(async () => {
+ await fetchInput('http://example.com/spec.yaml', {
+ fetchImpl: async () => jsonResponse('{}'),
+ });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true((err as any).message.includes('https://'));
+});
+
+test('fetchInput accepts uppercase HTTPS:// scheme', async t => {
+ const fetchImpl = async () => jsonResponse('{}');
+ const out = await fetchInput('HTTPS://example.com/spec.yaml', { fetchImpl });
+ t.is(out.format, 'json');
+});
+
+test('fetchInput rejects uppercase HTTP:// scheme', async t => {
+ const err = await t.throwsAsync(async () => {
+ await fetchInput('HTTP://example.com/spec.yaml', {
+ fetchImpl: async () => jsonResponse('{}'),
+ });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true((err as any).message.includes('https://'));
+});
+
+// --- redirects ---
+
+test('fetchInput follows a single 302 to a 200', async t => {
+ const responses = [
+ redirectResponse('https://example.com/final.yaml'),
+ yamlResponse('openapi: 3.0.3\n'),
+ ];
+ const seen: string[] = [];
+ const fetchImpl = async (url: string) => {
+ seen.push(url);
+ return responses.shift()!;
+ };
+ const out = await fetchInput('https://example.com/redirect', { fetchImpl });
+ t.is(out.format, 'yaml');
+ t.deepEqual(seen, ['https://example.com/redirect', 'https://example.com/final.yaml']);
+});
+
+test('fetchInput resolves a relative Location against the current URL', async t => {
+ const responses = [redirectResponse('/spec.yaml'), yamlResponse('openapi: 3.0.3\n')];
+ const seen: string[] = [];
+ const fetchImpl = async (url: string) => {
+ seen.push(url);
+ return responses.shift()!;
+ };
+ await fetchInput('https://example.com/redirect/here', { fetchImpl });
+ t.is(seen[1], 'https://example.com/spec.yaml');
+});
+
+test('fetchInput errors after 5 hops', async t => {
+ const fetchImpl = async (url: string) => {
+ const next = url + '/hop';
+ return redirectResponse(next);
+ };
+ const err = await t.throwsAsync(async () => {
+ await fetchInput('https://example.com/0', { fetchImpl });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true((err as any).message.includes('Redirect'));
+});
+
+test('fetchInput rejects https → http downgrade redirects', async t => {
+ const responses = [redirectResponse('http://example.com/spec.yaml'), yamlResponse('x')];
+ const fetchImpl = async () => responses.shift()!;
+ const err = await t.throwsAsync(async () => {
+ await fetchInput('https://example.com/redirect', { fetchImpl });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true(
+ (err as any).message.includes('downgrade') ||
+ (err as any).message.includes('https to http'),
+ );
+});
+
+test('fetchInput does not auto-follow 304/305/306; surfaces them as HTTP errors', async t => {
+ for (const status of [304, 305, 306]) {
+ const err = await t.throwsAsync(async () => {
+ await fetchInput('https://example.com/spec', {
+ fetchImpl: async () => new Response(null, { status }),
+ });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true((err as any).message.startsWith(`HTTP ${status}`));
+ }
+});
+
+// --- non-2xx ---
+
+test('fetchInput surfaces 404 with status and statusText', async t => {
+ const fetchImpl = async () =>
+ new Response('not found', { status: 404, statusText: 'Not Found' });
+ const err = await t.throwsAsync(async () => {
+ await fetchInput('https://example.com/missing', { fetchImpl });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true((err as any).message.includes('404'));
+});
+
+// --- timeout ---
+
+test('fetchInput respects the timeout option (shared across redirects)', async t => {
+ function delayed(ms: number, response: Response, signal: AbortSignal) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => resolve(response), ms);
+ signal.addEventListener('abort', () => {
+ clearTimeout(timer);
+ reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
+ });
+ });
+ }
+
+ // First hop returns a redirect after 30 ms; second hop would return
+ // a body after 30 ms. Total 60 ms, timeout 40 ms — must abort.
+ const fetchImpl = async (url: string, init?: RequestInit) => {
+ const signal = init?.signal as AbortSignal;
+ if (url.endsWith('/0')) {
+ return delayed(30, redirectResponse('https://example.com/1'), signal);
+ }
+ return delayed(30, yamlResponse('openapi: 3.0.3\n'), signal);
+ };
+
+ const err = await t.throwsAsync(async () => {
+ await fetchInput('https://example.com/0', { fetchImpl, timeoutMs: 40 });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true((err as any).message.includes('timed out'));
+});
+
+// --- size cap ---
+
+test('fetchInput rejects responses with Content-Length above the cap', async t => {
+ const body = 'x'.repeat(1000);
+ const fetchImpl = async () =>
+ new Response(body, {
+ headers: { 'content-type': 'application/json', 'content-length': '1000' },
+ });
+ const err = await t.throwsAsync(async () => {
+ await fetchInput('https://example.com/big.json', { fetchImpl, maxBytes: 500 });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true((err as any).message.includes('exceeds'));
+});
+
+test('fetchInput rejects body that exceeds cap during streaming (no Content-Length)', async t => {
+ function stream(chunks: string[]): Response {
+ const enc = new TextEncoder();
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ for (const c of chunks) controller.enqueue(enc.encode(c));
+ controller.close();
+ },
+ }),
+ { headers: { 'content-type': 'application/json' } },
+ );
+ }
+ const fetchImpl = async () => stream(['x'.repeat(300), 'x'.repeat(300)]);
+ const err = await t.throwsAsync(async () => {
+ await fetchInput('https://example.com/streamy.json', { fetchImpl, maxBytes: 500 });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true((err as any).message.includes('exceeds'));
+});
+
+// --- SSRF guard ---
+// These tests mutate the module-level DNS stub via stubDns; run them
+// serially so they don't race each other, and restore the public-IP
+// default via t.teardown.
+
+test.serial('fetchInput rejects literal IPv4 metadata address before any fetch', async t => {
+ t.teardown(restoreDns);
+ let fetched = false;
+ const fetchImpl = async () => {
+ fetched = true;
+ return jsonResponse('{}');
+ };
+ const err = await t.throwsAsync(async () => {
+ await fetchInput('https://169.254.169.254/latest/meta-data/', { fetchImpl });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true((err as any).message.includes('169.254.169.254'));
+ t.false(fetched, 'fetchImpl must not be invoked when the host resolves private');
+});
+
+test.serial('fetchInput rejects a host whose DNS resolves to loopback', async t => {
+ t.teardown(restoreDns);
+ stubDns(async () => [{ address: '127.0.0.1', family: 4 }]);
+ const fetchImpl = async () => jsonResponse('{}');
+ const err = await t.throwsAsync(async () => {
+ await fetchInput('https://localhost/spec.json', { fetchImpl });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true((err as any).message.includes('127.0.0.1'));
+});
+
+test.serial(
+ 'fetchInput rejects a redirect that lands on a private address on the second hop',
+ async t => {
+ t.teardown(restoreDns);
+ stubDns(async (host: string) => {
+ if (host === 'public.example.com') return [{ address: '93.184.216.34', family: 4 }];
+ if (host === 'internal.example.com') return [{ address: '10.0.0.5', family: 4 }];
+ throw new Error(`unexpected DNS lookup for ${host}`);
+ });
+ const responses = [
+ redirectResponse('https://internal.example.com/secret'),
+ jsonResponse('{}'),
+ ];
+ const fetchImpl = async () => responses.shift()!;
+ const err = await t.throwsAsync(async () => {
+ await fetchInput('https://public.example.com/redirect', { fetchImpl });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true((err as any).message.includes('10.0.0.5'));
+ },
+);
+
+test.serial(
+ 'fetchInput honours OPENAPI_NG_ALLOW_PRIVATE_HOSTS=1 to allow private addresses',
+ async t => {
+ process.env.OPENAPI_NG_ALLOW_PRIVATE_HOSTS = '1';
+ t.teardown(() => {
+ delete process.env.OPENAPI_NG_ALLOW_PRIVATE_HOSTS;
+ });
+ const fetchImpl = async () => jsonResponse('{"ok":true}');
+ const out = await fetchInput('https://169.254.169.254/spec', { fetchImpl });
+ t.is(out.contents, '{"ok":true}');
+ },
+);
+
+test.serial(
+ 'fetchInput accepts public IPv4 when DNS resolves outside any blocklist',
+ async t => {
+ t.teardown(restoreDns);
+ stubDns(async () => [{ address: '1.1.1.1', family: 4 }]);
+ const fetchImpl = async () => jsonResponse('{"ok":true}');
+ const out = await fetchInput('https://example.com/spec', { fetchImpl });
+ t.is(out.contents, '{"ok":true}');
+ },
+);
+
+test.serial('fetchInput rejects IPv6 loopback literal', async t => {
+ const fetchImpl = async () => jsonResponse('{}');
+ const err = await t.throwsAsync(async () => {
+ await fetchInput('https://[::1]/spec', { fetchImpl });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true((err as any).message.includes('::1'));
+});
diff --git a/__test__/generate.snapshot.spec.ts b/__test__/generate.snapshot.spec.ts
new file mode 100644
index 0000000..8192458
--- /dev/null
+++ b/__test__/generate.snapshot.spec.ts
@@ -0,0 +1,471 @@
+import test from 'ava';
+import fs from 'node:fs';
+import path from 'node:path';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+// Use the wrapper so caught errors are real `GenerateError` instances
+// (the failure snapshots pin `warnings`/`path` from the upgraded class
+// shape).
+import { generate } from '../lib/index.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.join(__dirname, '..');
+// Pass fixtures as paths relative to CWD (project root) so display_path
+// returns a short, machine-independent path in snapshots and banners.
+const fixture = (name: string) => path.join('test', 'fixtures', name);
+const snapshot = (name: string) =>
+ path.join(repoRoot, '__test__', 'snapshots', 'generate-native', name);
+
+function readJsonSnapshot(name: string) {
+ return JSON.parse(fs.readFileSync(snapshot(name), 'utf8'));
+}
+
+// Strip the per-version banner from artifact contents so snapshots
+// survive version bumps. Keep in sync with BANNER_RE in
+// scripts/regen-snapshots.mjs.
+const BANNER_RE =
+ /^\/\/ Generated by openapi-ng v[^\n]*\n\/\/ Source: [^\n]*\n\/\/ DO NOT EDIT[^\n]*\n\n/u;
+
+// Paths whose contents are byte-identical across every success fixture.
+// Listed by path in each per-fixture snapshot but the file bodies live
+// once under __test__/snapshots/generate-native/static-template/ — see
+// scripts/regen-snapshots.mjs for the storage layout.
+const STATIC_TEMPLATE_PATHS = new Set(['rest.model.ts', 'rest.util.ts']);
+const STATIC_TEMPLATE_DIR = path.join(
+ repoRoot,
+ '__test__',
+ 'snapshots',
+ 'generate-native',
+ 'static-template',
+);
+
+/**
+ * Hydrate a path-only success snapshot into the full shape the live
+ * `generate` result has: each non-static artifact picks up its
+ * `contents` from `//`; static-template
+ * artifacts remain path-only because their bodies are pinned in
+ * static-template.json and asserted by `staticTemplateArtifacts` below.
+ */
+function hydrateSuccessSnapshot(fixtureName: string) {
+ const snap = readJsonSnapshot(`${fixtureName}.success.json`);
+ const siblingsDir = path.join(
+ repoRoot,
+ '__test__',
+ 'snapshots',
+ 'generate-native',
+ fixtureName,
+ );
+ const artifacts = snap.artifacts.map((a: any) => {
+ if (STATIC_TEMPLATE_PATHS.has(a.path)) {
+ return { path: a.path };
+ }
+ const filePath = path.join(siblingsDir, a.path);
+ const contents = fs.readFileSync(filePath, 'utf8');
+ return { path: a.path, contents };
+ });
+ return { ...snap, artifacts };
+}
+
+/**
+ * Hydrate the static-template snapshot: list of {path, contents}
+ * loaded from /static-template/. Sorted by path so
+ * the deep-equal stays insensitive to OS readdir order.
+ */
+function hydrateStaticTemplate() {
+ const snap = readJsonSnapshot('static-template.json');
+ const artifacts = snap.artifacts
+ .map((a: any) => ({
+ path: a.path,
+ contents: fs.readFileSync(path.join(STATIC_TEMPLATE_DIR, a.path), 'utf8'),
+ }))
+ .sort((a: any, b: any) => a.path.localeCompare(b.path));
+ return { artifacts };
+}
+
+function normalizeSuccessResult(result: any) {
+ return {
+ ...result,
+ artifacts: result.artifacts.map((a: any) => {
+ if (STATIC_TEMPLATE_PATHS.has(a.path)) {
+ return { path: a.path };
+ }
+ return {
+ ...a,
+ contents:
+ typeof a.contents === 'string' ? a.contents.replace(BANNER_RE, '') : a.contents,
+ };
+ }),
+ };
+}
+
+function staticTemplateArtifacts(result: any) {
+ return {
+ artifacts: result.artifacts
+ .filter((a: any) => STATIC_TEMPLATE_PATHS.has(a.path))
+ .map((a: any) => ({ path: a.path, contents: a.contents.replace(BANNER_RE, '') }))
+ .sort((a: any, b: any) => a.path.localeCompare(b.path)),
+ };
+}
+
+async function successResult(name: string, options: Record = {}) {
+ return normalizeSuccessResult(
+ await generate({
+ inputPath: fixture(name),
+ emit: ['models', 'angular'],
+ ...options,
+ }),
+ );
+}
+
+async function failurePayload(name: string, options: Record = {}) {
+ try {
+ await generate({
+ inputPath: fixture(name),
+ emit: ['models', 'angular'],
+ ...options,
+ });
+ throw new Error(`Expected ${name} to fail.`);
+ } catch (thrown) {
+ const e = thrown as any;
+ return {
+ code: e.code,
+ message: e.message,
+ path: e.path ?? null,
+ warnings: e.warnings ?? [],
+ };
+ }
+}
+
+const successFixtures = [
+ 'petstore-minimal.openapi.yaml',
+ 'petstore-minimal.openapi.json',
+ 'petstore-rich.openapi.yaml',
+ 'petstore-rich.openapi.json',
+ 'oneof-anyof-composition.openapi.yaml',
+ 'oneof-anyof-composition.openapi.json',
+ 'allof-composition.openapi.yaml',
+ 'additional-properties.openapi.yaml',
+ // additional-properties: false combined with declared `properties`
+ // emits the named interface unchanged — OpenAPI semantics treat
+ // `false` as "no extras beyond what's declared", which is the
+ // default for TypeScript interfaces, so the field is structurally
+ // a no-op for our emit. (The `true` form remains rejected — see
+ // failureFixtures below.)
+ 'additional-properties-false.openapi.yaml',
+ 'recursive-model.openapi.yaml',
+ 'single-entry-composition.openapi.yaml',
+ 'empty-shapes.openapi.yaml',
+ 'inline-model.openapi.yaml',
+ 'nullable-optional.openapi.yaml',
+ // header-param emits a non-fatal warning for the unsupported `header`
+ // location; the snapshot pins the full warning shape (code/stage/
+ // severity/fatal/message/path) so multi-warning regressions surface
+ // here, not via a separate substring assertion (E12).
+ 'header-param.openapi.yaml',
+ // large-enum exercises both branches of the D8 enum-rendering width
+ // threshold: 50 values forces multi-line, 2 values stays inline.
+ 'large-enum.openapi.yaml',
+ // multi-tag-operation pins the (currently silent) policy that secondary
+ // tags are dropped — operation_grouper.rs uses tags.first() only. If
+ // someone later wires multi-tag emission, this snapshot surfaces the
+ // change.
+ 'multi-tag-operation.openapi.yaml',
+ // nullable-oneof exercises apply_nullable over composition: a oneOf
+ // with `nullable: true` (both at top level and as an inline property
+ // shape). Pins how the nullable wrapper combines with union semantics.
+ 'nullable-oneof.openapi.yaml',
+ // security-schemes confirms that components.securitySchemes blocks are
+ // silently accepted (no error, no warning) — generation proceeds as if
+ // the block were not present. If we ever wire auth-aware emission this
+ // snapshot surfaces the change.
+ 'security-schemes.openapi.yaml',
+ // circular-allof exercises the E4 recursion-guard happy path: 5 layers
+ // of allOf composition that bottom out at BaseAuditFields. Catches any
+ // off-by-one in MAX_NORMALIZE_DEPTH that would falsely fail legit
+ // deeply-nested specs.
+ 'circular-allof.openapi.yaml',
+ // discriminated-union pins the TS surface for `oneOf` + `discriminator:`
+ // (D11).
+ 'discriminated-union.openapi.yaml',
+ // bench-large is a large realistic spec (100+ schemas, 40+ operations).
+ // Adding it to the snapshot suite catches regressions that only surface
+ // at scale — sort order changes, buffer sizing issues, etc.
+ 'bench-large.openapi.yaml',
+ // reserved-prop-names pins the identifier-escaping policy: reserved
+ // words like `class` stay unquoted (valid TS property names), while
+ // digit-first / kebab / dotted / space-containing names get
+ // single-quoted via safe_property_name.
+ 'reserved-prop-names.openapi.yaml',
+ // jsdoc-descriptions pins JSDoc preservation: schema description on
+ // interface/type alias/enum, per-property description, and
+ // operation summary+description merged onto the service member.
+ 'jsdoc-descriptions.openapi.yaml',
+ // multi-warning triggers two normalize-stage cookie-parameter warnings
+ // in one operation. Pins the warning order (sessionId before
+ // trackingId) so a regression that reorders or coalesces the
+ // pipeline's pre-fatal diagnostics surfaces here, not as a silent
+ // change.
+ 'multi-warning.openapi.yaml',
+ // deprecated-fields exercises OpenAPI `deprecated: true` mapping to
+ // `@deprecated` JSDoc on the emitted operation, top-level type alias
+ // (enum), and per-property declaration. Pins the JSDoc emission so a
+ // regression that drops the tag surfaces here.
+ 'deprecated-fields.openapi.yaml',
+ // recursive-oneof closes the cycle-handling coverage matrix. The
+ // existing fixtures cover cycles via plain `$ref` properties
+ // (recursive-model) and bounded deep `allOf` chains (circular-allof);
+ // this one pins cycles routed through `oneOf` composition, where the
+ // back-edge lands as an unresolved Ref inside a union member.
+ 'recursive-oneof.openapi.yaml',
+ // string-formats exercises the format-dropped warning policy: every
+ // schema-level `format` hint (uuid, email, uri, date, date-time)
+ // surfaces as an E_UNSUPPORTED_SEMANTIC warning with subcode
+ // 'format-dropped'. Pins both the warning text/order and the fact that
+ // generation still proceeds with the base type.
+ 'string-formats.openapi.yaml',
+ // discriminator-mapping pins the `discriminator.mapping` honoring policy:
+ // when a oneOf carries an explicit wire-value mapping, the narrowed
+ // literal on each member must use the mapping key (e.g. `feline`,
+ // `canine`) rather than the lowercased schema name. Regressions that
+ // ignore `mapping` would emit `'cat'`/`'dog'` here.
+ 'discriminator-mapping.openapi.yaml',
+ // discriminator-allof exercises the `oneOf` + `allOf`-shaped members
+ // path: each member composes a shared base (Animal) with a
+ // variant-specific tail that redeclares the discriminator property.
+ // The Intersection walk must reach into the inline part to narrow
+ // `kind` to a single string literal on Cat/Dog.
+ 'discriminator-allof.openapi.yaml',
+ // anchor-modest pins the accept-side boundary of the
+ // mapping-expansion-exceeded guard: a small anchor (Audit) reused 3×
+ // across composed schemas — the kind of legitimate anchor pattern
+ // hand-written specs commonly use. The re-serialised byte ratio stays
+ // well under the 50× cap, so this fixture proves the guard does not
+ // regress modest anchor use. The reject-side boundary lives in
+ // `failureFixtures` as `anchor-fanout.openapi.yaml`.
+ 'anchor-modest.openapi.yaml',
+ // body-multipart-mixed-fields exercises the comprehensive multipart
+ // form-body walker: scalar + array-of-scalar + binary + array-of-binary
+ // + optional scalar. Pins the FormData IIFE shape (`fd.append(...)`
+ // per field, `String(...)` cast for scalars, raw passthrough for
+ // binaries, `if (... !== undefined)` guards for optional fields) and
+ // the request-interface field types (`Blob | File` and `(Blob | File)[]`).
+ 'body-multipart-mixed-fields.openapi.yaml',
+ // body-multipart-ref-to-named-object verifies the flattened_body_ref
+ // import suppression: when a multipart body schema is a top-level $ref
+ // to a named object (UploadForm), its properties are inlined as
+ // form-fields and the now-redundant UploadForm import is omitted.
+ 'body-multipart-ref-to-named-object.openapi.yaml',
+ // body-urlencoded-scalar-and-array exercises the urlencoded form-body
+ // walker: scalar + array-of-scalar. Pins the URLSearchParams IIFE
+ // shape (distinct from FormData) and the absence of binary fields
+ // (rejected upstream in normalize for urlencoded).
+ 'body-urlencoded-scalar-and-array.openapi.yaml',
+ // response-blob-via-pdf verifies the default response-kind classifier
+ // routes `application/pdf` to `Blob` using the `requestFactory.blob<…>(…)` variant.
+ 'response-blob-via-pdf.openapi.yaml',
+ // response-text-via-text-plain verifies the default response-kind
+ // classifier routes `text/plain` to `string` using the `requestFactory.text<…>(…)` variant.
+ 'response-text-via-text-plain.openapi.yaml',
+ // response-problem-json verifies the `*+json` suffix rule: a media
+ // type like `application/problem+json` classifies as Json (not Blob),
+ // so the response is emitted as a typed JSON shape via the default
+ // `requestFactory<…>(…)` (no non-JSON variant).
+ 'response-problem-json.openapi.yaml',
+] as const;
+
+for (const fixtureName of successFixtures) {
+ test(`generate preserves full success payload snapshot for ${fixtureName}`, async t => {
+ t.deepEqual(await successResult(fixtureName), hydrateSuccessSnapshot(fixtureName));
+ });
+}
+
+// rest.model.ts and rest.util.ts are byte-identical across every success
+// fixture. Pin them once here; per-fixture snapshots above keep only the
+// `path` for these two artifacts. petstore-minimal is the smallest
+// fixture that emits them, so it doubles as the baseline.
+test('generate emits stable static-template artifacts (rest.model.ts, rest.util.ts)', async t => {
+ const baseline = await generate({
+ inputPath: fixture('petstore-minimal.openapi.yaml'),
+ emit: ['models', 'angular'],
+ });
+ t.deepEqual(staticTemplateArtifacts(baseline), hydrateStaticTemplate());
+});
+
+// Compile gate: write every success-snapshot's artifacts (plus the static
+// templates) to a temp project tree and run `tsc --noEmit` over them.
+// Catches regressions where snapshots stay textually stable but the emitted
+// TS no longer compiles — e.g. an Angular service that references an
+// un-imported response type. Lives in this file (next to the snapshot
+// loader) so the inputs the gate type-checks are exactly the snapshots
+// committed to disk, not a re-generation that might mask drift.
+test('snapshot artifacts type-check under tsc --noEmit', t => {
+ const repoNodeModules = path.join(repoRoot, 'node_modules');
+ if (!fs.existsSync(path.join(repoNodeModules, 'typescript', 'bin', 'tsc'))) {
+ t.fail('node_modules/typescript not installed — run `pnpm install` first');
+ return;
+ }
+
+ // Co-locate the project tree with the existing angular-consumer fixture
+ // so module resolution traverses up to repo node_modules (same path that
+ // tsconfig.json's "moduleResolution": "bundler" relies on). Live as a
+ // sibling of `generated/` rather than inside it: generate.spec.ts's
+ // reset helper recursively wipes `generated/` and would race with this
+ // file under AVA's per-file parallelism.
+ const compileRoot = path.join(
+ repoRoot,
+ '__test__',
+ 'angular-consumer',
+ '__snapshot_compile__',
+ );
+ fs.rmSync(compileRoot, { recursive: true, force: true });
+ fs.mkdirSync(compileRoot, { recursive: true });
+
+ const staticArtifacts: Array<{ path: string; contents: string }> =
+ hydrateStaticTemplate().artifacts;
+
+ const includeGlobs: string[] = [];
+ for (const fixtureName of successFixtures) {
+ const snap = hydrateSuccessSnapshot(fixtureName);
+ const fixtureDir = path.join(
+ compileRoot,
+ fixtureName.replace(/[^a-zA-Z0-9_-]+/g, '_'),
+ );
+ fs.mkdirSync(fixtureDir, { recursive: true });
+
+ for (const artifact of snap.artifacts) {
+ // Per-fixture snapshots elide static-template `contents` (only the
+ // `path` survives); pull bodies from the shared static-template
+ // sibling files via `hydrateStaticTemplate`.
+ const contents =
+ typeof artifact.contents === 'string'
+ ? artifact.contents
+ : staticArtifacts.find(a => a.path === artifact.path)?.contents;
+ if (contents === undefined) {
+ t.fail(`Missing contents for ${artifact.path} in ${fixtureName}`);
+ return;
+ }
+ const filePath = path.join(fixtureDir, artifact.path);
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, contents, 'utf8');
+ }
+ includeGlobs.push(`${path.basename(fixtureDir)}/**/*.ts`);
+ }
+
+ const compileTsconfig = {
+ extends: '../tsconfig.json',
+ include: includeGlobs,
+ };
+ fs.writeFileSync(
+ path.join(compileRoot, 'tsconfig.json'),
+ JSON.stringify(compileTsconfig, null, 2),
+ 'utf8',
+ );
+
+ execFileSync(
+ process.execPath,
+ [path.join(repoNodeModules, 'typescript', 'bin', 'tsc'), '-p', compileRoot],
+ {
+ cwd: compileRoot,
+ stdio: 'pipe',
+ },
+ );
+ t.pass('tsc --noEmit succeeded on every success-snapshot artifact set');
+});
+
+const failureFixtures = [
+ 'unsupported-semantic.openapi.yaml',
+ 'unsupported-root.yaml',
+ 'empty-parameter.openapi.yaml',
+ 'inline-parameter.openapi.yaml',
+ 'invalid-enum-type.openapi.yaml',
+ 'invalid-enum-value.openapi.json',
+ // additional-properties: true (boolean form, not a schema object) is
+ // explicitly rejected at normalize/schema.rs. Snapshot pins the
+ // unsupported-subset diagnostic shape.
+ 'additional-properties-boolean.openapi.yaml',
+ // External $ref like 'shared.yaml#/...' is rejected at
+ // normalize/schema.rs's normalize_reference. Snapshot pins the
+ // unsupported-reference message format.
+ 'external-ref.openapi.yaml',
+ // field-collision pins the field-collision policy under smart-flatten:
+ // an inline-object body whose property name duplicates a path/query
+ // param is rejected, because hoisting the property to top-level would
+ // produce a duplicate field on the request interface. The author's
+ // escape hatch is to either rename the property or hoist the body to a
+ // named $ref (which nests under `body` instead of flattening).
+ 'field-collision.openapi.yaml',
+ // deep-nested-allof exercises the MAX_NORMALIZE_DEPTH guard at 40
+ // levels of inline allOf nesting. Pins the unsupported-semantic
+ // diagnostic so a regression that removes the guard or shifts the
+ // error code surfaces here, not just in the targeted spec test.
+ 'deep-nested-allof.openapi.yaml',
+ // unbalanced-path-template exercises the normalize-stage path string
+ // validation: paths with unmatched `{` / `}` would otherwise reach
+ // the emit stage and produce a broken TypeScript template
+ // (`url: `/pets/id`` with no `${encodeURIComponent(id)}` expansion).
+ 'unbalanced-path-template.openapi.yaml',
+ // discriminator-mapping-external-ref pins the normalize-stage
+ // rejection of a mapping value whose ref shape is not an internal
+ // `#/components/schemas/` ref. Without routing the mapping resolution
+ // through `normalize_reference`, an external URL like
+ // `http://example.com/schemas/Cat` would silently pass as a bare
+ // literal that never matches any union member.
+ 'discriminator-mapping-external-ref.openapi.yaml',
+ // anchor-fanout pins the reject-side boundary of the
+ // mapping-expansion-exceeded guard: a ~45 KB source whose 500 schemas
+ // × 16 anchor aliases each re-serialise into ~15 MB of inlined node
+ // tree (~340× ratio). The guard rejects the input at decode time
+ // before any normalize/emit work runs. Without this guard, the
+ // expanded tree would be carried through the entire pipeline and
+ // surface as a slowdown or memory blow-up rather than a clean
+ // diagnostic.
+ 'anchor-fanout.openapi.yaml',
+ // Policy-violation subcodes introduced in Phase 4 for multipart /
+ // urlencoded form bodies and unsupported content types. One fixture per
+ // subcode; each pins the diagnostic so a regression that renames a
+ // subcode or routes a reject path through a different arm surfaces here.
+ 'body-multi-content.openapi.yaml',
+ 'body-content-type-xml.openapi.yaml',
+ 'body-multipart-nested-object.openapi.yaml',
+ 'body-multipart-composed-field.openapi.yaml',
+ 'body-multipart-non-object.openapi.yaml',
+ 'body-multipart-open-schema.openapi.yaml',
+ 'body-urlencoded-binary-field.openapi.yaml',
+ 'body-urlencoded-nested-object.openapi.yaml',
+] as const;
+
+for (const fixtureName of failureFixtures) {
+ test(`generate preserves full failure payload snapshot for ${fixtureName}`, async t => {
+ t.deepEqual(
+ await failurePayload(fixtureName),
+ readJsonSnapshot(`${fixtureName}.failure.json`),
+ );
+ });
+}
+
+// malformed.yaml: pin code/path but use a regex on the message so
+// upstream serde_yml line/column wording changes don't churn the snapshot.
+test('generate preserves stable failure shape for malformed.yaml (regex message)', async t => {
+ const payload = await failurePayload('malformed.yaml');
+ t.is(payload.code, 'E_INPUT_INVALID');
+ t.regex(payload.message, /^Failed to decode OpenAPI input as YAML:/);
+ t.true((payload.path ?? '').endsWith(path.join('test', 'fixtures', 'malformed.yaml')));
+ // No pre-fatal warnings expected for a decode-stage failure.
+ t.deepEqual(payload.warnings, []);
+});
+
+test('generate preserves full failure payload snapshot for invalid mapped type option', async t => {
+ t.deepEqual(
+ await failurePayload('petstore-rich.openapi.yaml', {
+ mappedTypes: [
+ {
+ schema: 'MissingSchema',
+ import: '@demo/x',
+ type: 'Missing',
+ },
+ ],
+ }),
+ readJsonSnapshot('petstore-rich.openapi.yaml.invalid-mapped-type.failure.json'),
+ );
+});
diff --git a/__test__/generate.spec.ts b/__test__/generate.spec.ts
new file mode 100644
index 0000000..adb2474
--- /dev/null
+++ b/__test__/generate.spec.ts
@@ -0,0 +1,1556 @@
+import test, { type ExecutionContext } from 'ava';
+import fs from 'node:fs';
+import { createRequire } from 'node:module';
+import os from 'node:os';
+import path from 'node:path';
+import { execFileSync, spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+// Import from the package root (resolves to lib/index.js via package.json
+// `main`), not the native binding directly — the wrapper is what upgrades
+// thrown plain errors into `GenerateError` class instances.
+import { generate, GenerateError } from '../lib/index.js';
+import { __setFetchImplForTest } from '../lib/fetch-input.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+// Pass fixtures as paths relative to CWD (project root) so display_path
+// returns a short, machine-independent path in banners and diagnostics.
+const fixture = (name: string) => path.join('test', 'fixtures', name);
+
+// ── angular-consumer/generated/ convention ──────────────────────────────────
+//
+// `__test__/angular-consumer/generated/` is the SHARED working directory
+// for any test that needs to emit a real generator output and run `tsc`
+// over it against the consumer's tsconfig (the per-purpose
+// `tsconfig.*.json` files in that directory each `include` a subset of
+// this generated tree). The directory is shared — not per-test — so
+// the tsconfigs can stay declarative (each one names a stable
+// directory; tests don't have to thread a temp path into a generated
+// tsconfig file).
+//
+// Two contributor-facing rules follow from that:
+//
+// 1. Tests that emit into `generated/` MUST call
+// `resetAngularConsumerGeneratedDir()` before generating, or
+// leftover files from a previous test will be compiled too and
+// surface as a confusing tsc diagnostic. Ava runs each test file
+// serially by default but the order between tests in the same
+// file is implementation-defined — never assume a clean state.
+//
+// 2. The snapshot-suite tsc gate
+// (`__test__/generate.snapshot.spec.ts`, "snapshot artifacts
+// type-check under tsc --noEmit") writes into a SIBLING subtree
+// `__test__/angular-consumer/__snapshot_compile__/` — outside the
+// shared `generated/` tree — and cleans it on every run. Living
+// next to `generated/` rather than inside it is deliberate: this
+// reset helper recursively wipes `generated/`, and under AVA's
+// per-file parallelism the two files run concurrently. A future
+// test that needs its own preserved tree across runs should
+// likewise pick a NEW sibling directory next to `generated/` (e.g.
+// `__test__/angular-consumer/generated-/`) rather than
+// stash files inside the shared `generated/` tree — the reset
+// helper wipes the entire shared directory unconditionally and
+// collisions there are silent and hard to debug. The matching
+// tsconfig should live next to the existing
+// `tsconfig..json` files and `include` only the new
+// sibling subtree.
+const angularConsumerGeneratedDir = path.join(__dirname, 'angular-consumer', 'generated');
+
+function resetAngularConsumerGeneratedDir() {
+ fs.rmSync(angularConsumerGeneratedDir, { recursive: true, force: true });
+ fs.mkdirSync(angularConsumerGeneratedDir, { recursive: true });
+}
+
+async function withTempDir(run: (dir: string) => void | Promise) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-ng-generate-'));
+ try {
+ await run(dir);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+function stripBanner(contents: string | undefined): string {
+ return (contents ?? '').replace(
+ /^\/\/ Generated by openapi-ng v[^\n]*\n\/\/ Source: [^\n]*\n\/\/ DO NOT EDIT[^\n]*\n\n/u,
+ '',
+ );
+}
+
+// Default emit selection.
+const DEFAULT_EMIT = ['models', 'angular'] as const;
+const FULL_EMIT = ['models', 'angular'] as const;
+
+const expectedModelSource = [
+ 'export interface Pet {',
+ ' id: PetId;',
+ ' name: string;',
+ ' status: PetStatus;',
+ ' tags: Tag[];',
+ ' nickname?: string | null;',
+ '}',
+ '',
+ 'export type PetId = string;',
+ '',
+ 'export type PetList = Pet[];',
+ '',
+ "export type PetStatus = 'available' | 'pending' | 'sold';",
+ '',
+ 'export interface Tag {',
+ ' id: number;',
+ ' label: string;',
+ '}',
+ '',
+ 'export interface UpdatePetRequest {',
+ ' status: PetStatus;',
+ ' tagIds: number[];',
+ ' nickname?: string | null;',
+ '}',
+ '',
+].join('\n');
+
+const unsupportedSemanticDiagnostic = {
+ code: 'E_UNSUPPORTED_SEMANTIC',
+ severity: 'error',
+ message:
+ 'Unsupported OpenAPI semantic shape: property schema EventEnvelope composition member 2.data must define additionalProperties as a schema object.',
+ path: path.join('test', 'fixtures', 'unsupported-semantic.openapi.yaml'),
+};
+
+const unsupportedRootDiagnostic = {
+ code: 'E_INPUT_INVALID',
+ severity: 'error',
+ message:
+ 'Failed to decode OpenAPI input as YAML: components.schemas: invalid type: sequence, expected a map at line 8 column 5',
+ path: path.join('test', 'fixtures', 'unsupported-root.yaml'),
+};
+
+function artifactPaths(result: { artifacts: Array<{ path: string }> }) {
+ return result.artifacts.map(artifact => artifact.path);
+}
+
+interface Artifact {
+ path: string;
+ contents: string;
+}
+function getArtifact(result: { artifacts: Artifact[] }, targetPath: string) {
+ return result.artifacts.find(artifact => artifact.path === targetPath);
+}
+
+function assertRestHelpers(t: ExecutionContext, result: { artifacts: Artifact[] }) {
+ const restModel = getArtifact(result, 'rest.model.ts');
+ const restUtil = getArtifact(result, 'rest.util.ts');
+
+ t.truthy(restModel);
+ t.truthy(restUtil);
+ t.true(restModel?.contents?.includes('export interface CommonRequest'));
+ t.true(restModel?.contents?.includes('export type HttpResourceOptionsUnion'));
+ t.true(restUtil?.contents?.includes('export const requestFactory'));
+ t.true(restUtil?.contents?.includes('export interface ZeroArgRequestFnVoid'));
+}
+
+function assertPetServiceArtifact(
+ t: ExecutionContext,
+ serviceSource: string | undefined,
+) {
+ t.truthy(serviceSource);
+ t.true(serviceSource?.includes('export class PetRest {'));
+ t.true(serviceSource?.includes('requestFactory.zeroArg('));
+ t.true(serviceSource?.includes('requestFactory('));
+ t.true(serviceSource?.includes('requestFactory('));
+ t.true(serviceSource?.includes('export interface GetPetParams {'));
+ t.true(serviceSource?.includes('export interface UpdatePetParams {'));
+ t.true(serviceSource?.includes('petId: PetId;'));
+ t.true(serviceSource?.includes('includeHistory?: boolean;'));
+ t.true(serviceSource?.includes('body: UpdatePetRequest;'));
+ t.true(serviceSource?.includes('body: body,'));
+}
+
+test('normalize rejects path templates with unbalanced { } braces', async t => {
+ // Before the normalize-stage guard, a path like `/pets/{id` would slip
+ // through to the emit stage and silently produce a broken TypeScript
+ // template (`url: `/pets/id`` with no `${encodeURIComponent(id)}`
+ // expansion). The check rejects the spec instead.
+ const err = await t.throwsAsync(
+ async () => {
+ await generate({
+ inputPath: fixture('unbalanced-path-template.openapi.yaml'),
+ emit: ['models', 'angular'],
+ });
+ },
+ { instanceOf: GenerateError },
+ );
+ t.is(err?.code, 'E_UNSUPPORTED_SEMANTIC');
+ t.regex(err?.message ?? '', /unbalanced.*'\{'/u);
+ t.regex(err?.message ?? '', /\/pets\/\{id/u);
+});
+
+test('OpenAPI deprecated:true is mapped to @deprecated JSDoc on operations and properties', async t => {
+ const result = await generate({
+ inputPath: fixture('deprecated-fields.openapi.yaml'),
+ emit: ['models', 'angular'],
+ });
+
+ const model = result.artifacts.find(a => a.path === 'model.generated.ts');
+ const service = result.artifacts.find(a => a.path === 'rest/pet.rest.generated.ts');
+ t.truthy(model);
+ t.truthy(service);
+
+ // Deprecated enum (top-level type alias) carries @deprecated JSDoc.
+ t.regex(
+ model?.contents ?? '',
+ /\/\*\*[\s\S]*?Adoption state, legacy spelling\.[\s\S]*?@deprecated[\s\S]*?\*\/\s*export type LegacyPetStatus/u,
+ );
+ // Deprecated property inside an interface gets a per-property JSDoc.
+ t.regex(
+ model?.contents ?? '',
+ /\/\*\*[\s\S]*?@deprecated[\s\S]*?\*\/\s*legacyTagId\?: number;/u,
+ );
+ // Non-deprecated property nearby does NOT pick up the marker — its
+ // preceding line is the closing `*/` of the legacyTagId block, not a
+ // standalone `@deprecated` JSDoc.
+ const beforeTagIds = (model?.contents ?? '').match(
+ /([^\n]*\n[^\n]*\n)\s*tagIds\?: number\[\];/u,
+ );
+ t.truthy(beforeTagIds, 'expected to find tagIds property');
+ t.false(
+ beforeTagIds?.[1]?.includes('@deprecated') ?? false,
+ 'tagIds must not be preceded by a @deprecated JSDoc',
+ );
+
+ // Deprecated operation: @deprecated lives in the JSDoc above the
+ // service method, preserved alongside the merged summary+description.
+ t.regex(
+ service?.contents ?? '',
+ /\/\*\*[\s\S]*?Retrieve a single pet[\s\S]*?@deprecated[\s\S]*?\*\/\s*readonly getPet/u,
+ );
+});
+
+test('every generated artifact carries the version banner', async t => {
+ // Forward-compat regression guard: pipeline emits `format!("{banner}{body}")`
+ // for every artifact (models, rest.model.ts, rest.util.ts, per-tag service).
+ // If any emit path ever materialises an artifact without threading the banner
+ // through, this catches it before consumers see a banner-less file.
+ const result = await generate({
+ inputPath: fixture('petstore-rich.openapi.yaml'),
+ emit: ['models', 'angular'],
+ });
+
+ t.true(result.artifacts.length >= 4, 'fixture should emit models + rest.* + service');
+ for (const artifact of result.artifacts) {
+ t.regex(
+ artifact.contents,
+ /^\/\/ Generated by openapi-ng v\d/,
+ `${artifact.path} must start with the version banner`,
+ );
+ }
+});
+
+test.serial(
+ 'generate emits deterministic model and angular service artifacts for rich JSON and YAML fixtures',
+ async t => {
+ resetAngularConsumerGeneratedDir();
+
+ await withTempDir(async outputPath => {
+ const jsonResult = await generate({
+ inputPath: fixture('petstore-rich.openapi.json'),
+ outputPath: angularConsumerGeneratedDir,
+ emit: ['models', 'angular'],
+ });
+ const yamlResult = await generate({
+ inputPath: fixture('petstore-rich.openapi.yaml'),
+ outputPath,
+ emit: ['models', 'angular'],
+ });
+
+ t.deepEqual(jsonResult.diagnostics, []);
+ t.deepEqual(yamlResult.diagnostics, []);
+
+ t.deepEqual(
+ { ...jsonResult.summary, normalizedSourcePath: undefined },
+ { ...yamlResult.summary, normalizedSourcePath: undefined },
+ );
+
+ t.like(jsonResult, {
+ summary: {
+ specVersion: '3.0.3',
+ title: 'Petstore Rich',
+ pathCount: 2,
+ operationCount: 3,
+ schemaCount: 6,
+ },
+ artifacts: [
+ { path: 'model.generated.ts' },
+ { path: 'rest.model.ts' },
+ { path: 'rest.util.ts' },
+ { path: 'rest/pet.rest.generated.ts' },
+ ],
+ });
+
+ t.is(
+ jsonResult.summary.normalizedSourcePath,
+ path.join('test', 'fixtures', 'petstore-rich.openapi.json'),
+ );
+ t.is(
+ yamlResult.summary.normalizedSourcePath,
+ path.join('test', 'fixtures', 'petstore-rich.openapi.yaml'),
+ );
+ // Generated artifacts now carry a "// Source: " banner line, so
+ // JSON and YAML versions of the same spec produce intentionally
+ // different bytes. Compare paths and verify that body (post-banner)
+ // matches expectedModelSource for both surfaces.
+ t.deepEqual(
+ jsonResult.artifacts.map(a => a.path),
+ yamlResult.artifacts.map(a => a.path),
+ );
+ t.is(stripBanner(jsonResult.artifacts[0]?.contents), expectedModelSource);
+ t.is(stripBanner(yamlResult.artifacts[0]?.contents), expectedModelSource);
+ assertRestHelpers(t, jsonResult);
+ assertPetServiceArtifact(
+ t,
+ getArtifact(jsonResult, 'rest/pet.rest.generated.ts')?.contents,
+ );
+ t.true(fs.existsSync(path.join(angularConsumerGeneratedDir, 'model.generated.ts')));
+ t.true(fs.existsSync(path.join(angularConsumerGeneratedDir, 'rest.model.ts')));
+ t.true(fs.existsSync(path.join(angularConsumerGeneratedDir, 'rest.util.ts')));
+ t.true(
+ fs.existsSync(
+ path.join(angularConsumerGeneratedDir, 'rest', 'pet.rest.generated.ts'),
+ ),
+ );
+ t.is(
+ stripBanner(
+ fs.readFileSync(
+ path.join(angularConsumerGeneratedDir, 'model.generated.ts'),
+ 'utf8',
+ ),
+ ),
+ expectedModelSource,
+ );
+ assertPetServiceArtifact(
+ t,
+ fs.readFileSync(
+ path.join(angularConsumerGeneratedDir, 'rest', 'pet.rest.generated.ts'),
+ 'utf8',
+ ),
+ );
+ });
+ },
+);
+
+test('generate defaults emit to models+angular when omitted, matching the CLI', async t => {
+ // No `emit` field — the JS wrapper must apply the CLI's DEFAULT_EMIT
+ // (`['models', 'angular']`) so the consumer receives the same artifact
+ // set as `openapi-ng generate -i `.
+ const result = await generate({ inputPath: fixture('petstore-minimal.openapi.yaml') });
+
+ t.true(
+ result.artifacts.some(a => a.path === 'model.generated.ts'),
+ 'default emit must include the models artifact',
+ );
+ t.true(
+ result.artifacts.some(a => a.path.endsWith('.rest.generated.ts')),
+ 'default emit must include an angular service artifact',
+ );
+});
+
+test('generate produces models+angular when emit lists both targets', async t => {
+ await withTempDir(async outputPath => {
+ const result = await generate({
+ inputPath: fixture('petstore-rich.openapi.yaml'),
+ outputPath,
+ emit: ['models', 'angular'],
+ });
+
+ t.deepEqual(result.diagnostics, []);
+ t.deepEqual(artifactPaths(result), [
+ 'model.generated.ts',
+ 'rest.model.ts',
+ 'rest.util.ts',
+ 'rest/pet.rest.generated.ts',
+ ]);
+ t.is(stripBanner(result.artifacts[0]?.contents), expectedModelSource);
+ assertRestHelpers(t, result);
+ assertPetServiceArtifact(
+ t,
+ getArtifact(result, 'rest/pet.rest.generated.ts')?.contents,
+ );
+ });
+});
+
+test('generate rejects empty emit array as an invalid option', async t => {
+ const error = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: fixture('petstore-minimal.openapi.yaml'),
+ emit: [],
+ });
+ });
+
+ t.true(error instanceof GenerateError, 'thrown value must be a GenerateError instance');
+ t.is((error as any)?.code, 'E_INVALID_OPTION');
+ t.regex(error?.message ?? '', /emit/i);
+});
+
+test('generate auto-includes models when emit lists only angular, surfacing a non-fatal warning', async t => {
+ const result = await generate({
+ inputPath: fixture('petstore-minimal.openapi.yaml'),
+ emit: ['angular'],
+ });
+
+ // Models artifact is present even though the caller did not list 'models'.
+ t.true(result.artifacts.some(a => a.path === 'model.generated.ts'));
+ t.true(result.artifacts.some(a => a.path.startsWith('rest/')));
+
+ // The pipeline surfaces the auto-include as an E_INVALID_OPTION warning
+ // so consumers can see (with --verbose, or by inspecting diagnostics)
+ // that their emit list was widened.
+ const warning = result.diagnostics.find(d => d.code === 'E_INVALID_OPTION');
+ t.truthy(warning, 'expected an E_INVALID_OPTION warning');
+ t.is(warning?.severity, 'warning');
+ t.regex(warning?.message ?? '', /Auto-included 'models'/);
+ t.regex(warning?.message ?? '', /'angular'/);
+});
+
+test('generate rejects unknown option keys with E_INVALID_OPTION instead of silently ignoring them', async t => {
+ const error = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: fixture('petstore-minimal.openapi.yaml'),
+ emit: [...DEFAULT_EMIT],
+ // typo: lowercase `inputpath` and an unrecognised `artifacts` option
+ inputpath: 'oops',
+ artifacts: 'metadata',
+ } as any);
+ });
+
+ t.true(error instanceof GenerateError, 'thrown value must be a GenerateError instance');
+ t.is((error as any)?.code, 'E_INVALID_OPTION');
+ t.regex(error?.message ?? '', /inputpath/);
+ t.regex(error?.message ?? '', /artifacts/);
+});
+
+test('generate rejects non-array emit with a typed E_INVALID_OPTION (subcode shape) before crossing the NAPI boundary', async t => {
+ const error = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: fixture('petstore-minimal.openapi.yaml'),
+ outputPath: 'unused',
+ // typo: string instead of EmitTarget[]
+ emit: 'angular',
+ } as any);
+ });
+
+ t.true(error instanceof GenerateError, 'thrown value must be a GenerateError instance');
+ t.is((error as any)?.code, 'E_INVALID_OPTION');
+ t.is((error as any)?.subcode, 'shape');
+ t.regex(error?.message ?? '', /emit must be an array/);
+});
+
+test('generate rejects non-array mappedTypes with E_INVALID_OPTION (subcode shape) before crossing the NAPI boundary', async t => {
+ const error = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: fixture('petstore-minimal.openapi.yaml'),
+ outputPath: 'unused',
+ emit: [...DEFAULT_EMIT],
+ // typo: scalar instead of MappedType[]
+ mappedTypes: 'foo',
+ } as any);
+ });
+
+ t.true(error instanceof GenerateError, 'thrown value must be a GenerateError instance');
+ t.is((error as any)?.code, 'E_INVALID_OPTION');
+ t.is((error as any)?.subcode, 'shape');
+ t.regex(error?.message ?? '', /mappedTypes must be an array/);
+});
+
+test('generate rejects emit entries not in EmitTarget union', async t => {
+ const err = await t.throwsAsync(
+ async () => {
+ await generate({
+ inputPath: 'x.yaml',
+ emit: ['Models'] as any, // capitalized → invalid
+ });
+ },
+ { instanceOf: GenerateError },
+ );
+ t.is(err?.code, 'E_INVALID_OPTION');
+ t.is(err?.subcode, 'shape');
+ t.regex(err?.message ?? '', /'Models'/);
+});
+
+test('generate rejects non-string inputPath', async t => {
+ // Cast to any: TypeScript would catch this, but a JS consumer wouldn't.
+ const err = await t.throwsAsync(
+ async () => {
+ await generate({ inputPath: 123 as any, emit: ['models'] });
+ },
+ { instanceOf: GenerateError },
+ );
+ t.is(err?.code, 'E_INVALID_OPTION');
+ t.is(err?.subcode, 'shape');
+ t.regex(err?.message ?? '', /inputPath/);
+});
+
+test('generate rejects malformed mappedTypes entries', async t => {
+ const err = await t.throwsAsync(
+ async () => {
+ await generate({
+ inputPath: 'x.yaml',
+ emit: ['models'],
+ mappedTypes: [{ schema: 'X' } as any], // missing import + type
+ });
+ },
+ { instanceOf: GenerateError },
+ );
+ t.is(err?.code, 'E_INVALID_OPTION');
+ t.is(err?.subcode, 'shape');
+});
+
+test('generate rejects when neither inputPath nor inputContents is set', async t => {
+ const err = await t.throwsAsync(async () => {
+ await generate({ emit: ['models'] } as any);
+ });
+ t.true(err instanceof GenerateError);
+ t.is((err as any).code, 'E_INVALID_OPTION');
+ t.is((err as any).subcode, 'shape');
+ t.true((err as any).message.includes('exactly one'));
+});
+
+test('generate rejects when both inputPath and inputContents are set', async t => {
+ const err = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: 'spec.yaml',
+ inputContents: 'openapi: 3.0.3\n',
+ displayPath: 'inline',
+ emit: ['models'],
+ } as any);
+ });
+ t.is((err as any).code, 'E_INVALID_OPTION');
+ t.is((err as any).subcode, 'shape');
+});
+
+test('generate rejects inputContents without displayPath', async t => {
+ const err = await t.throwsAsync(async () => {
+ await generate({
+ inputContents: 'openapi: 3.0.3\n',
+ emit: ['models'],
+ } as any);
+ });
+ t.is((err as any).code, 'E_INVALID_OPTION');
+ t.is((err as any).subcode, 'shape');
+ t.true((err as any).message.includes('displayPath'));
+});
+
+test('generate rejects inputFormat with inputPath', async t => {
+ const err = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: 'spec.yaml',
+ inputFormat: 'json',
+ emit: ['models'],
+ } as any);
+ });
+ t.is((err as any).code, 'E_INVALID_OPTION');
+ t.is((err as any).subcode, 'shape');
+});
+
+test('generate accepts inputContents with displayPath and yields a result', async t => {
+ const yaml = 'openapi: 3.0.3\ninfo: { title: Inline Pkg, version: 1.0.0 }\npaths: {}\n';
+ const result = await generate({
+ inputContents: yaml,
+ displayPath: 'https://example.com/spec.yaml',
+ inputFormat: 'yaml',
+ emit: ['models'],
+ } as any);
+ t.is(result.summary.title, 'Inline Pkg');
+ t.is(result.summary.normalizedSourcePath, 'https://example.com/spec.yaml');
+});
+
+test('generate fails when discriminator property is absent on a oneOf member', async t => {
+ const err = await t.throwsAsync(
+ async () => {
+ await generate({
+ inputPath: fixture('discriminator-missing-property.openapi.yaml'),
+ emit: ['models'],
+ });
+ },
+ { instanceOf: GenerateError },
+ );
+ t.is(err?.code, 'E_POLICY_VIOLATION');
+ t.is(err?.subcode, 'missing-discriminator-property');
+ t.regex(err?.message ?? '', /Cat/);
+});
+
+test('generate rejects empty-string outputPath; in-memory mode requires omitting the field', async t => {
+ const error = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: fixture('petstore-minimal.openapi.yaml'),
+ outputPath: '',
+ emit: [...DEFAULT_EMIT],
+ });
+ });
+
+ t.truthy(error);
+ t.is((error as any)?.code, 'E_INVALID_OPTION');
+ t.regex(error?.message ?? '', /outputPath must be a non-empty path/);
+});
+
+test('generate returns artifact contents and writes the same bytes to disk', async t => {
+ await withTempDir(async outputPath => {
+ const result = await generate({
+ inputPath: fixture('petstore-rich.openapi.yaml'),
+ outputPath,
+ emit: [...DEFAULT_EMIT],
+ });
+
+ t.true(result.artifacts.length > 0);
+ for (const artifact of result.artifacts) {
+ t.is(typeof artifact.contents, 'string');
+ t.true(artifact.contents.length > 0, `${artifact.path} contents must be non-empty`);
+ }
+ // Files were still written to disk.
+ t.true(fs.existsSync(path.join(outputPath, 'model.generated.ts')));
+ const modelArtifact = getArtifact(result, 'model.generated.ts');
+ t.is(
+ fs.readFileSync(path.join(outputPath, 'model.generated.ts'), 'utf8'),
+ modelArtifact?.contents,
+ );
+ });
+});
+
+test('generate exposes flattened request interfaces and operation-object APIs in the service artifact', async t => {
+ await withTempDir(async outputPath => {
+ const result = await generate({
+ inputPath: fixture('petstore-rich.openapi.yaml'),
+ outputPath,
+ emit: [...DEFAULT_EMIT],
+ });
+
+ t.deepEqual(result.diagnostics, []);
+ const serviceArtifact = getArtifact(result, 'rest/pet.rest.generated.ts');
+
+ t.truthy(serviceArtifact);
+ assertPetServiceArtifact(t, serviceArtifact?.contents);
+ t.false(serviceArtifact?.contents?.includes('#http'));
+ t.false(serviceArtifact?.contents?.includes('HttpClient'));
+ t.true(serviceArtifact?.contents?.includes('requestFactory.zeroArg('));
+ t.true(serviceArtifact?.contents?.includes('requestFactory('));
+ t.true(serviceArtifact?.contents?.includes('requestFactory('));
+ });
+});
+
+test('generate surfaces the bounded option contract through the node api', async t => {
+ await withTempDir(async outputPath => {
+ const result = await generate({
+ inputPath: fixture('petstore-rich.openapi.yaml'),
+ outputPath,
+ emit: [...FULL_EMIT],
+ mappedTypes: [
+ {
+ schema: 'PetId',
+ import: '@demo/native-types',
+ type: 'PetIdentifier',
+ },
+ ],
+ });
+
+ t.deepEqual(result.diagnostics, []);
+ });
+});
+
+test.serial(
+ 'generate emits discriminated-union model that narrows correctly in a consumer project',
+ async t => {
+ resetAngularConsumerGeneratedDir();
+
+ await generate({
+ inputPath: fixture('discriminated-union.openapi.yaml'),
+ outputPath: angularConsumerGeneratedDir,
+ emit: [...DEFAULT_EMIT],
+ });
+
+ execFileSync(
+ process.execPath,
+ [
+ path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'),
+ '-p',
+ path.join(__dirname, 'angular-consumer', 'tsconfig.discriminator.json'),
+ ],
+ {
+ cwd: path.join(__dirname, 'angular-consumer'),
+ stdio: 'pipe',
+ },
+ );
+ t.pass('tsc type-checked discriminated-union narrowing successfully');
+ },
+);
+
+test.serial(
+ 'generate emits service helper typings that type-check in a consumer project',
+ async t => {
+ resetAngularConsumerGeneratedDir();
+
+ await generate({
+ inputPath: fixture('petstore-rich.openapi.json'),
+ outputPath: angularConsumerGeneratedDir,
+ emit: [...DEFAULT_EMIT],
+ });
+
+ execFileSync(
+ process.execPath,
+ [
+ path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'),
+ '-p',
+ path.join(__dirname, 'angular-consumer', 'tsconfig.service.json'),
+ ],
+ {
+ cwd: path.join(__dirname, 'angular-consumer'),
+ stdio: 'pipe',
+ },
+ );
+ t.pass('tsc type-checked generated service artifact successfully');
+ },
+);
+
+// Type-surface gate for `EmitTarget`. The proof file destructures the
+// runtime `EmitTarget` and asserts assignability between its named
+// properties and the published `EmitTarget` type, compiled under
+// `isolatedModules`. If the published `index.d.ts` ever regresses to a
+// `const enum` form, isolatedModules' single-file transpilation rejects
+// the import and this gate fails.
+test('public EmitTarget surface compiles under isolatedModules', t => {
+ execFileSync(
+ process.execPath,
+ [
+ path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'),
+ '-p',
+ path.join(__dirname, 'angular-consumer', 'tsconfig.emit-target.json'),
+ ],
+ {
+ cwd: path.join(__dirname, 'angular-consumer'),
+ stdio: 'pipe',
+ },
+ );
+ t.pass('tsc type-checked EmitTarget public surface under isolatedModules');
+});
+
+test.serial(
+ 'generate emits oneOf/anyOf composition models that type-check in a consumer project',
+ async t => {
+ resetAngularConsumerGeneratedDir();
+
+ await generate({
+ inputPath: fixture('oneof-anyof-composition.openapi.yaml'),
+ outputPath: angularConsumerGeneratedDir,
+ emit: [...DEFAULT_EMIT],
+ });
+
+ execFileSync(
+ process.execPath,
+ [
+ path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'),
+ '-p',
+ path.join(__dirname, 'angular-consumer', 'tsconfig.consumer.json'),
+ ],
+ {
+ cwd: path.join(__dirname, 'angular-consumer'),
+ stdio: 'pipe',
+ },
+ );
+ t.pass('tsc type-checked generated oneOf/anyOf composition output successfully');
+ },
+);
+
+// Bulk-spec compile gate: bench-large emits 30 entity models and 30
+// service files referencing them. The all-snapshots compile gate in
+// generate.snapshot.spec.ts covers the snapshot bytes; this dedicated
+// gate exercises a freshly-generated output against the real Angular
+// consumer's tsconfig, so a regression in bulk emit surfaces as a
+// targeted failure (not a snapshot diff buried in a 30-file blob).
+test.serial(
+ 'generate emits bulk-spec output that type-checks under an Angular consumer (bench-large)',
+ async t => {
+ resetAngularConsumerGeneratedDir();
+
+ await generate({
+ inputPath: fixture('bench-large.openapi.yaml'),
+ outputPath: angularConsumerGeneratedDir,
+ emit: [...DEFAULT_EMIT],
+ });
+
+ execFileSync(
+ process.execPath,
+ [
+ path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'),
+ '-p',
+ path.join(__dirname, 'angular-consumer', 'tsconfig.bench-large.json'),
+ ],
+ {
+ cwd: path.join(__dirname, 'angular-consumer'),
+ stdio: 'pipe',
+ },
+ );
+ t.pass('tsc type-checked bulk-spec (bench-large) generated output successfully');
+ },
+);
+
+// Negative-compile fixture: asserts that tsc FAILS when assigning a Dog to a
+// Cat-typed slot. If this test ever starts passing (tsc exits 0), it means the
+// tagged union has collapsed to `any` or similar — a type-soundness regression.
+test.serial(
+ 'negative-proof tsconfig fails to compile (type-soundness regression detector)',
+ async t => {
+ resetAngularConsumerGeneratedDir();
+
+ await generate({
+ inputPath: fixture('discriminated-union.openapi.yaml'),
+ outputPath: angularConsumerGeneratedDir,
+ emit: [...DEFAULT_EMIT],
+ });
+
+ const result = spawnSync(
+ process.execPath,
+ [
+ path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'),
+ '-p',
+ path.join(__dirname, 'angular-consumer', 'tsconfig.negative.json'),
+ ],
+ {
+ cwd: path.join(__dirname, 'angular-consumer'),
+ encoding: 'utf8',
+ },
+ );
+
+ const stdout = (result.stdout ?? '') + (result.stderr ?? '');
+
+ // Must fail — otherwise types have become too permissive (e.g. collapsed to `any`).
+ t.not(result.status, 0, `expected tsc to fail but it succeeded; output:\n${stdout}`);
+ // The specific error must be TS2322 (type assignability), not e.g. TS2304 (cannot find name).
+ t.regex(stdout, /TS2322/, `expected TS2322 type-mismatch error; output:\n${stdout}`);
+ },
+);
+
+// Compile gate for Phase 7's request-body (multipart + urlencoded) and
+// non-JSON response (Blob / string / ArrayBuffer) surfaces. The proof
+// file (src/form-non-json-proof.ts) asserts call-site typing on each
+// service and pins the carrier type of `observable` / `resource` via
+// expectType<>. Generated from a combined fixture that also exercises
+// the user-facing `responseTypeMapping` knob (octet-stream → ArrayBuffer).
+// A regression that widens a binary field to `any`, drops the response
+// generic, or fails to honour the mapping fails here under tsc --noEmit.
+test.serial(
+ 'generate emits form-body and non-JSON response typings that type-check in a consumer project',
+ async t => {
+ resetAngularConsumerGeneratedDir();
+
+ await generate({
+ inputPath: fixture('consumer-forms-and-non-json.openapi.yaml'),
+ outputPath: angularConsumerGeneratedDir,
+ emit: [...DEFAULT_EMIT],
+ responseTypeMapping: [
+ { contentType: 'application/octet-stream', responseType: 'arrayBuffer' },
+ ],
+ });
+
+ execFileSync(
+ process.execPath,
+ [
+ path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'),
+ '-p',
+ path.join(__dirname, 'angular-consumer', 'tsconfig.form-non-json.json'),
+ ],
+ {
+ cwd: path.join(__dirname, 'angular-consumer'),
+ stdio: 'pipe',
+ },
+ );
+ t.pass('tsc type-checked form-body + non-JSON response artifacts successfully');
+ },
+);
+
+// Negative-compile fixture for the multipart binary-field surface:
+// asserts that tsc FAILS when assigning a plain `string` to a
+// `Blob | File` slot in the generated `UpdatePetAvatarParams`. If this
+// test starts passing (tsc exits 0), the binary field has widened to
+// `any`/`unknown` or to `string`-permissive — a type-soundness
+// regression that would silently let callers ship non-binary payloads
+// down the multipart path.
+test.serial(
+ 'negative-proof tsconfig fails to compile when a string is passed to a binary multipart field',
+ async t => {
+ resetAngularConsumerGeneratedDir();
+
+ await generate({
+ inputPath: fixture('consumer-forms-and-non-json.openapi.yaml'),
+ outputPath: angularConsumerGeneratedDir,
+ emit: [...DEFAULT_EMIT],
+ responseTypeMapping: [
+ { contentType: 'application/octet-stream', responseType: 'arrayBuffer' },
+ ],
+ });
+
+ const result = spawnSync(
+ process.execPath,
+ [
+ path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'),
+ '-p',
+ path.join(__dirname, 'angular-consumer', 'tsconfig.negative-binary-field.json'),
+ ],
+ {
+ cwd: path.join(__dirname, 'angular-consumer'),
+ encoding: 'utf8',
+ },
+ );
+
+ const stdout = (result.stdout ?? '') + (result.stderr ?? '');
+
+ // Must fail — otherwise the binary field has widened (e.g. to `string`/`any`).
+ t.not(result.status, 0, `expected tsc to fail but it succeeded; output:\n${stdout}`);
+ // The specific error must be TS2322 (type assignability), not e.g. TS2304 (cannot find name).
+ t.regex(stdout, /TS2322/, `expected TS2322 type-mismatch error; output:\n${stdout}`);
+ },
+);
+
+test('generate maps a targeted schema to an imported external type without changing unrelated model symbols', async t => {
+ await withTempDir(async outputPath => {
+ const result = await generate({
+ inputPath: fixture('petstore-rich.openapi.yaml'),
+ outputPath,
+ emit: [...DEFAULT_EMIT],
+
+ mappedTypes: [
+ {
+ schema: 'PetId',
+ import: '@demo/native-types',
+ type: 'PetIdentifier',
+ },
+ ],
+ });
+
+ const modelArtifact = getArtifact(result, 'model.generated.ts');
+
+ t.truthy(modelArtifact);
+ const serviceArtifact = getArtifact(result, 'rest/pet.rest.generated.ts');
+
+ t.truthy(serviceArtifact);
+ t.true(
+ modelArtifact?.contents?.includes(
+ "import type { PetIdentifier } from '@demo/native-types';",
+ ),
+ );
+ t.false(modelArtifact?.contents?.includes('export type PetId = string;'));
+ t.true(modelArtifact?.contents?.includes('export type PetStatus ='));
+ t.true(
+ serviceArtifact?.contents?.includes(
+ "import type { Pet, PetId, PetList, UpdatePetRequest } from '../model.generated';",
+ ),
+ );
+ t.true(serviceArtifact?.contents?.includes(' petId: PetId;'));
+ });
+});
+
+test('generate encodes oneOf/anyOf composition as focused public contract fragments', async t => {
+ await withTempDir(async outputPath => {
+ const result = await generate({
+ inputPath: fixture('oneof-anyof-composition.openapi.yaml'),
+ outputPath,
+ emit: [...DEFAULT_EMIT],
+ });
+
+ // `format-dropped` warnings are orthogonal to this test's focus on
+ // composition emit; the snapshot suite pins the full warning list.
+ t.deepEqual(
+ result.diagnostics.filter(d => d.subcode !== 'format-dropped'),
+ [],
+ );
+
+ const modelArtifact = getArtifact(result, 'model.generated.ts');
+ const serviceArtifact = getArtifact(
+ result,
+ 'rest/adoption-request.rest.generated.ts',
+ );
+
+ t.truthy(modelArtifact);
+ t.truthy(serviceArtifact);
+ t.deepEqual(artifactPaths(result), [
+ 'model.generated.ts',
+ 'rest.model.ts',
+ 'rest.util.ts',
+ 'rest/adoption-request.rest.generated.ts',
+ 'rest/pet.rest.generated.ts',
+ ]);
+ t.true(modelArtifact?.contents?.includes('export type PetUnion = Cat | Dog;'));
+ t.true(modelArtifact?.contents?.includes('export type PetUnionList = PetUnion[];'));
+ t.true(modelArtifact?.contents?.includes(' preferredPet?: PetUnion;'));
+ t.true(modelArtifact?.contents?.includes(' contact: ContactEmail | ContactPhone;'));
+ t.true(modelArtifact?.contents?.includes(' notes?: string;'));
+ t.true(modelArtifact?.contents?.includes(' referralCode?: string | null;'));
+ t.true(modelArtifact?.contents?.includes(' matchedPet?: PetUnion;'));
+ t.true(modelArtifact?.contents?.includes(' reviewerNote?: string | null;'));
+ t.true(
+ serviceArtifact?.contents?.includes(
+ 'requestFactory(',
+ ),
+ );
+ });
+});
+
+test('generate keeps mapped-type assertions explicit alongside composition contract coverage', async t => {
+ await withTempDir(async outputPath => {
+ const result = await generate({
+ inputPath: fixture('oneof-anyof-composition.openapi.yaml'),
+ outputPath,
+ emit: [...DEFAULT_EMIT],
+
+ mappedTypes: [
+ {
+ schema: 'Cat',
+ import: '@demo/composition-types',
+ type: 'ExternalCat',
+ },
+ ],
+ });
+
+ const modelArtifact = getArtifact(result, 'model.generated.ts');
+
+ t.truthy(modelArtifact);
+ t.true(
+ modelArtifact?.contents?.includes(
+ "import type { ExternalCat } from '@demo/composition-types';",
+ ),
+ );
+ t.true(modelArtifact?.contents?.includes('export type PetUnion = Cat | Dog;'));
+ t.true(modelArtifact?.contents?.includes('export type Cat = ExternalCat;'));
+ t.false(modelArtifact?.contents?.includes(' lives: number;'));
+ });
+});
+
+test('generate emits a re-export for mapped types whose binding name equals the schema name', async t => {
+ await withTempDir(async outputPath => {
+ const result = await generate({
+ inputPath: fixture('oneof-anyof-composition.openapi.yaml'),
+ outputPath,
+ emit: [...DEFAULT_EMIT],
+ mappedTypes: [
+ // alias=Cat matches the schema, so `import type { ExternalCat
+ // as Cat }` + `export type Cat = Cat;` would collide. The
+ // emitter must collapse to a single `export type { ... } from
+ // '...'` re-export.
+ {
+ schema: 'Cat',
+ import: '@demo/composition-types',
+ type: 'ExternalCat',
+ alias: 'Cat',
+ },
+ ],
+ });
+
+ const modelArtifact = getArtifact(result, 'model.generated.ts');
+ t.truthy(modelArtifact);
+ t.true(
+ modelArtifact?.contents?.includes(
+ "export type { ExternalCat as Cat } from '@demo/composition-types';",
+ ),
+ );
+ t.false(
+ modelArtifact?.contents?.includes('export type Cat = Cat;'),
+ 'self-alias case must not emit the broken placeholder',
+ );
+ t.false(
+ modelArtifact?.contents?.includes('import type { ExternalCat as Cat }'),
+ 'self-alias case must not emit a separate import-type line',
+ );
+ });
+});
+
+test('generate emits a bare re-export when schema name equals imported type name', async t => {
+ await withTempDir(async outputPath => {
+ const result = await generate({
+ inputPath: fixture('oneof-anyof-composition.openapi.yaml'),
+ outputPath,
+ emit: [...DEFAULT_EMIT],
+ mappedTypes: [
+ {
+ schema: 'Cat',
+ import: '@demo/composition-types',
+ type: 'Cat',
+ },
+ ],
+ });
+
+ const modelArtifact = getArtifact(result, 'model.generated.ts');
+ t.truthy(modelArtifact);
+ t.true(
+ modelArtifact?.contents?.includes(
+ "export type { Cat } from '@demo/composition-types';",
+ ),
+ );
+ t.false(
+ modelArtifact?.contents?.includes(' as Cat'),
+ 'no rename needed when names match',
+ );
+ });
+});
+
+test('generate encodes allOf composition as an intersection contract with nullable-vs-optional fragments', async t => {
+ await withTempDir(async outputPath => {
+ const result = await generate({
+ inputPath: fixture('allof-composition.openapi.yaml'),
+ outputPath,
+ emit: [...DEFAULT_EMIT],
+ });
+
+ // `format-dropped` warnings are orthogonal to this test's focus on
+ // intersection emit; the snapshot suite pins the full warning list.
+ t.deepEqual(
+ result.diagnostics.filter(d => d.subcode !== 'format-dropped'),
+ [],
+ );
+
+ const modelArtifact = getArtifact(result, 'model.generated.ts');
+ const serviceArtifact = getArtifact(result, 'rest/adopter.rest.generated.ts');
+
+ t.truthy(modelArtifact);
+ t.truthy(serviceArtifact);
+ t.deepEqual(artifactPaths(result), [
+ 'model.generated.ts',
+ 'rest.model.ts',
+ 'rest.util.ts',
+ 'rest/adopter.rest.generated.ts',
+ ]);
+ t.true(
+ modelArtifact?.contents?.includes(
+ 'export type AdopterProfile = AuditFields & ContactFields & {',
+ ),
+ );
+ t.true(modelArtifact?.contents?.includes(' id: string;'));
+ t.true(modelArtifact?.contents?.includes(' name: string;'));
+ t.true(modelArtifact?.contents?.includes(' nickname?: string | null;'));
+ t.true(modelArtifact?.contents?.includes(' archivedAt?: string | null;'));
+ t.true(modelArtifact?.contents?.includes(' phone?: string;'));
+ t.true(
+ serviceArtifact?.contents?.includes(
+ 'requestFactory(',
+ ),
+ );
+ });
+});
+
+test('generate collapses single-entry oneOf/anyOf/allOf wrappers instead of emitting redundant composition syntax', async t => {
+ await withTempDir(async outputPath => {
+ const result = await generate({
+ inputPath: fixture('single-entry-composition.openapi.yaml'),
+ outputPath,
+ emit: [...DEFAULT_EMIT],
+ });
+
+ t.deepEqual(result.diagnostics, []);
+
+ const modelArtifact = getArtifact(result, 'model.generated.ts');
+
+ t.truthy(modelArtifact);
+ t.true(modelArtifact?.contents?.includes('export type AnimalView = AnimalBase;'));
+ t.true(modelArtifact?.contents?.includes('export type AnimalDraft = AnimalBase;'));
+ t.true(modelArtifact?.contents?.includes('export type ContactPreference = string;'));
+ t.false(modelArtifact?.contents?.includes('AnimalBase |'));
+ t.false(modelArtifact?.contents?.includes('AnimalBase &'));
+ t.false(modelArtifact?.contents?.includes('ContactPreference |'));
+ t.true(modelArtifact?.contents?.includes(' nickname?: string | null;'));
+ });
+});
+
+test('generate emits Record-based contracts for typed additionalProperties object maps', async t => {
+ await withTempDir(async outputPath => {
+ const result = await generate({
+ inputPath: fixture('additional-properties.openapi.yaml'),
+ outputPath,
+ emit: [...DEFAULT_EMIT],
+ });
+
+ t.deepEqual(result.diagnostics, []);
+
+ const modelArtifact = getArtifact(result, 'model.generated.ts');
+ const serviceArtifact = getArtifact(result, 'rest/pet.rest.generated.ts');
+
+ t.truthy(modelArtifact);
+ t.truthy(serviceArtifact);
+ t.true(modelArtifact?.contents?.includes(' petsByBreed: Record;'));
+ t.true(
+ modelArtifact?.contents?.includes(
+ 'export type PetMetadataByTag = Record;',
+ ),
+ );
+ t.true(serviceArtifact?.contents?.includes('requestFactory.zeroArg('));
+ });
+});
+
+test('unsupported semantic fixture reports normalize-stage additionalProperties boundary instead of composition rejection', async t => {
+ await withTempDir(async outputPath => {
+ const error = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: fixture('unsupported-semantic.openapi.yaml'),
+ outputPath,
+ emit: [...DEFAULT_EMIT],
+ });
+ });
+
+ t.truthy(error);
+ t.true(error instanceof GenerateError);
+ t.is((error as any)?.code, unsupportedSemanticDiagnostic.code);
+ t.is((error as any)?.path, unsupportedSemanticDiagnostic.path);
+ t.regex(error?.message ?? '', /additionalProperties/i);
+ t.notRegex(error?.message ?? '', /\ballOf\b/i);
+ // Warnings live on `error.warnings`; the fatal sits at top level.
+ t.deepEqual((error as any)?.warnings, []);
+ });
+});
+
+test('generate rejects malformed fixture with stable fatal input diagnostic metadata', async t => {
+ await withTempDir(async outputPath => {
+ const error = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: fixture('malformed.yaml'),
+ outputPath,
+ emit: [...DEFAULT_EMIT],
+ });
+ });
+
+ t.truthy(error);
+ t.true(error instanceof GenerateError);
+ t.is((error as any)?.code, 'E_INPUT_INVALID');
+ t.true(
+ (error as any)?.path.endsWith(path.join('test', 'fixtures', 'malformed.yaml')),
+ );
+ });
+});
+
+test('generate rejects unsupported-root fixture with stable input-stage shape diagnostics', async t => {
+ await withTempDir(async outputPath => {
+ const error = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: fixture('unsupported-root.yaml'),
+ outputPath,
+ emit: [...DEFAULT_EMIT],
+ });
+ });
+
+ t.truthy(error);
+ t.true(error instanceof GenerateError);
+ t.is((error as any)?.code, unsupportedRootDiagnostic.code);
+ t.is(error?.message, unsupportedRootDiagnostic.message);
+ t.is((error as any)?.path, unsupportedRootDiagnostic.path);
+ });
+});
+
+test('generate produces byte-identical output across repeated calls (determinism)', async t => {
+ // README guarantees deterministic output: "same input always produces
+ // identical output". Run each fixture three times and assert byte-level
+ // equality on the full result (summary + diagnostics + artifact contents
+ // including banner).
+ const fixtures = ['petstore-rich.openapi.yaml', 'bench-large.openapi.yaml'];
+ for (const name of fixtures) {
+ 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] });
+ t.deepEqual(first, second, `${name}: run 1 vs run 2 must be byte-identical`);
+ t.deepEqual(second, third, `${name}: run 2 vs run 3 must be byte-identical`);
+ }
+});
+
+test('generate rejects schemas nested deeper than MAX_NORMALIZE_DEPTH', async t => {
+ // Pathological inline-allOf nesting (40 levels) trips the depth guard
+ // at level 32 — well below the serde recursion limit (~60), so this
+ // exercises the normalize-side guard rather than the YAML/JSON
+ // decoder's own protection. The error must surface as a typed
+ // GenerateError carrying E_UNSUPPORTED_SEMANTIC (no new subcode), with
+ // a message mentioning "nesting" and "exceeds" so consumers can
+ // recognise the bound rather than treating it as a generic schema
+ // shape failure.
+ const err = await t.throwsAsync(
+ async () => {
+ await generate({
+ inputPath: path.join(
+ __dirname,
+ '../test/fixtures/deep-nested-allof.openapi.yaml',
+ ),
+ emit: ['models'],
+ });
+ },
+ { instanceOf: GenerateError },
+ );
+ t.is(err?.code, 'E_UNSUPPORTED_SEMANTIC');
+ t.regex(err?.message ?? '', /nesting.*exceeds/i);
+});
+
+test('generate wraps Rust panic into E_UNEXPECTED GenerateError', async t => {
+ // Triggered by the cfg-test-only `inputPath: "__panic_for_test__"` sentinel.
+ const err = await t.throwsAsync(
+ async () => {
+ await generate({
+ inputPath: '__panic_for_test__',
+ emit: ['models'],
+ });
+ },
+ { instanceOf: GenerateError },
+ );
+ t.is(err?.code, 'E_UNEXPECTED');
+ t.regex(err?.message ?? '', /unexpected.*panic/i);
+});
+
+test('GenerateError is a real class so consumers can guard with instanceof', async t => {
+ const error = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: fixture('petstore-minimal.openapi.yaml'),
+ // emit is required — the missing field surfaces as a fatal
+ // option-resolution error.
+ emit: [],
+ });
+ });
+
+ t.true(error instanceof GenerateError, 'thrown value must be a GenerateError instance');
+ t.is((error as any).code, 'E_INVALID_OPTION');
+ t.is(typeof (error as any).message, 'string');
+ t.true(Array.isArray((error as any).warnings));
+});
+
+test('GenerateError carries pre-fatal warnings on error.warnings', async t => {
+ // The warning-then-fatal fixture trips a cookie-parameter warning on
+ // operation /a (normalize keeps going past warnings), then a fatal on
+ // operation /b (unsupported parameter location). Asserts the NAPI
+ // boundary enriches the thrown error with both surfaces: the fatal at
+ // top-level and the pre-fatal warning on `warnings[]`.
+ const error = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: fixture('warning-then-fatal.openapi.yaml'),
+ emit: ['models', 'angular'],
+ });
+ });
+
+ t.true(error instanceof GenerateError);
+ t.is((error as any).code, 'E_UNSUPPORTED_SEMANTIC');
+ t.regex(error?.message ?? '', /bogus_location/);
+
+ const warnings = (error as any).warnings as Array<{
+ code: string;
+ severity: string;
+ message: string;
+ path: string;
+ }>;
+ t.true(Array.isArray(warnings));
+ t.is(warnings.length, 1, 'cookie param should produce exactly one pre-fatal warning');
+ t.is(warnings[0].severity, 'warning');
+ t.is(warnings[0].code, 'E_UNSUPPORTED_SEMANTIC');
+ t.regex(warnings[0].message, /cookie/);
+ t.regex(warnings[0].message, /sessionId/);
+});
+
+test('GenerateError.isGenerateError detects upgraded errors via the cross-realm marker', async t => {
+ const error = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: fixture('petstore-minimal.openapi.yaml'),
+ emit: [],
+ });
+ });
+ t.true(GenerateError.isGenerateError(error));
+ t.false(GenerateError.isGenerateError(new Error('plain')));
+ t.false(GenerateError.isGenerateError(null));
+ t.false(GenerateError.isGenerateError(undefined));
+ t.false(GenerateError.isGenerateError({ code: 'E_INVALID_OPTION' }));
+});
+
+test('GenerateError marker constant matches the value Rust embeds via build.rs', async t => {
+ // `lib/error-marker.json` is the single source of truth shared with
+ // the Rust binding through `env!("OPENAPI_NG_ERROR_MARKER")` (see
+ // build.rs). The constant inside the published JSON file must remain
+ // a non-empty string so the cross-realm marker survives the boundary.
+ const marker = JSON.parse(
+ fs.readFileSync(path.join(__dirname, '..', 'lib', 'error-marker.json'), 'utf8'),
+ );
+ t.is(typeof marker.marker, 'string');
+ t.true(marker.marker.length > 0);
+
+ // The marker property is non-enumerable on a GenerateError instance —
+ // expose it only via OwnPropertyDescriptors so that JSON.stringify(err)
+ // doesn't leak the sentinel into application output.
+ const error = (await t.throwsAsync(async () => {
+ await generate({ inputPath: fixture('petstore-minimal.openapi.yaml'), emit: [] });
+ }))!;
+ const descriptor = Object.getOwnPropertyDescriptor(error, marker.marker);
+ t.truthy(descriptor, 'marker property must be present on the GenerateError instance');
+ t.is(descriptor?.enumerable, false);
+ t.is(descriptor?.value, true);
+});
+
+test('generate is the public API name (replaces generateNative)', t => {
+ const mod = createRequire(import.meta.url)('../lib/index.js');
+ t.is(typeof mod.generate, 'function');
+ t.is(mod.generateNative, undefined);
+});
+
+test('index.d.ts exposes the generate contract (emit array, contents-only artifacts, GenerateError class)', t => {
+ const contract = fs.readFileSync(path.join(__dirname, '..', 'index.d.ts'), 'utf8');
+
+ t.true(
+ contract.includes(
+ 'export declare function generate(options: GenerateOptions): Promise',
+ ),
+ );
+ t.true(contract.includes('export interface GenerateOptions {'));
+ t.true(contract.includes('inputPath?: string'));
+ t.true(contract.includes('inputContents?: string'));
+ t.true(contract.includes('displayPath?: string'));
+ t.true(contract.includes('inputFormat?: InputFormat'));
+ t.true(contract.includes('outputPath?: string'));
+ t.true(contract.includes('emit?: Array'));
+ t.true(contract.includes('mappedTypes?: Array'));
+ t.true(contract.includes('export interface GenerateResult {'));
+ t.true(contract.includes('summary: GenerateSummary'));
+ t.true(contract.includes('diagnostics: Array'));
+ t.true(contract.includes('artifacts: Array'));
+ t.true(contract.includes('export interface GeneratedArtifact {'));
+ t.true(contract.includes('normalizedSourcePath: string'));
+ t.true(contract.includes('export declare class GenerateError extends Error {'));
+ t.true(contract.includes('export interface MappedType {'));
+ t.true(contract.includes(' schema: string'));
+ t.true(contract.includes(' import: string'));
+ t.true(contract.includes(' type: string'));
+ t.true(contract.includes(' alias?: string'));
+});
+
+test('naming.methodName strips verb prefix end-to-end (posts_listAll → listAll)', async t => {
+ const result = await generate({
+ inputPath: fixture('verb-prefix.openapi.yaml'),
+ emit: ['models', 'angular'],
+ naming: {
+ methodName: {
+ from: '{operationId}',
+ parse: /^[^_]+_(?.+)$/,
+ format: '{capture.rest}',
+ case: 'camel',
+ },
+ },
+ });
+ const service = result.artifacts.find(
+ (a: { path: string }) => a.path === 'rest/post.rest.generated.ts',
+ );
+ t.assert(service, 'expected post service artifact');
+ t.regex(service!.contents, /\blistAll\b/);
+ t.regex(service!.contents, /\bcreate\b/);
+ t.notRegex(service!.contents, /\bposts_listAll\b/);
+ t.notRegex(service!.contents, /\bposts_create\b/);
+});
+
+test.serial('generate fetches an https URL and uses it as displayPath', async t => {
+ const yaml = 'openapi: 3.0.3\ninfo: { title: Fetched, version: 1.0.0 }\npaths: {}\n';
+ __setFetchImplForTest(
+ async () => new Response(yaml, { headers: { 'content-type': 'application/yaml' } }),
+ );
+ try {
+ const result = await generate({
+ inputPath: 'https://example.com/spec.yaml',
+ emit: ['models'],
+ });
+ t.is(result.summary.title, 'Fetched');
+ t.is(result.summary.normalizedSourcePath, 'https://example.com/spec.yaml');
+ } finally {
+ __setFetchImplForTest(null);
+ }
+});
+
+test.serial('generate rejects http URLs with E_INPUT_INVALID', async t => {
+ __setFetchImplForTest(async () => new Response('{}'));
+ try {
+ const err = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: 'http://example.com/spec.json',
+ emit: ['models'],
+ });
+ });
+ t.is((err as any).code, 'E_INPUT_INVALID');
+ t.true((err as any).message.includes('https://'));
+ } finally {
+ __setFetchImplForTest(null);
+ }
+});
+
+test.serial(
+ 'generate rejects both inputContents and a URL inputPath without fetching',
+ async t => {
+ let fetched = false;
+ __setFetchImplForTest(async () => {
+ fetched = true;
+ return new Response('openapi: 3.0.3\n', {
+ headers: { 'content-type': 'application/yaml' },
+ });
+ });
+ try {
+ const err = await t.throwsAsync(async () => {
+ await generate({
+ inputPath: 'https://example.com/spec.yaml',
+ inputContents: 'openapi: 3.0.3\n',
+ displayPath: 'inline',
+ emit: ['models'],
+ } as any);
+ });
+ t.is((err as any).code, 'E_INVALID_OPTION');
+ t.is((err as any).subcode, 'shape');
+ t.false(
+ fetched,
+ 'fetchInput must not be called when validation would reject the call anyway',
+ );
+ } finally {
+ __setFetchImplForTest(null);
+ }
+ },
+);
+
+// Three concurrent generate() calls against distinct temp dirs, each
+// pointed at the same on-disk spec. Catches mutable-state regressions in
+// `prepareOptions` (the options object is no longer mutated; see the
+// related non-mutation fix in lib/wrapper-core.js) and any future caching
+// layer that might leak across simultaneous invocations.
+test('generate is safe to run concurrently across distinct outputs', async t => {
+ const baseOptions = {
+ inputPath: fixture('petstore-minimal.openapi.yaml'),
+ emit: [...DEFAULT_EMIT] as ('models' | 'angular')[],
+ naming: { methodName: '{operationId}' },
+ };
+
+ await withTempDir(async dirA => {
+ await withTempDir(async dirB => {
+ await withTempDir(async dirC => {
+ const sharedNaming = baseOptions.naming;
+ const [a, b, c] = await Promise.all([
+ generate({ ...baseOptions, outputPath: dirA }),
+ generate({ ...baseOptions, outputPath: dirB }),
+ generate({ ...baseOptions, outputPath: dirC }),
+ ]);
+
+ // The shared `naming` object must be untouched after concurrent
+ // calls — `prepareOptions` builds a normalized naming via spread
+ // instead of writing back to the caller's input.
+ t.is(baseOptions.naming, sharedNaming);
+ t.deepEqual(baseOptions.naming, { methodName: '{operationId}' });
+
+ // Every call returns the same artifact set (deterministic) and
+ // every output directory contains the same file list.
+ const namesA = a.artifacts.map(art => art.path).sort();
+ const namesB = b.artifacts.map(art => art.path).sort();
+ const namesC = c.artifacts.map(art => art.path).sort();
+ t.deepEqual(namesA, namesB);
+ t.deepEqual(namesA, namesC);
+ t.true(namesA.includes('model.generated.ts'));
+
+ for (const dir of [dirA, dirB, dirC]) {
+ t.true(
+ fs.existsSync(path.join(dir, 'model.generated.ts')),
+ `model.generated.ts must exist in ${dir}`,
+ );
+ }
+
+ // Contents are byte-identical across all three calls.
+ const aByPath = new Map(a.artifacts.map(art => [art.path, art.contents]));
+ for (const art of b.artifacts) {
+ t.is(aByPath.get(art.path), art.contents, `mismatch at ${art.path}`);
+ }
+ });
+ });
+ });
+});
diff --git a/__test__/package.json b/__test__/package.json
new file mode 100644
index 0000000..3dbc1ca
--- /dev/null
+++ b/__test__/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "module"
+}
diff --git a/__test__/package.spec.ts b/__test__/package.spec.ts
new file mode 100644
index 0000000..5bf7c47
--- /dev/null
+++ b/__test__/package.spec.ts
@@ -0,0 +1,326 @@
+import test from 'ava';
+import fs from 'node:fs';
+import { createRequire } from 'node:module';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { GenerateError } from '../lib/index.js';
+import * as lib from '../lib/index.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.join(__dirname, '..');
+const require = createRequire(import.meta.url);
+
+test('browser entry exists and fails with an explicit unsupported-runtime error', async t => {
+ const browserEntry = require(path.join(repoRoot, 'browser.js')) as {
+ generate: (options?: unknown) => Promise;
+ };
+
+ t.is(typeof browserEntry.generate, 'function');
+
+ // The browser entry is now a real async wrapper; generate() rejects
+ // with a GenerateError when the optional
+ // `@avsystem/openapi-ng-wasm32-wasip1-threads` package can't load
+ // (typical on a dev machine without the WASI sub-package installed).
+ // The message names the optional package so the consumer knows what
+ // to install.
+ const generateError = await t.throwsAsync(async () => {
+ await browserEntry.generate();
+ });
+ const msg = generateError?.message ?? '';
+ t.regex(msg, /browser|runtime/i);
+ t.true(
+ msg.includes('@avsystem/openapi-ng-wasm32-wasip1-threads'),
+ `message must name the optional WASI package: ${msg}`,
+ );
+});
+
+test('package.json exports map covers node, browser, default, and types conditions', t => {
+ const packageJson = JSON.parse(
+ fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'),
+ ) as {
+ exports?: Record | string>;
+ };
+
+ const root = packageJson.exports?.['.'];
+ t.truthy(root, 'package.json exports map must include "."');
+ t.is(typeof root, 'object');
+ const conditions = root as Record;
+ t.is(conditions.types, './index.d.ts');
+ t.is(conditions.browser, './browser.js');
+ // Node entry is the wrapper at lib/index.js that upgrades thrown
+ // errors into `GenerateError` instances. The raw napi-rs binding at
+ // ./index.js is internal.
+ t.is(conditions.node, './lib/index.js');
+ t.is(conditions.default, './lib/index.js');
+});
+
+test('package metadata keeps the node-only packaging contract explicit', t => {
+ const packageJson = JSON.parse(
+ fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'),
+ ) as {
+ browser?: string;
+ description?: string;
+ files?: string[];
+ main?: string;
+ types?: string;
+ };
+
+ t.is(packageJson.main, 'lib/index.js');
+ t.is(packageJson.types, 'index.d.ts');
+ t.is(packageJson.browser, 'browser.js');
+ t.true(packageJson.files?.includes('browser.js') ?? false);
+ t.true(packageJson.files?.includes('lib/index.js') ?? false);
+ t.notRegex(packageJson.description ?? '', /Template project/i);
+});
+
+/**
+ * Pure mapping from a Rust target triple to the npm sub-package name that
+ * `napi prepublish -t npm` publishes. Mirrors the canonical napi-rs naming
+ * scheme so the optionalDependencies block can be derived from `napi.targets`.
+ * `packageName` is the host package's full name (including any `@scope/`),
+ * which napi-rs uses as the prefix for every sub-package.
+ */
+function napiSubPackageName(packageName: string, triple: string): string {
+ const archMap: Record = {
+ x86_64: 'x64',
+ aarch64: 'arm64',
+ i686: 'ia32',
+ armv7: 'arm',
+ };
+
+ // WASI/WASM targets keep their full Rust triple as the sub-package suffix
+ // (napi-rs convention; e.g. `@avsystem/openapi-ng-wasm32-wasip1-threads`).
+ if (triple.startsWith('wasm32-')) {
+ return `${packageName}-${triple}`;
+ }
+
+ const [rawArch, ...rest] = triple.split('-');
+ const arch = archMap[rawArch] ?? rawArch;
+ const remainder = rest.join('-');
+
+ if (remainder === 'apple-darwin') {
+ return `${packageName}-darwin-${arch}`;
+ }
+ if (remainder === 'unknown-linux-gnu') {
+ return `${packageName}-linux-${arch}-gnu`;
+ }
+ if (remainder === 'unknown-linux-musl') {
+ return `${packageName}-linux-${arch}-musl`;
+ }
+ if (remainder === 'pc-windows-msvc') {
+ return `${packageName}-win32-${arch}-msvc`;
+ }
+ if (remainder === 'pc-windows-gnu') {
+ return `${packageName}-win32-${arch}-gnu`;
+ }
+ throw new Error(`Unsupported napi target triple: ${triple}`);
+}
+
+test('optionalDependencies covers every napi target', t => {
+ const packageJson = JSON.parse(
+ fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'),
+ ) as {
+ name?: string;
+ napi?: { binaryName?: string; packageName?: string; targets?: string[] };
+ optionalDependencies?: Record;
+ };
+
+ const targets = packageJson.napi?.targets ?? [];
+ // napi-rs derives sub-package names from `napi.packageName ?? pkg.name`,
+ // NOT from `napi.binaryName` (which only controls the `.node` filename).
+ // Mirror that so a scoped host package (e.g. `@avsystem/openapi-ng`) maps
+ // to scoped sub-packages.
+ const packageName = packageJson.napi?.packageName ?? packageJson.name ?? '';
+ t.true(targets.length > 0, 'napi.targets must be non-empty');
+ t.truthy(packageName, 'package.json#name (or napi.packageName) must be set');
+
+ const expected = [
+ ...new Set(targets.map(triple => napiSubPackageName(packageName, triple))),
+ ].sort();
+ const actual = [...new Set(Object.keys(packageJson.optionalDependencies ?? {}))].sort();
+
+ t.deepEqual(
+ actual,
+ expected,
+ 'optionalDependencies must list exactly the napi-derived sub-package names',
+ );
+});
+
+test('optionalDependency versions track package.json#version', t => {
+ const packageJson = JSON.parse(
+ fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'),
+ ) as {
+ version: string;
+ optionalDependencies?: Record;
+ };
+
+ for (const [name, version] of Object.entries(packageJson.optionalDependencies ?? {})) {
+ t.is(
+ version,
+ packageJson.version,
+ `${name} pinned to a different version than the host`,
+ );
+ }
+});
+
+test('GenerateError always exposes code as a string', t => {
+ const err = new GenerateError({ message: 'm' });
+ t.is(typeof err.code, 'string');
+ t.not(err.code, undefined);
+});
+
+test('GenerateError always exposes path as a string', t => {
+ const err = new GenerateError({ message: 'm' });
+ t.is(typeof err.path, 'string');
+ t.not(err.path, undefined);
+});
+
+test('GenerateError.subcode is null (never undefined) when absent', t => {
+ const err = new GenerateError({ message: 'm' });
+ t.is(err.subcode, null);
+});
+
+test('public surface is a fixed allow-list', t => {
+ const allowed = new Set(['generate', 'GenerateError', 'EmitTarget']);
+ // Node's CJS-to-ESM interop synthesises two bindings on `import * as`:
+ // - `'module.exports'`: the raw CJS object alongside per-property exports;
+ // - `'default'`: the same CJS object exposed as the default import.
+ // Neither is under the wrapper's control, so filter both out here.
+ // The wrapper-controlled absence of a default export is asserted via
+ // CommonJS `require()` in the dedicated test below.
+ const actual = new Set(
+ Object.keys(lib).filter(k => k !== 'module.exports' && k !== 'default'),
+ );
+ for (const key of actual) {
+ t.true(allowed.has(key), `unexpected export: ${key}`);
+ }
+});
+
+test('public surface does not advertise a default export', t => {
+ // Read via CommonJS `require()` so we see the wrapper's actual
+ // `module.exports` object — `import * as` would synthesise a
+ // `default` binding via Node's CJS-to-ESM interop regardless of what
+ // the wrapper exposes, hiding the very thing we're asserting.
+ const mod = require('../lib/index.js');
+ t.false('default' in mod, 'Node entry should not expose a default export');
+});
+
+test('engines.node declares a >=18 floor', t => {
+ const packageJson = JSON.parse(
+ fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'),
+ ) as { engines?: { node?: string } };
+
+ const range = packageJson.engines?.node ?? '';
+ // 18 is the floor pinned by the supported-Node policy (CI matrix, README,
+ // and native-binding ABI assumptions). Installs on older Node should fail
+ // at the manifest boundary, not at first CLI invocation.
+ const match = range.match(/>=\s*(\d+)/);
+ t.truthy(match, `engines.node must declare a >= lower bound (got '${range}')`);
+ const major = Number(match![1]);
+ t.true(major >= 18, `engines.node lower bound must be >= 18, got '${range}'`);
+});
+
+test('package.json engines.node matches README', t => {
+ const pkg = JSON.parse(
+ fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'),
+ ) as { engines?: { node?: string } };
+ const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
+ t.is(pkg.engines?.node, '>=18.0.0');
+ t.regex(readme, /Requires Node\.js >= 18/);
+});
+
+test('patch-types narrows GeneratorDiagnostic and GenerateErrorPayload bodies', t => {
+ const dts = fs.readFileSync(path.join(repoRoot, 'index.d.ts'), 'utf8');
+
+ // Each narrowed line must appear exactly once in the generated d.ts
+ // (twice in total — once per interface — for the shared lines).
+ function countOccurrences(haystack: string, needle: string): number {
+ return haystack.split(needle).length - 1;
+ }
+
+ t.is(countOccurrences(dts, ' code: DiagnosticCode'), 2);
+ t.is(countOccurrences(dts, ' subcode: DiagnosticSubcode | null'), 2);
+ t.is(countOccurrences(dts, " severity: 'warning' | 'error'"), 1);
+
+ // The unpatched literals must not survive in the published surface.
+ t.false(dts.includes(' subcode?: string'));
+ t.false(dts.includes(' code: string'));
+ t.false(dts.includes(' severity: string'));
+});
+
+test('runtime docs document the current node-only boundary and thin native transport', t => {
+ const runtimeDoc = fs.readFileSync(
+ path.join(
+ repoRoot,
+ 'website',
+ 'src',
+ 'content',
+ 'docs',
+ 'reference',
+ 'runtime.md',
+ ),
+ 'utf8',
+ );
+
+ t.regex(runtimeDoc, /does not support browser runtimes/i);
+ t.regex(
+ runtimeDoc,
+ /thin transport over the same\s+native `generate\(\)` path used by Node/i,
+ );
+});
+
+test('native.js includes a friendly unsupported-platform error with supported list', t => {
+ const nativeJs = fs.readFileSync(path.join(repoRoot, 'native.js'), 'utf8');
+
+ t.true(
+ nativeJs.includes('does not ship a native binary for'),
+ 'native.js must include the friendly unsupported-platform error message',
+ );
+
+ // Each supported platform key must be listed in the injected Set.
+ for (const platformKey of [
+ 'darwin/x64',
+ 'darwin/arm64',
+ 'linux/x64',
+ 'linux/arm64',
+ 'win32/x64',
+ 'win32/arm64',
+ ]) {
+ t.true(
+ nativeJs.includes(`'${platformKey}'`),
+ `native.js must list '${platformKey}' as a supported platform`,
+ );
+ }
+});
+
+test('lib/config.js is shipped in package.json files allow-list', t => {
+ const packageJson = JSON.parse(
+ fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'),
+ ) as { files?: string[] };
+ t.true(
+ packageJson.files?.includes('lib/config.js') ?? false,
+ 'package.json files must include lib/config.js',
+ );
+});
+
+test('package.json exports map exposes ./config subpath', t => {
+ const packageJson = JSON.parse(
+ fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'),
+ ) as { exports?: Record | string> };
+ const subpath = packageJson.exports?.['./config'];
+ t.truthy(subpath, 'package.json exports must include "./config"');
+ t.is(typeof subpath, 'object');
+ const conditions = subpath as Record;
+ t.is(conditions.types, './index.d.ts');
+ t.is(conditions.default, './lib/config.js');
+});
+
+test('@avsystem/openapi-ng/config exports defineConfig as an identity function', t => {
+ const mod = require(path.join(repoRoot, 'lib', 'config.js')) as {
+ defineConfig: (c: T) => T;
+ };
+ t.is(typeof mod.defineConfig, 'function');
+ const input = { input: 'spec.yaml', output: './out' };
+ t.is(mod.defineConfig(input), input);
+});
diff --git a/__test__/rest-util-base-path.spec.ts b/__test__/rest-util-base-path.spec.ts
new file mode 100644
index 0000000..3cfbc93
--- /dev/null
+++ b/__test__/rest-util-base-path.spec.ts
@@ -0,0 +1,98 @@
+// Mirror-test of the URL-join logic used inside `templates/angular/rest.util.ts`
+// to prepend `OPENAPI_NG_BASE_PATH` to `CommonRequest.url`. We can't import the
+// template directly here — it pulls in `@angular/common/http`, which needs
+// Angular's JIT/platform bootstrapping that's heavy to stand up under plain
+// Node — so we keep a parallel implementation pinned to the template's
+// behaviour. The snapshot suite is the authoritative check that the template
+// itself still contains the matching algorithm.
+import test from 'ava';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+function joinBasePath(base: string, url: string): string {
+ // Absolute URLs (https://…, etc.) bypass the configured basePath.
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return url;
+ const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
+ const normalizedUrl = url.startsWith('/') ? url : `/${url}`;
+ return normalizedBase + normalizedUrl;
+}
+
+test('joinBasePath: absolute base + leading-slash path', t => {
+ t.is(
+ joinBasePath('https://api.example.com', '/pets'),
+ 'https://api.example.com/pets',
+ );
+});
+
+test('joinBasePath: trailing slash on base is normalised', t => {
+ t.is(
+ joinBasePath('https://api.example.com/', '/pets'),
+ 'https://api.example.com/pets',
+ );
+});
+
+test('joinBasePath: base with subpath', t => {
+ t.is(
+ joinBasePath('https://api.example.com/v1', '/pets/123'),
+ 'https://api.example.com/v1/pets/123',
+ );
+});
+
+test('joinBasePath: relative base + leading-slash path', t => {
+ t.is(joinBasePath('/api', '/pets'), '/api/pets');
+});
+
+test('joinBasePath: url without leading slash gets one added', t => {
+ t.is(joinBasePath('/api', 'pets'), '/api/pets');
+});
+
+test('joinBasePath: both trailing- and leading-slash collapse to one slash', t => {
+ t.is(joinBasePath('/api/', '/pets'), '/api/pets');
+});
+
+test('joinBasePath: absolute http url on request bypasses basePath', t => {
+ t.is(
+ joinBasePath('https://api.example.com', 'http://other.example.com/pets'),
+ 'http://other.example.com/pets',
+ );
+});
+
+test('joinBasePath: absolute https url on request bypasses basePath', t => {
+ t.is(
+ joinBasePath('/api', 'https://other.example.com/pets'),
+ 'https://other.example.com/pets',
+ );
+});
+
+test('joinBasePath: protocol-relative urls are NOT bypassed', t => {
+ // `//host/path` is protocol-relative, not absolute by RFC 3986 scheme rules;
+ // the regex requires `scheme:` before `//`, so basePath still applies.
+ t.is(joinBasePath('/api', '//other.example.com/pets'), '/api//other.example.com/pets');
+});
+
+// Pin the template against the parallel implementation: if the template's
+// joinBasePath is ever edited away from this algorithm, this test surfaces
+// the drift immediately (without booting Angular).
+test('template `joinBasePath` source matches this parallel implementation', t => {
+ const templatePath = path.resolve(
+ __dirname,
+ '..',
+ 'templates',
+ 'angular',
+ 'rest.util.ts',
+ );
+ const source = fs.readFileSync(templatePath, 'utf8');
+ const match = source.match(
+ /function joinBasePath\(base: string, url: string\): string \{\s*([\s\S]*?)\n\}/u,
+ );
+ t.truthy(match, 'expected to find joinBasePath in template');
+ const body = (match![1] ?? '').replace(/\s+/gu, ' ').trim();
+ t.is(
+ body,
+ "// Absolute URLs (https://…, etc.) bypass the configured basePath. if (/^[a-z][a-z0-9+.-]*:\\/\\//i.test(url)) return url; const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base; const normalizedUrl = url.startsWith('/') ? url : `/${url}`; return normalizedBase + normalizedUrl;",
+ 'template joinBasePath drifted from the parallel test implementation',
+ );
+});
diff --git a/__test__/snapshots/generate-native/additional-properties-boolean.openapi.yaml.failure.json b/__test__/snapshots/generate-native/additional-properties-boolean.openapi.yaml.failure.json
new file mode 100644
index 0000000..8823bf2
--- /dev/null
+++ b/__test__/snapshots/generate-native/additional-properties-boolean.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "message": "Unsupported OpenAPI semantic shape: schema PermissiveBag combines additionalProperties with named object properties, which remains outside the supported subset.",
+ "path": "test/fixtures/additional-properties-boolean.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/additional-properties-false.openapi.yaml.success.json b/__test__/snapshots/generate-native/additional-properties-false.openapi.yaml.success.json
new file mode 100644
index 0000000..a0d03ae
--- /dev/null
+++ b/__test__/snapshots/generate-native/additional-properties-false.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/additional-properties-false.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Additional Properties False",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 1
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/additional-properties-false.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/additional-properties-false.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..f240ff2
--- /dev/null
+++ b/__test__/snapshots/generate-native/additional-properties-false.openapi.yaml/model.generated.ts
@@ -0,0 +1,3 @@
+export interface StrictBag {
+ id: string;
+}
diff --git a/__test__/snapshots/generate-native/additional-properties.openapi.yaml.success.json b/__test__/snapshots/generate-native/additional-properties.openapi.yaml.success.json
new file mode 100644
index 0000000..3f1bb3f
--- /dev/null
+++ b/__test__/snapshots/generate-native/additional-properties.openapi.yaml.success.json
@@ -0,0 +1,25 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/additional-properties.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Petstore Additional Properties",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 5
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/additional-properties.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/additional-properties.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..2f0e970
--- /dev/null
+++ b/__test__/snapshots/generate-native/additional-properties.openapi.yaml/model.generated.ts
@@ -0,0 +1,21 @@
+export interface Pet {
+ id: string;
+ name: string;
+}
+
+export interface PetCatalog {
+ scope: 'available' | 'adopted' | 'foster';
+ petsByBreed: Record;
+}
+
+export interface PetMetadata {
+ spotlight: boolean;
+ tag: Tag;
+}
+
+export type PetMetadataByTag = Record;
+
+export interface Tag {
+ id: number;
+ label: string;
+}
diff --git a/__test__/snapshots/generate-native/additional-properties.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/additional-properties.openapi.yaml/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..8911440
--- /dev/null
+++ b/__test__/snapshots/generate-native/additional-properties.openapi.yaml/rest/pet.rest.generated.ts
@@ -0,0 +1,16 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+import type { PetCatalog } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ readonly listPetCatalog = requestFactory.zeroArg(
+ () => ({
+ method: 'GET',
+ url: `/pets`,
+ }),
+ );
+}
diff --git a/__test__/snapshots/generate-native/allof-composition.openapi.yaml.success.json b/__test__/snapshots/generate-native/allof-composition.openapi.yaml.success.json
new file mode 100644
index 0000000..2fc190b
--- /dev/null
+++ b/__test__/snapshots/generate-native/allof-composition.openapi.yaml.success.json
@@ -0,0 +1,47 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/allof-composition.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "AllOf Composition",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 3
+ },
+ "diagnostics": [
+ {
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "severity": "warning",
+ "message": "schema AuditFields.createdAt declares format 'date-time', which is currently dropped — the generator emits the base type without format-specific narrowing.",
+ "path": "test/fixtures/allof-composition.openapi.yaml",
+ "subcode": "format-dropped"
+ },
+ {
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "severity": "warning",
+ "message": "schema AuditFields.archivedAt declares format 'date-time', which is currently dropped — the generator emits the base type without format-specific narrowing.",
+ "path": "test/fixtures/allof-composition.openapi.yaml",
+ "subcode": "format-dropped"
+ },
+ {
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "severity": "warning",
+ "message": "schema ContactFields.email declares format 'email', which is currently dropped — the generator emits the base type without format-specific narrowing.",
+ "path": "test/fixtures/allof-composition.openapi.yaml",
+ "subcode": "format-dropped"
+ }
+ ],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/adopter.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/allof-composition.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/allof-composition.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..b32d848
--- /dev/null
+++ b/__test__/snapshots/generate-native/allof-composition.openapi.yaml/model.generated.ts
@@ -0,0 +1,15 @@
+export type AdopterProfile = AuditFields & ContactFields & {
+ id: string;
+ name: string;
+ nickname?: string | null;
+};
+
+export interface AuditFields {
+ createdAt: string;
+ archivedAt?: string | null;
+}
+
+export interface ContactFields {
+ email: string;
+ phone?: string;
+}
diff --git a/__test__/snapshots/generate-native/allof-composition.openapi.yaml/rest/adopter.rest.generated.ts b/__test__/snapshots/generate-native/allof-composition.openapi.yaml/rest/adopter.rest.generated.ts
new file mode 100644
index 0000000..200dfdc
--- /dev/null
+++ b/__test__/snapshots/generate-native/allof-composition.openapi.yaml/rest/adopter.rest.generated.ts
@@ -0,0 +1,23 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+import type { AdopterProfile } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class AdopterRest {
+
+ readonly getAdopterProfile = requestFactory(
+ (request: GetAdopterProfileParams) => {
+ const { adopterId } = request;
+ return {
+ method: 'GET',
+ url: `/adopters/${encodeURIComponent(adopterId)}`,
+ };
+ },
+ );
+}
+
+export interface GetAdopterProfileParams {
+ adopterId: string;
+}
diff --git a/__test__/snapshots/generate-native/anchor-fanout.openapi.yaml.failure.json b/__test__/snapshots/generate-native/anchor-fanout.openapi.yaml.failure.json
new file mode 100644
index 0000000..c5f6640
--- /dev/null
+++ b/__test__/snapshots/generate-native/anchor-fanout.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_POLICY_VIOLATION",
+ "message": "Failed to decode OpenAPI input: YAML anchor expansion produced 15381905 bytes from 45147 bytes of source — 340× ratio exceeds the cap of 50×. The spec likely uses anchors with deep fan-out; inline the aliases or set OPENAPI_NG_MAX_EXPANSION_RATIO to override.",
+ "path": "test/fixtures/anchor-fanout.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/anchor-modest.openapi.yaml.success.json b/__test__/snapshots/generate-native/anchor-modest.openapi.yaml.success.json
new file mode 100644
index 0000000..93fc891
--- /dev/null
+++ b/__test__/snapshots/generate-native/anchor-modest.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/anchor-modest.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "anchor-modest",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 4
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/anchor-modest.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/anchor-modest.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..d021e28
--- /dev/null
+++ b/__test__/snapshots/generate-native/anchor-modest.openapi.yaml/model.generated.ts
@@ -0,0 +1,28 @@
+export interface Audit {
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+export type Owner = {
+ createdAt?: string;
+ updatedAt?: string;
+} & {
+ id?: string;
+ displayName?: string;
+};
+
+export type Pet = {
+ createdAt?: string;
+ updatedAt?: string;
+} & {
+ id?: string;
+ name?: string;
+};
+
+export type Visit = {
+ createdAt?: string;
+ updatedAt?: string;
+} & {
+ id?: string;
+ notes?: string;
+};
diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml.success.json b/__test__/snapshots/generate-native/bench-large.openapi.yaml.success.json
new file mode 100644
index 0000000..cf030d7
--- /dev/null
+++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml.success.json
@@ -0,0 +1,40 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/bench-large.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Bench Large",
+ "pathCount": 30,
+ "operationCount": 60,
+ "schemaCount": 90
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/resource1.rest.generated.ts"
+ },
+ {
+ "path": "rest/resource2.rest.generated.ts"
+ },
+ {
+ "path": "rest/resource3.rest.generated.ts"
+ },
+ {
+ "path": "rest/resource4.rest.generated.ts"
+ },
+ {
+ "path": "rest/resource5.rest.generated.ts"
+ },
+ {
+ "path": "rest/resource6.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..cb7c6d0
--- /dev/null
+++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/model.generated.ts
@@ -0,0 +1,359 @@
+export interface Resource1 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource1Status;
+}
+
+export interface Resource10 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource10Status;
+}
+
+export type Resource10List = Resource10[];
+
+export type Resource10Status = 'active' | 'archived' | 'pending';
+
+export interface Resource11 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource11Status;
+}
+
+export type Resource11List = Resource11[];
+
+export type Resource11Status = 'active' | 'archived' | 'pending';
+
+export interface Resource12 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource12Status;
+}
+
+export type Resource12List = Resource12[];
+
+export type Resource12Status = 'active' | 'archived' | 'pending';
+
+export interface Resource13 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource13Status;
+}
+
+export type Resource13List = Resource13[];
+
+export type Resource13Status = 'active' | 'archived' | 'pending';
+
+export interface Resource14 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource14Status;
+}
+
+export type Resource14List = Resource14[];
+
+export type Resource14Status = 'active' | 'archived' | 'pending';
+
+export interface Resource15 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource15Status;
+}
+
+export type Resource15List = Resource15[];
+
+export type Resource15Status = 'active' | 'archived' | 'pending';
+
+export interface Resource16 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource16Status;
+}
+
+export type Resource16List = Resource16[];
+
+export type Resource16Status = 'active' | 'archived' | 'pending';
+
+export interface Resource17 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource17Status;
+}
+
+export type Resource17List = Resource17[];
+
+export type Resource17Status = 'active' | 'archived' | 'pending';
+
+export interface Resource18 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource18Status;
+}
+
+export type Resource18List = Resource18[];
+
+export type Resource18Status = 'active' | 'archived' | 'pending';
+
+export interface Resource19 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource19Status;
+}
+
+export type Resource19List = Resource19[];
+
+export type Resource19Status = 'active' | 'archived' | 'pending';
+
+export type Resource1List = Resource1[];
+
+export type Resource1Status = 'active' | 'archived' | 'pending';
+
+export interface Resource2 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource2Status;
+}
+
+export interface Resource20 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource20Status;
+}
+
+export type Resource20List = Resource20[];
+
+export type Resource20Status = 'active' | 'archived' | 'pending';
+
+export interface Resource21 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource21Status;
+}
+
+export type Resource21List = Resource21[];
+
+export type Resource21Status = 'active' | 'archived' | 'pending';
+
+export interface Resource22 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource22Status;
+}
+
+export type Resource22List = Resource22[];
+
+export type Resource22Status = 'active' | 'archived' | 'pending';
+
+export interface Resource23 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource23Status;
+}
+
+export type Resource23List = Resource23[];
+
+export type Resource23Status = 'active' | 'archived' | 'pending';
+
+export interface Resource24 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource24Status;
+}
+
+export type Resource24List = Resource24[];
+
+export type Resource24Status = 'active' | 'archived' | 'pending';
+
+export interface Resource25 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource25Status;
+}
+
+export type Resource25List = Resource25[];
+
+export type Resource25Status = 'active' | 'archived' | 'pending';
+
+export interface Resource26 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource26Status;
+}
+
+export type Resource26List = Resource26[];
+
+export type Resource26Status = 'active' | 'archived' | 'pending';
+
+export interface Resource27 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource27Status;
+}
+
+export type Resource27List = Resource27[];
+
+export type Resource27Status = 'active' | 'archived' | 'pending';
+
+export interface Resource28 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource28Status;
+}
+
+export type Resource28List = Resource28[];
+
+export type Resource28Status = 'active' | 'archived' | 'pending';
+
+export interface Resource29 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource29Status;
+}
+
+export type Resource29List = Resource29[];
+
+export type Resource29Status = 'active' | 'archived' | 'pending';
+
+export type Resource2List = Resource2[];
+
+export type Resource2Status = 'active' | 'archived' | 'pending';
+
+export interface Resource3 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource3Status;
+}
+
+export interface Resource30 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource30Status;
+}
+
+export type Resource30List = Resource30[];
+
+export type Resource30Status = 'active' | 'archived' | 'pending';
+
+export type Resource3List = Resource3[];
+
+export type Resource3Status = 'active' | 'archived' | 'pending';
+
+export interface Resource4 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource4Status;
+}
+
+export type Resource4List = Resource4[];
+
+export type Resource4Status = 'active' | 'archived' | 'pending';
+
+export interface Resource5 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource5Status;
+}
+
+export type Resource5List = Resource5[];
+
+export type Resource5Status = 'active' | 'archived' | 'pending';
+
+export interface Resource6 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource6Status;
+}
+
+export type Resource6List = Resource6[];
+
+export type Resource6Status = 'active' | 'archived' | 'pending';
+
+export interface Resource7 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource7Status;
+}
+
+export type Resource7List = Resource7[];
+
+export type Resource7Status = 'active' | 'archived' | 'pending';
+
+export interface Resource8 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource8Status;
+}
+
+export type Resource8List = Resource8[];
+
+export type Resource8Status = 'active' | 'archived' | 'pending';
+
+export interface Resource9 {
+ id: string;
+ name: string;
+ description?: string | null;
+ tags?: string[];
+ status?: Resource9Status;
+}
+
+export type Resource9List = Resource9[];
+
+export type Resource9Status = 'active' | 'archived' | 'pending';
diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource1.rest.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource1.rest.generated.ts
new file mode 100644
index 0000000..d5c702d
--- /dev/null
+++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource1.rest.generated.ts
@@ -0,0 +1,170 @@
+import { Injectable } from '@angular/core';
+import { httpParams, requestFactory } from '../rest.util';
+import type {
+ Resource1,
+ Resource1List,
+ Resource2,
+ Resource2List,
+ Resource3,
+ Resource3List,
+ Resource4,
+ Resource4List,
+ Resource5,
+ Resource5List,
+} from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class Resource1Rest {
+
+ readonly createResource1 = requestFactory(
+ (request: CreateResource1Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource1`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource2 = requestFactory(
+ (request: CreateResource2Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource2`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource3 = requestFactory(
+ (request: CreateResource3Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource3`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource4 = requestFactory(
+ (request: CreateResource4Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource4`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource5 = requestFactory(
+ (request: CreateResource5Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource5`,
+ body: body,
+ };
+ },
+ );
+
+ readonly getResource1 = requestFactory(
+ (request: GetResource1Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource1`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource2 = requestFactory(
+ (request: GetResource2Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource2`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource3 = requestFactory(
+ (request: GetResource3Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource3`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource4 = requestFactory(
+ (request: GetResource4Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource4`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource5 = requestFactory(
+ (request: GetResource5Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource5`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+}
+
+export interface CreateResource1Params {
+ body: Resource1;
+}
+
+export interface CreateResource2Params {
+ body: Resource2;
+}
+
+export interface CreateResource3Params {
+ body: Resource3;
+}
+
+export interface CreateResource4Params {
+ body: Resource4;
+}
+
+export interface CreateResource5Params {
+ body: Resource5;
+}
+
+export interface GetResource1Params {
+ limit?: number;
+}
+
+export interface GetResource2Params {
+ limit?: number;
+}
+
+export interface GetResource3Params {
+ limit?: number;
+}
+
+export interface GetResource4Params {
+ limit?: number;
+}
+
+export interface GetResource5Params {
+ limit?: number;
+}
diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource2.rest.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource2.rest.generated.ts
new file mode 100644
index 0000000..607c82b
--- /dev/null
+++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource2.rest.generated.ts
@@ -0,0 +1,170 @@
+import { Injectable } from '@angular/core';
+import { httpParams, requestFactory } from '../rest.util';
+import type {
+ Resource10,
+ Resource10List,
+ Resource6,
+ Resource6List,
+ Resource7,
+ Resource7List,
+ Resource8,
+ Resource8List,
+ Resource9,
+ Resource9List,
+} from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class Resource2Rest {
+
+ readonly createResource10 = requestFactory(
+ (request: CreateResource10Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource10`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource6 = requestFactory(
+ (request: CreateResource6Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource6`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource7 = requestFactory(
+ (request: CreateResource7Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource7`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource8 = requestFactory(
+ (request: CreateResource8Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource8`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource9 = requestFactory(
+ (request: CreateResource9Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource9`,
+ body: body,
+ };
+ },
+ );
+
+ readonly getResource10 = requestFactory(
+ (request: GetResource10Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource10`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource6 = requestFactory(
+ (request: GetResource6Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource6`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource7 = requestFactory(
+ (request: GetResource7Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource7`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource8 = requestFactory(
+ (request: GetResource8Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource8`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource9 = requestFactory(
+ (request: GetResource9Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource9`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+}
+
+export interface CreateResource10Params {
+ body: Resource10;
+}
+
+export interface CreateResource6Params {
+ body: Resource6;
+}
+
+export interface CreateResource7Params {
+ body: Resource7;
+}
+
+export interface CreateResource8Params {
+ body: Resource8;
+}
+
+export interface CreateResource9Params {
+ body: Resource9;
+}
+
+export interface GetResource10Params {
+ limit?: number;
+}
+
+export interface GetResource6Params {
+ limit?: number;
+}
+
+export interface GetResource7Params {
+ limit?: number;
+}
+
+export interface GetResource8Params {
+ limit?: number;
+}
+
+export interface GetResource9Params {
+ limit?: number;
+}
diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource3.rest.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource3.rest.generated.ts
new file mode 100644
index 0000000..d57096d
--- /dev/null
+++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource3.rest.generated.ts
@@ -0,0 +1,170 @@
+import { Injectable } from '@angular/core';
+import { httpParams, requestFactory } from '../rest.util';
+import type {
+ Resource11,
+ Resource11List,
+ Resource12,
+ Resource12List,
+ Resource13,
+ Resource13List,
+ Resource14,
+ Resource14List,
+ Resource15,
+ Resource15List,
+} from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class Resource3Rest {
+
+ readonly createResource11 = requestFactory(
+ (request: CreateResource11Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource11`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource12 = requestFactory(
+ (request: CreateResource12Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource12`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource13 = requestFactory(
+ (request: CreateResource13Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource13`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource14 = requestFactory(
+ (request: CreateResource14Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource14`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource15 = requestFactory(
+ (request: CreateResource15Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource15`,
+ body: body,
+ };
+ },
+ );
+
+ readonly getResource11 = requestFactory(
+ (request: GetResource11Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource11`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource12 = requestFactory(
+ (request: GetResource12Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource12`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource13 = requestFactory(
+ (request: GetResource13Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource13`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource14 = requestFactory(
+ (request: GetResource14Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource14`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource15 = requestFactory(
+ (request: GetResource15Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource15`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+}
+
+export interface CreateResource11Params {
+ body: Resource11;
+}
+
+export interface CreateResource12Params {
+ body: Resource12;
+}
+
+export interface CreateResource13Params {
+ body: Resource13;
+}
+
+export interface CreateResource14Params {
+ body: Resource14;
+}
+
+export interface CreateResource15Params {
+ body: Resource15;
+}
+
+export interface GetResource11Params {
+ limit?: number;
+}
+
+export interface GetResource12Params {
+ limit?: number;
+}
+
+export interface GetResource13Params {
+ limit?: number;
+}
+
+export interface GetResource14Params {
+ limit?: number;
+}
+
+export interface GetResource15Params {
+ limit?: number;
+}
diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource4.rest.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource4.rest.generated.ts
new file mode 100644
index 0000000..8d655f8
--- /dev/null
+++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource4.rest.generated.ts
@@ -0,0 +1,170 @@
+import { Injectable } from '@angular/core';
+import { httpParams, requestFactory } from '../rest.util';
+import type {
+ Resource16,
+ Resource16List,
+ Resource17,
+ Resource17List,
+ Resource18,
+ Resource18List,
+ Resource19,
+ Resource19List,
+ Resource20,
+ Resource20List,
+} from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class Resource4Rest {
+
+ readonly createResource16 = requestFactory(
+ (request: CreateResource16Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource16`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource17 = requestFactory(
+ (request: CreateResource17Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource17`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource18 = requestFactory(
+ (request: CreateResource18Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource18`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource19 = requestFactory(
+ (request: CreateResource19Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource19`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource20 = requestFactory(
+ (request: CreateResource20Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource20`,
+ body: body,
+ };
+ },
+ );
+
+ readonly getResource16 = requestFactory(
+ (request: GetResource16Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource16`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource17 = requestFactory(
+ (request: GetResource17Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource17`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource18 = requestFactory(
+ (request: GetResource18Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource18`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource19 = requestFactory(
+ (request: GetResource19Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource19`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource20 = requestFactory(
+ (request: GetResource20Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource20`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+}
+
+export interface CreateResource16Params {
+ body: Resource16;
+}
+
+export interface CreateResource17Params {
+ body: Resource17;
+}
+
+export interface CreateResource18Params {
+ body: Resource18;
+}
+
+export interface CreateResource19Params {
+ body: Resource19;
+}
+
+export interface CreateResource20Params {
+ body: Resource20;
+}
+
+export interface GetResource16Params {
+ limit?: number;
+}
+
+export interface GetResource17Params {
+ limit?: number;
+}
+
+export interface GetResource18Params {
+ limit?: number;
+}
+
+export interface GetResource19Params {
+ limit?: number;
+}
+
+export interface GetResource20Params {
+ limit?: number;
+}
diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource5.rest.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource5.rest.generated.ts
new file mode 100644
index 0000000..9af0b44
--- /dev/null
+++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource5.rest.generated.ts
@@ -0,0 +1,170 @@
+import { Injectable } from '@angular/core';
+import { httpParams, requestFactory } from '../rest.util';
+import type {
+ Resource21,
+ Resource21List,
+ Resource22,
+ Resource22List,
+ Resource23,
+ Resource23List,
+ Resource24,
+ Resource24List,
+ Resource25,
+ Resource25List,
+} from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class Resource5Rest {
+
+ readonly createResource21 = requestFactory(
+ (request: CreateResource21Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource21`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource22 = requestFactory(
+ (request: CreateResource22Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource22`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource23 = requestFactory(
+ (request: CreateResource23Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource23`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource24 = requestFactory(
+ (request: CreateResource24Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource24`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource25 = requestFactory(
+ (request: CreateResource25Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource25`,
+ body: body,
+ };
+ },
+ );
+
+ readonly getResource21 = requestFactory(
+ (request: GetResource21Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource21`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource22 = requestFactory(
+ (request: GetResource22Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource22`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource23 = requestFactory(
+ (request: GetResource23Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource23`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource24 = requestFactory(
+ (request: GetResource24Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource24`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource25 = requestFactory(
+ (request: GetResource25Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource25`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+}
+
+export interface CreateResource21Params {
+ body: Resource21;
+}
+
+export interface CreateResource22Params {
+ body: Resource22;
+}
+
+export interface CreateResource23Params {
+ body: Resource23;
+}
+
+export interface CreateResource24Params {
+ body: Resource24;
+}
+
+export interface CreateResource25Params {
+ body: Resource25;
+}
+
+export interface GetResource21Params {
+ limit?: number;
+}
+
+export interface GetResource22Params {
+ limit?: number;
+}
+
+export interface GetResource23Params {
+ limit?: number;
+}
+
+export interface GetResource24Params {
+ limit?: number;
+}
+
+export interface GetResource25Params {
+ limit?: number;
+}
diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource6.rest.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource6.rest.generated.ts
new file mode 100644
index 0000000..ea7eebe
--- /dev/null
+++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource6.rest.generated.ts
@@ -0,0 +1,170 @@
+import { Injectable } from '@angular/core';
+import { httpParams, requestFactory } from '../rest.util';
+import type {
+ Resource26,
+ Resource26List,
+ Resource27,
+ Resource27List,
+ Resource28,
+ Resource28List,
+ Resource29,
+ Resource29List,
+ Resource30,
+ Resource30List,
+} from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class Resource6Rest {
+
+ readonly createResource26 = requestFactory(
+ (request: CreateResource26Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource26`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource27 = requestFactory(
+ (request: CreateResource27Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource27`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource28 = requestFactory(
+ (request: CreateResource28Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource28`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource29 = requestFactory(
+ (request: CreateResource29Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource29`,
+ body: body,
+ };
+ },
+ );
+
+ readonly createResource30 = requestFactory(
+ (request: CreateResource30Params) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/resource30`,
+ body: body,
+ };
+ },
+ );
+
+ readonly getResource26 = requestFactory(
+ (request: GetResource26Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource26`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource27 = requestFactory(
+ (request: GetResource27Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource27`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource28 = requestFactory(
+ (request: GetResource28Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource28`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource29 = requestFactory(
+ (request: GetResource29Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource29`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+
+ readonly getResource30 = requestFactory(
+ (request: GetResource30Params) => {
+ const { limit } = request;
+ return {
+ method: 'GET',
+ url: `/resource30`,
+ params: httpParams({ limit }),
+ };
+ },
+ );
+}
+
+export interface CreateResource26Params {
+ body: Resource26;
+}
+
+export interface CreateResource27Params {
+ body: Resource27;
+}
+
+export interface CreateResource28Params {
+ body: Resource28;
+}
+
+export interface CreateResource29Params {
+ body: Resource29;
+}
+
+export interface CreateResource30Params {
+ body: Resource30;
+}
+
+export interface GetResource26Params {
+ limit?: number;
+}
+
+export interface GetResource27Params {
+ limit?: number;
+}
+
+export interface GetResource28Params {
+ limit?: number;
+}
+
+export interface GetResource29Params {
+ limit?: number;
+}
+
+export interface GetResource30Params {
+ limit?: number;
+}
diff --git a/__test__/snapshots/generate-native/body-content-type-xml.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-content-type-xml.openapi.yaml.failure.json
new file mode 100644
index 0000000..f4d86fb
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-content-type-xml.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_POLICY_VIOLATION",
+ "message": "requestBody for POST /x: unsupported content type \"application/xml\". Use application/json, multipart/form-data, or application/x-www-form-urlencoded.",
+ "path": "test/fixtures/body-content-type-xml.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/body-multi-content.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-multi-content.openapi.yaml.failure.json
new file mode 100644
index 0000000..1c6b87e
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-multi-content.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_POLICY_VIOLATION",
+ "message": "requestBody for POST /x must declare exactly one content type.",
+ "path": "test/fixtures/body-multi-content.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/body-multipart-composed-field.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-multipart-composed-field.openapi.yaml.failure.json
new file mode 100644
index 0000000..ac096ca
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-multipart-composed-field.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_POLICY_VIOLATION",
+ "message": "body field 'variant' in POST /x: composed schemas are not supported in multipart bodies.",
+ "path": "test/fixtures/body-multipart-composed-field.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml.success.json b/__test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml.success.json
new file mode 100644
index 0000000..70716dd
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml.success.json
@@ -0,0 +1,37 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/body-multipart-mixed-fields.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Multipart Mixed",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 0
+ },
+ "diagnostics": [
+ {
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "severity": "warning",
+ "message": "requestBody for POST /pets/{petId}/avatar.avatar declares format 'binary', which is currently dropped — the generator emits the base type without format-specific narrowing.",
+ "path": "test/fixtures/body-multipart-mixed-fields.openapi.yaml",
+ "subcode": "format-dropped"
+ },
+ {
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "severity": "warning",
+ "message": "requestBody for POST /pets/{petId}/avatar.galleries declares format 'binary', which is currently dropped — the generator emits the base type without format-specific narrowing.",
+ "path": "test/fixtures/body-multipart-mixed-fields.openapi.yaml",
+ "subcode": "format-dropped"
+ }
+ ],
+ "artifacts": [
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..64b29b3
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml/rest/pet.rest.generated.ts
@@ -0,0 +1,38 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ readonly updatePetAvatar = requestFactory(
+ (request: UpdatePetAvatarParams) => {
+ const { petId, avatar, galleries, nickname, status, tagIds } = request;
+ return {
+ method: 'POST',
+ url: `/pets/${encodeURIComponent(petId)}/avatar`,
+ body: ((): FormData => {
+ const fd = new FormData();
+ fd.append('avatar', avatar);
+ for (const v of galleries) fd.append('galleries', v);
+ if (nickname !== undefined) fd.append('nickname', String(nickname));
+ fd.append('status', String(status));
+ if (tagIds !== undefined) for (const v of tagIds) fd.append('tagIds', String(v));
+ return fd;
+ })(),
+ };
+ },
+ );
+}
+
+export interface UpdatePetAvatarParams {
+ petId: string;
+ avatar: Blob | File;
+ galleries: (Blob | File)[];
+ nickname?: string;
+ status: string;
+ tagIds?: number[];
+}
diff --git a/__test__/snapshots/generate-native/body-multipart-nested-object.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-multipart-nested-object.openapi.yaml.failure.json
new file mode 100644
index 0000000..39e9fc9
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-multipart-nested-object.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_POLICY_VIOLATION",
+ "message": "body field 'metadata' in POST /x: nested objects are not supported in multipart bodies.",
+ "path": "test/fixtures/body-multipart-nested-object.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/body-multipart-non-object.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-multipart-non-object.openapi.yaml.failure.json
new file mode 100644
index 0000000..44dfb52
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-multipart-non-object.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_POLICY_VIOLATION",
+ "message": "requestBody for POST /x: multipart body schema must resolve to an object.",
+ "path": "test/fixtures/body-multipart-non-object.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/body-multipart-open-schema.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-multipart-open-schema.openapi.yaml.failure.json
new file mode 100644
index 0000000..ed75af4
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-multipart-open-schema.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_POLICY_VIOLATION",
+ "message": "requestBody for POST /x: multipart bodies must not declare additionalProperties; every field must be enumerated.",
+ "path": "test/fixtures/body-multipart-open-schema.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml.success.json b/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml.success.json
new file mode 100644
index 0000000..b3e7f86
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml.success.json
@@ -0,0 +1,25 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/body-multipart-ref-to-named-object.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Multipart Ref",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 1
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/upload.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..86fa774
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/model.generated.ts
@@ -0,0 +1,4 @@
+export interface UploadForm {
+ title: string;
+ description?: string;
+}
diff --git a/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/rest/upload.rest.generated.ts b/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/rest/upload.rest.generated.ts
new file mode 100644
index 0000000..1f1c5d3
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/rest/upload.rest.generated.ts
@@ -0,0 +1,31 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class UploadRest {
+
+ readonly createUpload = requestFactory(
+ (request: CreateUploadParams) => {
+ const { description, title } = request;
+ return {
+ method: 'POST',
+ url: `/uploads`,
+ body: ((): FormData => {
+ const fd = new FormData();
+ if (description !== undefined) fd.append('description', String(description));
+ fd.append('title', String(title));
+ return fd;
+ })(),
+ };
+ },
+ );
+}
+
+export interface CreateUploadParams {
+ description?: string;
+ title: string;
+}
diff --git a/__test__/snapshots/generate-native/body-urlencoded-binary-field.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-urlencoded-binary-field.openapi.yaml.failure.json
new file mode 100644
index 0000000..1654bb7
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-urlencoded-binary-field.openapi.yaml.failure.json
@@ -0,0 +1,14 @@
+{
+ "code": "E_POLICY_VIOLATION",
+ "message": "body field 'avatar' in POST /x: binary fields are not supported in application/x-www-form-urlencoded.",
+ "path": "test/fixtures/body-urlencoded-binary-field.openapi.yaml",
+ "warnings": [
+ {
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "severity": "warning",
+ "message": "requestBody for POST /x.avatar declares format 'binary', which is currently dropped — the generator emits the base type without format-specific narrowing.",
+ "path": "test/fixtures/body-urlencoded-binary-field.openapi.yaml",
+ "subcode": "format-dropped"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/body-urlencoded-nested-object.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-urlencoded-nested-object.openapi.yaml.failure.json
new file mode 100644
index 0000000..95f4942
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-urlencoded-nested-object.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_POLICY_VIOLATION",
+ "message": "body field 'metadata' in POST /x: nested objects are not supported in urlencoded bodies.",
+ "path": "test/fixtures/body-urlencoded-nested-object.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml.success.json b/__test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml.success.json
new file mode 100644
index 0000000..97b482b
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/body-urlencoded-scalar-and-array.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Urlencoded",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 0
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/search.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml/rest/search.rest.generated.ts b/__test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml/rest/search.rest.generated.ts
new file mode 100644
index 0000000..3023fd8
--- /dev/null
+++ b/__test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml/rest/search.rest.generated.ts
@@ -0,0 +1,31 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class SearchRest {
+
+ readonly submitSearch = requestFactory(
+ (request: SubmitSearchParams) => {
+ const { query, tagIds } = request;
+ return {
+ method: 'POST',
+ url: `/search`,
+ body: ((): URLSearchParams => {
+ const params = new URLSearchParams();
+ params.append('query', String(query));
+ if (tagIds !== undefined) for (const v of tagIds) params.append('tagIds', String(v));
+ return params;
+ })(),
+ };
+ },
+ );
+}
+
+export interface SubmitSearchParams {
+ query: string;
+ tagIds?: number[];
+}
diff --git a/__test__/snapshots/generate-native/circular-allof.openapi.yaml.success.json b/__test__/snapshots/generate-native/circular-allof.openapi.yaml.success.json
new file mode 100644
index 0000000..2a58ffb
--- /dev/null
+++ b/__test__/snapshots/generate-native/circular-allof.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/circular-allof.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Circular AllOf (Bottoms Out)",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 6
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/circular-allof.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/circular-allof.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..0aa8f22
--- /dev/null
+++ b/__test__/snapshots/generate-native/circular-allof.openapi.yaml/model.generated.ts
@@ -0,0 +1,24 @@
+export interface BaseAuditFields {
+ createdAt: string;
+ updatedAt?: string | null;
+}
+
+export type DeepRecord = Layer4 & {
+ tier5?: string;
+};
+
+export type Layer1 = BaseAuditFields & {
+ tier1?: string;
+};
+
+export type Layer2 = Layer1 & {
+ tier2?: string;
+};
+
+export type Layer3 = Layer2 & {
+ tier3?: string;
+};
+
+export type Layer4 = Layer3 & {
+ tier4?: string;
+};
diff --git a/__test__/snapshots/generate-native/deep-nested-allof.openapi.yaml.failure.json b/__test__/snapshots/generate-native/deep-nested-allof.openapi.yaml.failure.json
new file mode 100644
index 0000000..42c9f4c
--- /dev/null
+++ b/__test__/snapshots/generate-native/deep-nested-allof.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "message": "Unsupported OpenAPI semantic shape: schema Deep composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 nesting exceeds 32 levels (likely cyclic or pathological spec).",
+ "path": "test/fixtures/deep-nested-allof.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml.success.json b/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml.success.json
new file mode 100644
index 0000000..f7fb2b2
--- /dev/null
+++ b/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml.success.json
@@ -0,0 +1,25 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/deprecated-fields.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Deprecated Fields",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 2
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..dbba123
--- /dev/null
+++ b/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml/model.generated.ts
@@ -0,0 +1,15 @@
+/**
+ * Adoption state, legacy spelling.
+ * @deprecated
+ */
+export type LegacyPetStatus = 'available' | 'sold';
+
+export interface Pet {
+ id: string;
+ /**
+ * Legacy numeric tag id; use `tagIds` instead.
+ * @deprecated
+ */
+ legacyTagId?: number;
+ tagIds?: number[];
+}
diff --git a/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..6b1e9d6
--- /dev/null
+++ b/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml/rest/pet.rest.generated.ts
@@ -0,0 +1,29 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+import type { Pet } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ /**
+ * Retrieve a single pet
+ *
+ * Use `getPetById` instead — this endpoint will be removed.
+ * @deprecated
+ */
+ readonly getPet = requestFactory(
+ (request: GetPetParams) => {
+ const { petId } = request;
+ return {
+ method: 'GET',
+ url: `/pets/${encodeURIComponent(petId)}`,
+ };
+ },
+ );
+}
+
+export interface GetPetParams {
+ petId: string;
+}
diff --git a/__test__/snapshots/generate-native/discriminated-union.openapi.yaml.success.json b/__test__/snapshots/generate-native/discriminated-union.openapi.yaml.success.json
new file mode 100644
index 0000000..6af806a
--- /dev/null
+++ b/__test__/snapshots/generate-native/discriminated-union.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/discriminated-union.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Discriminated Union",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 3
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/discriminated-union.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/discriminated-union.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..49ed92a
--- /dev/null
+++ b/__test__/snapshots/generate-native/discriminated-union.openapi.yaml/model.generated.ts
@@ -0,0 +1,11 @@
+export interface Cat {
+ kind: 'cat';
+ lives: number;
+}
+
+export interface Dog {
+ kind: 'dog';
+ breed: string;
+}
+
+export type PetUnion = Cat | Dog;
diff --git a/__test__/snapshots/generate-native/discriminator-allof.openapi.yaml.success.json b/__test__/snapshots/generate-native/discriminator-allof.openapi.yaml.success.json
new file mode 100644
index 0000000..131f05d
--- /dev/null
+++ b/__test__/snapshots/generate-native/discriminator-allof.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/discriminator-allof.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "discriminator-allof",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 4
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/discriminator-allof.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/discriminator-allof.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..77a2faf
--- /dev/null
+++ b/__test__/snapshots/generate-native/discriminator-allof.openapi.yaml/model.generated.ts
@@ -0,0 +1,15 @@
+export interface Animal {
+ name: string;
+}
+
+export type Cat = Animal & {
+ kind: 'cat';
+ whiskers?: number;
+};
+
+export type Dog = Animal & {
+ kind: 'dog';
+ barkLoudness?: string;
+};
+
+export type Pet = Cat | Dog;
diff --git a/__test__/snapshots/generate-native/discriminator-mapping-external-ref.openapi.yaml.failure.json b/__test__/snapshots/generate-native/discriminator-mapping-external-ref.openapi.yaml.failure.json
new file mode 100644
index 0000000..75c1640
--- /dev/null
+++ b/__test__/snapshots/generate-native/discriminator-mapping-external-ref.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "message": "Unsupported OpenAPI semantic shape: schema Pet uses unsupported reference http://example.com/schemas/Cat.. See the supported subset documented in README.md ('Out of Scope' section).",
+ "path": "test/fixtures/discriminator-mapping-external-ref.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/discriminator-mapping.openapi.yaml.success.json b/__test__/snapshots/generate-native/discriminator-mapping.openapi.yaml.success.json
new file mode 100644
index 0000000..2cafe00
--- /dev/null
+++ b/__test__/snapshots/generate-native/discriminator-mapping.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/discriminator-mapping.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "discriminator-mapping",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 3
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/discriminator-mapping.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/discriminator-mapping.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..03dfbf5
--- /dev/null
+++ b/__test__/snapshots/generate-native/discriminator-mapping.openapi.yaml/model.generated.ts
@@ -0,0 +1,9 @@
+export interface Cat {
+ kind: 'feline';
+}
+
+export interface Dog {
+ kind: 'canine';
+}
+
+export type Pet = Cat | Dog;
diff --git a/__test__/snapshots/generate-native/discriminator-missing-property.openapi.yaml.failure.json b/__test__/snapshots/generate-native/discriminator-missing-property.openapi.yaml.failure.json
new file mode 100644
index 0000000..e33b6fa
--- /dev/null
+++ b/__test__/snapshots/generate-native/discriminator-missing-property.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_POLICY_VIOLATION",
+ "message": "Failed to validate spec: oneOf member 'Cat' does not declare the discriminator property 'kind'. Add the property to the member schema (typically as `type: string`) or remove the discriminator.",
+ "path": "test/fixtures/discriminator-missing-property.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/empty-parameter.openapi.yaml.failure.json b/__test__/snapshots/generate-native/empty-parameter.openapi.yaml.failure.json
new file mode 100644
index 0000000..5ebffcd
--- /dev/null
+++ b/__test__/snapshots/generate-native/empty-parameter.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "message": "Unsupported OpenAPI semantic shape: parameter filter for GET /pets uses an empty schema, which is outside the supported subset.. See the supported subset documented in README.md ('Out of Scope' section).",
+ "path": "test/fixtures/empty-parameter.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/empty-shapes.openapi.yaml.success.json b/__test__/snapshots/generate-native/empty-shapes.openapi.yaml.success.json
new file mode 100644
index 0000000..b02bc14
--- /dev/null
+++ b/__test__/snapshots/generate-native/empty-shapes.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/empty-shapes.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Empty Schema Shapes",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 4
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/empty-shapes.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/empty-shapes.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..54250ed
--- /dev/null
+++ b/__test__/snapshots/generate-native/empty-shapes.openapi.yaml/model.generated.ts
@@ -0,0 +1,13 @@
+export type AnyValue = unknown;
+
+export type EmptyObject = Record;
+
+export type EmptyObjectWithProperties = Record;
+
+export interface ShapeContainer {
+ anything: unknown;
+ emptyInline: Record;
+ emptyInlineWithProperties?: Record;
+ emptyArray: unknown[];
+ emptyMap: Record;
+}
diff --git a/__test__/snapshots/generate-native/external-ref.openapi.yaml.failure.json b/__test__/snapshots/generate-native/external-ref.openapi.yaml.failure.json
new file mode 100644
index 0000000..09d4353
--- /dev/null
+++ b/__test__/snapshots/generate-native/external-ref.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "message": "Unsupported OpenAPI semantic shape: schema Pet.owner uses unsupported reference shared.yaml#/components/schemas/Owner.. See the supported subset documented in README.md ('Out of Scope' section).",
+ "path": "test/fixtures/external-ref.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/field-collision.openapi.yaml.failure.json b/__test__/snapshots/generate-native/field-collision.openapi.yaml.failure.json
new file mode 100644
index 0000000..b5c073c
--- /dev/null
+++ b/__test__/snapshots/generate-native/field-collision.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_POLICY_VIOLATION",
+ "message": "operationId 'createUser': body fields [id] duplicate path/query parameter names. Rename the colliding fields in the OpenAPI spec, or hoist the body schema to a named `$ref` so it nests under `body`.",
+ "path": "test/fixtures/field-collision.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/header-param.openapi.yaml.success.json b/__test__/snapshots/generate-native/header-param.openapi.yaml.success.json
new file mode 100644
index 0000000..9ca916a
--- /dev/null
+++ b/__test__/snapshots/generate-native/header-param.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/header-param.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Header Param",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 0
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/header-param.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/header-param.openapi.yaml/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..873b213
--- /dev/null
+++ b/__test__/snapshots/generate-native/header-param.openapi.yaml/rest/pet.rest.generated.ts
@@ -0,0 +1,25 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ readonly listPets = requestFactory(
+ (request: ListPetsParams) => {
+ const { headers } = request;
+ return {
+ method: 'GET',
+ url: `/pets`,
+ headers,
+ };
+ },
+ );
+}
+
+export interface ListPetsParams {
+ headers: {
+ 'X-Api-Key': string;
+ };
+}
diff --git a/__test__/snapshots/generate-native/inline-model.openapi.yaml.success.json b/__test__/snapshots/generate-native/inline-model.openapi.yaml.success.json
new file mode 100644
index 0000000..d316b7e
--- /dev/null
+++ b/__test__/snapshots/generate-native/inline-model.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/inline-model.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Inline Model Schemas",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 1
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/inline-model.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/inline-model.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..e13afa5
--- /dev/null
+++ b/__test__/snapshots/generate-native/inline-model.openapi.yaml/model.generated.ts
@@ -0,0 +1,17 @@
+export interface PetProfile {
+ id: string;
+ details: {
+ displayName: string;
+ address: {
+ city: string;
+ postalCode?: string | null;
+ };
+ };
+ labelsByLocale: Record;
+ visits: {
+ visitedAt: string;
+ notes?: string | null;
+ }[];
+}
diff --git a/__test__/snapshots/generate-native/inline-parameter.openapi.yaml.failure.json b/__test__/snapshots/generate-native/inline-parameter.openapi.yaml.failure.json
new file mode 100644
index 0000000..a5ea18d
--- /dev/null
+++ b/__test__/snapshots/generate-native/inline-parameter.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "message": "Unsupported OpenAPI semantic shape: parameter filter for GET /pets uses an inline object schema, which is outside the supported subset.. See the supported subset documented in README.md ('Out of Scope' section).",
+ "path": "test/fixtures/inline-parameter.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/invalid-enum-type.openapi.yaml.failure.json b/__test__/snapshots/generate-native/invalid-enum-type.openapi.yaml.failure.json
new file mode 100644
index 0000000..ac2e6a2
--- /dev/null
+++ b/__test__/snapshots/generate-native/invalid-enum-type.openapi.yaml.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "message": "Unsupported OpenAPI semantic shape: schema InvalidEnum enum is supported only for string schemas, found type integer.. See the supported subset documented in README.md ('Out of Scope' section).",
+ "path": "test/fixtures/invalid-enum-type.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/invalid-enum-value.openapi.json.failure.json b/__test__/snapshots/generate-native/invalid-enum-value.openapi.json.failure.json
new file mode 100644
index 0000000..ff5a80e
--- /dev/null
+++ b/__test__/snapshots/generate-native/invalid-enum-value.openapi.json.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "message": "Unsupported OpenAPI semantic shape: schema InvalidEnum enum values must not contain null bytes.. See the supported subset documented in README.md ('Out of Scope' section).",
+ "path": "test/fixtures/invalid-enum-value.openapi.json",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml.success.json b/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml.success.json
new file mode 100644
index 0000000..feed96f
--- /dev/null
+++ b/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml.success.json
@@ -0,0 +1,25 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/jsdoc-descriptions.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "JSDoc Descriptions",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 2
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..dd0211f
--- /dev/null
+++ b/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/model.generated.ts
@@ -0,0 +1,19 @@
+/**
+ * A pet that is up for adoption.
+ */
+export interface Pet {
+ /**
+ * Stable identifier across renames.
+ */
+ id: string;
+ status: PetStatus;
+ /**
+ * Optional informal name used in marketing copy.
+ */
+ nickname?: string;
+}
+
+/**
+ * Adoption state of the pet.
+ */
+export type PetStatus = 'available' | 'pending' | 'adopted';
diff --git a/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..187dd8d
--- /dev/null
+++ b/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/rest/pet.rest.generated.ts
@@ -0,0 +1,29 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+import type { Pet } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ /**
+ * Retrieve a single pet
+ *
+ * Returns the full pet record by id. The returned shape
+ * matches `Pet` exactly — no partial fetches.
+ */
+ readonly getPet = requestFactory(
+ (request: GetPetParams) => {
+ const { petId } = request;
+ return {
+ method: 'GET',
+ url: `/pets/${encodeURIComponent(petId)}`,
+ };
+ },
+ );
+}
+
+export interface GetPetParams {
+ petId: string;
+}
diff --git a/__test__/snapshots/generate-native/large-enum.openapi.yaml.success.json b/__test__/snapshots/generate-native/large-enum.openapi.yaml.success.json
new file mode 100644
index 0000000..508b100
--- /dev/null
+++ b/__test__/snapshots/generate-native/large-enum.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/large-enum.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Large Enum",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 2
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/large-enum.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/large-enum.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..88ad02a
--- /dev/null
+++ b/__test__/snapshots/generate-native/large-enum.openapi.yaml/model.generated.ts
@@ -0,0 +1,56 @@
+export interface AddressBook {
+ state: UsState;
+ compactStatus?: 'active' | 'sealed';
+}
+
+export type UsState =
+ | 'alabama'
+ | 'alaska'
+ | 'arizona'
+ | 'arkansas'
+ | 'california'
+ | 'colorado'
+ | 'connecticut'
+ | 'delaware'
+ | 'florida'
+ | 'georgia'
+ | 'hawaii'
+ | 'idaho'
+ | 'illinois'
+ | 'indiana'
+ | 'iowa'
+ | 'kansas'
+ | 'kentucky'
+ | 'louisiana'
+ | 'maine'
+ | 'maryland'
+ | 'massachusetts'
+ | 'michigan'
+ | 'minnesota'
+ | 'mississippi'
+ | 'missouri'
+ | 'montana'
+ | 'nebraska'
+ | 'nevada'
+ | 'new-hampshire'
+ | 'new-jersey'
+ | 'new-mexico'
+ | 'new-york'
+ | 'north-carolina'
+ | 'north-dakota'
+ | 'ohio'
+ | 'oklahoma'
+ | 'oregon'
+ | 'pennsylvania'
+ | 'rhode-island'
+ | 'south-carolina'
+ | 'south-dakota'
+ | 'tennessee'
+ | 'texas'
+ | 'utah'
+ | 'vermont'
+ | 'virginia'
+ | 'washington'
+ | 'west-virginia'
+ | 'wisconsin'
+ | 'wyoming';
diff --git a/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml.success.json b/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml.success.json
new file mode 100644
index 0000000..851e022
--- /dev/null
+++ b/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml.success.json
@@ -0,0 +1,25 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/multi-tag-operation.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Multi Tag Operation",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 2
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..30d78df
--- /dev/null
+++ b/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/model.generated.ts
@@ -0,0 +1,5 @@
+export interface Pet {
+ id: string;
+}
+
+export type PetList = Pet[];
diff --git a/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..d851027
--- /dev/null
+++ b/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/rest/pet.rest.generated.ts
@@ -0,0 +1,16 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+import type { PetList } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ readonly listPetsAndShelters = requestFactory.zeroArg(
+ () => ({
+ method: 'GET',
+ url: `/pets`,
+ }),
+ );
+}
diff --git a/__test__/snapshots/generate-native/multi-warning.openapi.yaml.success.json b/__test__/snapshots/generate-native/multi-warning.openapi.yaml.success.json
new file mode 100644
index 0000000..a5ed995
--- /dev/null
+++ b/__test__/snapshots/generate-native/multi-warning.openapi.yaml.success.json
@@ -0,0 +1,40 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/multi-warning.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Multi Warning",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 1
+ },
+ "diagnostics": [
+ {
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "severity": "warning",
+ "message": "operationId 'listPets': parameter 'sessionId' uses location 'cookie', which is not supported in the generated service contract and will be omitted.",
+ "path": "test/fixtures/multi-warning.openapi.yaml",
+ "subcode": "unsupported-parameter-location"
+ },
+ {
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "severity": "warning",
+ "message": "operationId 'listPets': parameter 'trackingId' uses location 'cookie', which is not supported in the generated service contract and will be omitted.",
+ "path": "test/fixtures/multi-warning.openapi.yaml",
+ "subcode": "unsupported-parameter-location"
+ }
+ ],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/multi-warning.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/multi-warning.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..d9dd182
--- /dev/null
+++ b/__test__/snapshots/generate-native/multi-warning.openapi.yaml/model.generated.ts
@@ -0,0 +1,3 @@
+export interface Pet {
+ id: string;
+}
diff --git a/__test__/snapshots/generate-native/multi-warning.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/multi-warning.openapi.yaml/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..81ee94e
--- /dev/null
+++ b/__test__/snapshots/generate-native/multi-warning.openapi.yaml/rest/pet.rest.generated.ts
@@ -0,0 +1,16 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+import type { Pet } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ readonly listPets = requestFactory.zeroArg(
+ () => ({
+ method: 'GET',
+ url: `/pets`,
+ }),
+ );
+}
diff --git a/__test__/snapshots/generate-native/nullable-oneof.openapi.yaml.success.json b/__test__/snapshots/generate-native/nullable-oneof.openapi.yaml.success.json
new file mode 100644
index 0000000..b3cd40b
--- /dev/null
+++ b/__test__/snapshots/generate-native/nullable-oneof.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/nullable-oneof.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Nullable OneOf",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 4
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/nullable-oneof.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/nullable-oneof.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..38cee94
--- /dev/null
+++ b/__test__/snapshots/generate-native/nullable-oneof.openapi.yaml/model.generated.ts
@@ -0,0 +1,16 @@
+export interface Cat {
+ kind: 'cat';
+ whiskers?: number;
+}
+
+export interface Dog {
+ kind: 'dog';
+ breed?: string;
+}
+
+export type Pet = Cat | Dog | null;
+
+export interface Profile {
+ id: string;
+ favoritePet?: Cat | Dog | null;
+}
diff --git a/__test__/snapshots/generate-native/nullable-optional.openapi.yaml.success.json b/__test__/snapshots/generate-native/nullable-optional.openapi.yaml.success.json
new file mode 100644
index 0000000..526dc4d
--- /dev/null
+++ b/__test__/snapshots/generate-native/nullable-optional.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/nullable-optional.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Nullable optional fixture",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 1
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/nullable-optional.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/nullable-optional.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..b381ffd
--- /dev/null
+++ b/__test__/snapshots/generate-native/nullable-optional.openapi.yaml/model.generated.ts
@@ -0,0 +1,7 @@
+export interface Profile {
+ id: string;
+ displayName: string;
+ nickname?: string | null;
+ bio?: string;
+ legalName?: string | null;
+}
diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json.success.json b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json.success.json
new file mode 100644
index 0000000..ccc72df
--- /dev/null
+++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json.success.json
@@ -0,0 +1,36 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/oneof-anyof-composition.openapi.json",
+ "specVersion": "3.0.3",
+ "title": "OneOf AnyOf Composition",
+ "pathCount": 2,
+ "operationCount": 2,
+ "schemaCount": 8
+ },
+ "diagnostics": [
+ {
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "severity": "warning",
+ "message": "schema ContactEmail.email declares format 'email', which is currently dropped — the generator emits the base type without format-specific narrowing.",
+ "path": "test/fixtures/oneof-anyof-composition.openapi.json",
+ "subcode": "format-dropped"
+ }
+ ],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/adoption-request.rest.generated.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/model.generated.ts b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/model.generated.ts
new file mode 100644
index 0000000..76b2b27
--- /dev/null
+++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/model.generated.ts
@@ -0,0 +1,35 @@
+export interface AdoptionDecision {
+ approved: boolean;
+ matchedPet?: PetUnion;
+ reviewerNote?: string | null;
+}
+
+export interface AdoptionRequest {
+ applicantName: string;
+ preferredPet?: PetUnion;
+ contact: ContactEmail | ContactPhone;
+ notes?: string;
+ referralCode?: string | null;
+}
+
+export interface Cat {
+ id: string;
+ lives: number;
+}
+
+export interface ContactEmail {
+ email: string;
+}
+
+export interface ContactPhone {
+ phone: string;
+}
+
+export interface Dog {
+ id: string;
+ breed: string;
+}
+
+export type PetUnion = Cat | Dog;
+
+export type PetUnionList = PetUnion[];
diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/adoption-request.rest.generated.ts b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/adoption-request.rest.generated.ts
new file mode 100644
index 0000000..aff3c12
--- /dev/null
+++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/adoption-request.rest.generated.ts
@@ -0,0 +1,24 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+import type { AdoptionDecision, AdoptionRequest } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class AdoptionRequestRest {
+
+ readonly createAdoptionRequest = requestFactory(
+ (request: CreateAdoptionRequestParams) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/adoption-request`,
+ body: body,
+ };
+ },
+ );
+}
+
+export interface CreateAdoptionRequestParams {
+ body: AdoptionRequest;
+}
diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..65e57e6
--- /dev/null
+++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/pet.rest.generated.ts
@@ -0,0 +1,16 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+import type { PetUnionList } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ readonly listUnionPets = requestFactory.zeroArg(
+ () => ({
+ method: 'GET',
+ url: `/union-pets`,
+ }),
+ );
+}
diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml.success.json b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml.success.json
new file mode 100644
index 0000000..8321830
--- /dev/null
+++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml.success.json
@@ -0,0 +1,36 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/oneof-anyof-composition.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "OneOf AnyOf Composition",
+ "pathCount": 2,
+ "operationCount": 2,
+ "schemaCount": 8
+ },
+ "diagnostics": [
+ {
+ "code": "E_UNSUPPORTED_SEMANTIC",
+ "severity": "warning",
+ "message": "schema ContactEmail.email declares format 'email', which is currently dropped — the generator emits the base type without format-specific narrowing.",
+ "path": "test/fixtures/oneof-anyof-composition.openapi.yaml",
+ "subcode": "format-dropped"
+ }
+ ],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/adoption-request.rest.generated.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..76b2b27
--- /dev/null
+++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/model.generated.ts
@@ -0,0 +1,35 @@
+export interface AdoptionDecision {
+ approved: boolean;
+ matchedPet?: PetUnion;
+ reviewerNote?: string | null;
+}
+
+export interface AdoptionRequest {
+ applicantName: string;
+ preferredPet?: PetUnion;
+ contact: ContactEmail | ContactPhone;
+ notes?: string;
+ referralCode?: string | null;
+}
+
+export interface Cat {
+ id: string;
+ lives: number;
+}
+
+export interface ContactEmail {
+ email: string;
+}
+
+export interface ContactPhone {
+ phone: string;
+}
+
+export interface Dog {
+ id: string;
+ breed: string;
+}
+
+export type PetUnion = Cat | Dog;
+
+export type PetUnionList = PetUnion[];
diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/adoption-request.rest.generated.ts b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/adoption-request.rest.generated.ts
new file mode 100644
index 0000000..aff3c12
--- /dev/null
+++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/adoption-request.rest.generated.ts
@@ -0,0 +1,24 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+import type { AdoptionDecision, AdoptionRequest } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class AdoptionRequestRest {
+
+ readonly createAdoptionRequest = requestFactory(
+ (request: CreateAdoptionRequestParams) => {
+ const { body } = request;
+ return {
+ method: 'POST',
+ url: `/adoption-request`,
+ body: body,
+ };
+ },
+ );
+}
+
+export interface CreateAdoptionRequestParams {
+ body: AdoptionRequest;
+}
diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..65e57e6
--- /dev/null
+++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/pet.rest.generated.ts
@@ -0,0 +1,16 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+import type { PetUnionList } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ readonly listUnionPets = requestFactory.zeroArg(
+ () => ({
+ method: 'GET',
+ url: `/union-pets`,
+ }),
+ );
+}
diff --git a/__test__/snapshots/generate-native/petstore-minimal.openapi.json.success.json b/__test__/snapshots/generate-native/petstore-minimal.openapi.json.success.json
new file mode 100644
index 0000000..48256bd
--- /dev/null
+++ b/__test__/snapshots/generate-native/petstore-minimal.openapi.json.success.json
@@ -0,0 +1,25 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/petstore-minimal.openapi.json",
+ "specVersion": "3.0.3",
+ "title": "Petstore Minimal",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 1
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/petstore-minimal.openapi.json/model.generated.ts b/__test__/snapshots/generate-native/petstore-minimal.openapi.json/model.generated.ts
new file mode 100644
index 0000000..de123f0
--- /dev/null
+++ b/__test__/snapshots/generate-native/petstore-minimal.openapi.json/model.generated.ts
@@ -0,0 +1 @@
+export type Pet = Record;
diff --git a/__test__/snapshots/generate-native/petstore-minimal.openapi.json/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/petstore-minimal.openapi.json/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..1f999d4
--- /dev/null
+++ b/__test__/snapshots/generate-native/petstore-minimal.openapi.json/rest/pet.rest.generated.ts
@@ -0,0 +1,15 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ readonly listPets = requestFactory.zeroArg(
+ () => ({
+ method: 'GET',
+ url: `/pets`,
+ }),
+ );
+}
diff --git a/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml.success.json b/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml.success.json
new file mode 100644
index 0000000..1ab70c5
--- /dev/null
+++ b/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml.success.json
@@ -0,0 +1,25 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/petstore-minimal.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Petstore Minimal",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 1
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..de123f0
--- /dev/null
+++ b/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml/model.generated.ts
@@ -0,0 +1 @@
+export type Pet = Record;
diff --git a/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..1f999d4
--- /dev/null
+++ b/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml/rest/pet.rest.generated.ts
@@ -0,0 +1,15 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ readonly listPets = requestFactory.zeroArg(
+ () => ({
+ method: 'GET',
+ url: `/pets`,
+ }),
+ );
+}
diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.json.success.json b/__test__/snapshots/generate-native/petstore-rich.openapi.json.success.json
new file mode 100644
index 0000000..7228b16
--- /dev/null
+++ b/__test__/snapshots/generate-native/petstore-rich.openapi.json.success.json
@@ -0,0 +1,25 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/petstore-rich.openapi.json",
+ "specVersion": "3.0.3",
+ "title": "Petstore Rich",
+ "pathCount": 2,
+ "operationCount": 3,
+ "schemaCount": 6
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.json/model.generated.ts b/__test__/snapshots/generate-native/petstore-rich.openapi.json/model.generated.ts
new file mode 100644
index 0000000..ce16b3c
--- /dev/null
+++ b/__test__/snapshots/generate-native/petstore-rich.openapi.json/model.generated.ts
@@ -0,0 +1,24 @@
+export interface Pet {
+ id: PetId;
+ name: string;
+ status: PetStatus;
+ tags: Tag[];
+ nickname?: string | null;
+}
+
+export type PetId = string;
+
+export type PetList = Pet[];
+
+export type PetStatus = 'available' | 'pending' | 'sold';
+
+export interface Tag {
+ id: number;
+ label: string;
+}
+
+export interface UpdatePetRequest {
+ status: PetStatus;
+ tagIds: number[];
+ nickname?: string | null;
+}
diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.json/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/petstore-rich.openapi.json/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..734f184
--- /dev/null
+++ b/__test__/snapshots/generate-native/petstore-rich.openapi.json/rest/pet.rest.generated.ts
@@ -0,0 +1,48 @@
+import { Injectable } from '@angular/core';
+import { httpParams, requestFactory } from '../rest.util';
+import type { Pet, PetId, PetList, UpdatePetRequest } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ readonly getPet = requestFactory(
+ (request: GetPetParams) => {
+ const { petId } = request;
+ return {
+ method: 'GET',
+ url: `/pets/${encodeURIComponent(petId)}`,
+ };
+ },
+ );
+
+ readonly listPets = requestFactory.zeroArg(
+ () => ({
+ method: 'GET',
+ url: `/pets`,
+ }),
+ );
+
+ readonly updatePet = requestFactory(
+ (request: UpdatePetParams) => {
+ const { petId, includeHistory, body } = request;
+ return {
+ method: 'POST',
+ url: `/pets/${encodeURIComponent(petId)}`,
+ params: httpParams({ includeHistory }),
+ body: body,
+ };
+ },
+ );
+}
+
+export interface GetPetParams {
+ petId: PetId;
+}
+
+export interface UpdatePetParams {
+ petId: PetId;
+ includeHistory?: boolean;
+ body: UpdatePetRequest;
+}
diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.yaml.invalid-mapped-type.failure.json b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml.invalid-mapped-type.failure.json
new file mode 100644
index 0000000..ddbd49d
--- /dev/null
+++ b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml.invalid-mapped-type.failure.json
@@ -0,0 +1,6 @@
+{
+ "code": "E_INVALID_OPTION",
+ "message": "Failed to resolve generation options: mapped schema MissingSchema does not exist in the IR.",
+ "path": "test/fixtures/petstore-rich.openapi.yaml",
+ "warnings": []
+}
diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.yaml.success.json b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml.success.json
new file mode 100644
index 0000000..0cbc0ec
--- /dev/null
+++ b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml.success.json
@@ -0,0 +1,25 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/petstore-rich.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Petstore Rich",
+ "pathCount": 2,
+ "operationCount": 3,
+ "schemaCount": 6
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..ce16b3c
--- /dev/null
+++ b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml/model.generated.ts
@@ -0,0 +1,24 @@
+export interface Pet {
+ id: PetId;
+ name: string;
+ status: PetStatus;
+ tags: Tag[];
+ nickname?: string | null;
+}
+
+export type PetId = string;
+
+export type PetList = Pet[];
+
+export type PetStatus = 'available' | 'pending' | 'sold';
+
+export interface Tag {
+ id: number;
+ label: string;
+}
+
+export interface UpdatePetRequest {
+ status: PetStatus;
+ tagIds: number[];
+ nickname?: string | null;
+}
diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..734f184
--- /dev/null
+++ b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml/rest/pet.rest.generated.ts
@@ -0,0 +1,48 @@
+import { Injectable } from '@angular/core';
+import { httpParams, requestFactory } from '../rest.util';
+import type { Pet, PetId, PetList, UpdatePetRequest } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ readonly getPet = requestFactory(
+ (request: GetPetParams) => {
+ const { petId } = request;
+ return {
+ method: 'GET',
+ url: `/pets/${encodeURIComponent(petId)}`,
+ };
+ },
+ );
+
+ readonly listPets = requestFactory.zeroArg(
+ () => ({
+ method: 'GET',
+ url: `/pets`,
+ }),
+ );
+
+ readonly updatePet = requestFactory(
+ (request: UpdatePetParams) => {
+ const { petId, includeHistory, body } = request;
+ return {
+ method: 'POST',
+ url: `/pets/${encodeURIComponent(petId)}`,
+ params: httpParams({ includeHistory }),
+ body: body,
+ };
+ },
+ );
+}
+
+export interface GetPetParams {
+ petId: PetId;
+}
+
+export interface UpdatePetParams {
+ petId: PetId;
+ includeHistory?: boolean;
+ body: UpdatePetRequest;
+}
diff --git a/__test__/snapshots/generate-native/recursive-model.openapi.yaml.success.json b/__test__/snapshots/generate-native/recursive-model.openapi.yaml.success.json
new file mode 100644
index 0000000..d2fbdce
--- /dev/null
+++ b/__test__/snapshots/generate-native/recursive-model.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/recursive-model.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Recursive Model",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 3
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/recursive-model.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/recursive-model.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..de853d2
--- /dev/null
+++ b/__test__/snapshots/generate-native/recursive-model.openapi.yaml/model.generated.ts
@@ -0,0 +1,14 @@
+export interface Category {
+ name: string;
+ subcategories: Category[];
+}
+
+export interface Person {
+ name: string;
+ favoritePet?: Pet;
+}
+
+export interface Pet {
+ name: string;
+ owner?: Person;
+}
diff --git a/__test__/snapshots/generate-native/recursive-oneof.openapi.yaml.success.json b/__test__/snapshots/generate-native/recursive-oneof.openapi.yaml.success.json
new file mode 100644
index 0000000..eb4c6f2
--- /dev/null
+++ b/__test__/snapshots/generate-native/recursive-oneof.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/recursive-oneof.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Recursive OneOf",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 1
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/recursive-oneof.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/recursive-oneof.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..ca4b773
--- /dev/null
+++ b/__test__/snapshots/generate-native/recursive-oneof.openapi.yaml/model.generated.ts
@@ -0,0 +1,5 @@
+export type TreeNode = {
+ leaf: string;
+} | {
+ children: TreeNode[];
+};
diff --git a/__test__/snapshots/generate-native/reserved-prop-names.openapi.yaml.success.json b/__test__/snapshots/generate-native/reserved-prop-names.openapi.yaml.success.json
new file mode 100644
index 0000000..0e6168b
--- /dev/null
+++ b/__test__/snapshots/generate-native/reserved-prop-names.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/reserved-prop-names.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Reserved And Non-Identifier Property Names",
+ "pathCount": 0,
+ "operationCount": 0,
+ "schemaCount": 1
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/reserved-prop-names.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/reserved-prop-names.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..e620a7e
--- /dev/null
+++ b/__test__/snapshots/generate-native/reserved-prop-names.openapi.yaml/model.generated.ts
@@ -0,0 +1,8 @@
+export interface NonIdentifierProps {
+ class: string;
+ id: string;
+ '2legged'?: string;
+ 'kebab-case'?: boolean;
+ 'dotted.name'?: number;
+ 'with space'?: string;
+}
diff --git a/__test__/snapshots/generate-native/response-204-no-content.openapi.yaml.success.json b/__test__/snapshots/generate-native/response-204-no-content.openapi.yaml.success.json
new file mode 100644
index 0000000..61505cf
--- /dev/null
+++ b/__test__/snapshots/generate-native/response-204-no-content.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/response-204-no-content.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Empty",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 0
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/util.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/response-204-no-content.openapi.yaml/rest/util.rest.generated.ts b/__test__/snapshots/generate-native/response-204-no-content.openapi.yaml/rest/util.rest.generated.ts
new file mode 100644
index 0000000..da97c82
--- /dev/null
+++ b/__test__/snapshots/generate-native/response-204-no-content.openapi.yaml/rest/util.rest.generated.ts
@@ -0,0 +1,15 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class UtilRest {
+
+ readonly deletePing = requestFactory.zeroArg(
+ () => ({
+ method: 'DELETE',
+ url: `/ping`,
+ }),
+ );
+}
diff --git a/__test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml.success.json b/__test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml.success.json
new file mode 100644
index 0000000..a51c63b
--- /dev/null
+++ b/__test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/response-blob-via-pdf.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "PDF Response",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 0
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/report.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml/rest/report.rest.generated.ts b/__test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml/rest/report.rest.generated.ts
new file mode 100644
index 0000000..53f5742
--- /dev/null
+++ b/__test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml/rest/report.rest.generated.ts
@@ -0,0 +1,22 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class ReportRest {
+
+ readonly getReport = requestFactory.blob(
+ (request: GetReportParams) => {
+ const { id } = request;
+ return {
+ method: 'GET',
+ url: `/reports/${encodeURIComponent(id)}`,
+ };
+ },
+ );
+}
+
+export interface GetReportParams {
+ id: string;
+}
diff --git a/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml.success.json b/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml.success.json
new file mode 100644
index 0000000..e6e413b
--- /dev/null
+++ b/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml.success.json
@@ -0,0 +1,25 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/response-default-fallback.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Default",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 2
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/thing.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..360402a
--- /dev/null
+++ b/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml/model.generated.ts
@@ -0,0 +1,7 @@
+export interface Err {
+ message?: string;
+}
+
+export interface Thing {
+ id?: string;
+}
diff --git a/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml/rest/thing.rest.generated.ts b/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml/rest/thing.rest.generated.ts
new file mode 100644
index 0000000..9a786aa
--- /dev/null
+++ b/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml/rest/thing.rest.generated.ts
@@ -0,0 +1,16 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+import type { Thing } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class ThingRest {
+
+ readonly getThing = requestFactory.zeroArg(
+ () => ({
+ method: 'GET',
+ url: `/thing`,
+ }),
+ );
+}
diff --git a/__test__/snapshots/generate-native/response-octet-stream.openapi.yaml.success.json b/__test__/snapshots/generate-native/response-octet-stream.openapi.yaml.success.json
new file mode 100644
index 0000000..720f49b
--- /dev/null
+++ b/__test__/snapshots/generate-native/response-octet-stream.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/response-octet-stream.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Blob",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 0
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/blob.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/response-octet-stream.openapi.yaml/rest/blob.rest.generated.ts b/__test__/snapshots/generate-native/response-octet-stream.openapi.yaml/rest/blob.rest.generated.ts
new file mode 100644
index 0000000..8c54c6b
--- /dev/null
+++ b/__test__/snapshots/generate-native/response-octet-stream.openapi.yaml/rest/blob.rest.generated.ts
@@ -0,0 +1,22 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class BlobRest {
+
+ readonly getBlob = requestFactory.blob(
+ (request: GetBlobParams) => {
+ const { id } = request;
+ return {
+ method: 'GET',
+ url: `/blob/${encodeURIComponent(id)}`,
+ };
+ },
+ );
+}
+
+export interface GetBlobParams {
+ id: string;
+}
diff --git a/__test__/snapshots/generate-native/response-problem-json.openapi.yaml.success.json b/__test__/snapshots/generate-native/response-problem-json.openapi.yaml.success.json
new file mode 100644
index 0000000..e51e481
--- /dev/null
+++ b/__test__/snapshots/generate-native/response-problem-json.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/response-problem-json.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Problem JSON",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 0
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/error.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/response-problem-json.openapi.yaml/rest/error.rest.generated.ts b/__test__/snapshots/generate-native/response-problem-json.openapi.yaml/rest/error.rest.generated.ts
new file mode 100644
index 0000000..43ffc8a
--- /dev/null
+++ b/__test__/snapshots/generate-native/response-problem-json.openapi.yaml/rest/error.rest.generated.ts
@@ -0,0 +1,24 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class ErrorRest {
+
+ readonly getError = requestFactory(
+ (request: GetErrorParams) => {
+ const { id } = request;
+ return {
+ method: 'GET',
+ url: `/errors/${encodeURIComponent(id)}`,
+ };
+ },
+ );
+}
+
+export interface GetErrorParams {
+ id: string;
+}
diff --git a/__test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml.success.json b/__test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml.success.json
new file mode 100644
index 0000000..5bb420d
--- /dev/null
+++ b/__test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml.success.json
@@ -0,0 +1,22 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/response-text-via-text-plain.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Text Response",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 0
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/note.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml/rest/note.rest.generated.ts b/__test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml/rest/note.rest.generated.ts
new file mode 100644
index 0000000..b97d3a4
--- /dev/null
+++ b/__test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml/rest/note.rest.generated.ts
@@ -0,0 +1,22 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class NoteRest {
+
+ readonly getNote = requestFactory.text(
+ (request: GetNoteParams) => {
+ const { id } = request;
+ return {
+ method: 'GET',
+ url: `/notes/${encodeURIComponent(id)}`,
+ };
+ },
+ );
+}
+
+export interface GetNoteParams {
+ id: string;
+}
diff --git a/__test__/snapshots/generate-native/security-schemes.openapi.yaml.success.json b/__test__/snapshots/generate-native/security-schemes.openapi.yaml.success.json
new file mode 100644
index 0000000..e827335
--- /dev/null
+++ b/__test__/snapshots/generate-native/security-schemes.openapi.yaml.success.json
@@ -0,0 +1,25 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/security-schemes.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Security Schemes",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 2
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/pet.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/security-schemes.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/security-schemes.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..30d78df
--- /dev/null
+++ b/__test__/snapshots/generate-native/security-schemes.openapi.yaml/model.generated.ts
@@ -0,0 +1,5 @@
+export interface Pet {
+ id: string;
+}
+
+export type PetList = Pet[];
diff --git a/__test__/snapshots/generate-native/security-schemes.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/security-schemes.openapi.yaml/rest/pet.rest.generated.ts
new file mode 100644
index 0000000..5488742
--- /dev/null
+++ b/__test__/snapshots/generate-native/security-schemes.openapi.yaml/rest/pet.rest.generated.ts
@@ -0,0 +1,16 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+import type { PetList } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PetRest {
+
+ readonly listPets = requestFactory.zeroArg(
+ () => ({
+ method: 'GET',
+ url: `/pets`,
+ }),
+ );
+}
diff --git a/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml.success.json b/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml.success.json
new file mode 100644
index 0000000..9f35ab5
--- /dev/null
+++ b/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml.success.json
@@ -0,0 +1,25 @@
+{
+ "summary": {
+ "normalizedSourcePath": "test/fixtures/single-entry-composition.openapi.yaml",
+ "specVersion": "3.0.3",
+ "title": "Single Entry Composition",
+ "pathCount": 1,
+ "operationCount": 1,
+ "schemaCount": 4
+ },
+ "diagnostics": [],
+ "artifacts": [
+ {
+ "path": "model.generated.ts"
+ },
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ },
+ {
+ "path": "rest/animal.rest.generated.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml/model.generated.ts
new file mode 100644
index 0000000..3d93f75
--- /dev/null
+++ b/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml/model.generated.ts
@@ -0,0 +1,11 @@
+export interface AnimalBase {
+ id: string;
+ name: string;
+ nickname?: string | null;
+}
+
+export type AnimalDraft = AnimalBase;
+
+export type AnimalView = AnimalBase;
+
+export type ContactPreference = string;
diff --git a/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml/rest/animal.rest.generated.ts b/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml/rest/animal.rest.generated.ts
new file mode 100644
index 0000000..b99b28f
--- /dev/null
+++ b/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml/rest/animal.rest.generated.ts
@@ -0,0 +1,23 @@
+import { Injectable } from '@angular/core';
+import { requestFactory } from '../rest.util';
+import type { AnimalView } from '../model.generated';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class AnimalRest {
+
+ readonly getAnimal = requestFactory(
+ (request: GetAnimalParams) => {
+ const { animalId } = request;
+ return {
+ method: 'GET',
+ url: `/animals/${encodeURIComponent(animalId)}`,
+ };
+ },
+ );
+}
+
+export interface GetAnimalParams {
+ animalId: string;
+}
diff --git a/__test__/snapshots/generate-native/static-template.json b/__test__/snapshots/generate-native/static-template.json
new file mode 100644
index 0000000..fab4648
--- /dev/null
+++ b/__test__/snapshots/generate-native/static-template.json
@@ -0,0 +1,10 @@
+{
+ "artifacts": [
+ {
+ "path": "rest.model.ts"
+ },
+ {
+ "path": "rest.util.ts"
+ }
+ ]
+}
diff --git a/__test__/snapshots/generate-native/static-template/rest.model.ts b/__test__/snapshots/generate-native/static-template/rest.model.ts
new file mode 100644
index 0000000..3167af7
--- /dev/null
+++ b/__test__/snapshots/generate-native/static-template/rest.model.ts
@@ -0,0 +1,78 @@
+import type {
+ HttpContext,
+ HttpHeaders,
+ HttpParams,
+ HttpResourceOptions,
+ HttpResourceRequest,
+} from '@angular/common/http';
+
+export type QueryParamValue =
+ | string
+ | number
+ | boolean
+ | ReadonlyArray;
+
+export interface CommonRequest extends Pick<
+ HttpResourceRequest,
+ 'body' | 'params' | 'headers'
+> {
+ method: string;
+ url: string;
+ body?: unknown;
+ params?: HttpParams | Record;
+ headers?: HttpHeaders | Record;
+}
+
+export interface WithDefault {
+ defaultValue: NoInfer;
+}
+
+export interface WithParse {
+ parse: (raw: TRaw) => TResult;
+}
+
+export type BaseHttpResourceOptions = Omit<
+ HttpResourceOptions,
+ 'parse' | 'defaultValue'
+>;
+
+export type BaseHttpResourceOptionsWithParse = BaseHttpResourceOptions<
+ TResult,
+ TRaw
+> &
+ WithParse;
+
+export type BaseHttpResourceOptionsWithDefault<
+ TResult,
+ TRaw = TResult,
+> = BaseHttpResourceOptions & WithDefault;
+
+export type BaseHttpResourceOptionsWithDefaultAndParse =
+ BaseHttpResourceOptions &
+ WithParse &
+ WithDefault;
+
+export type HttpResourceOptionsUnion =
+ | BaseHttpResourceOptions
+ | BaseHttpResourceOptionsWithParse
+ | BaseHttpResourceOptionsWithDefault
+ | BaseHttpResourceOptionsWithDefaultAndParse;
+
+// Omits body/params/headers (generator supplies them) and responseType
+// (fixed per requestFactory variant). Structurally compatible with
+// HttpClient.request(method, url, options) so the runtime spread doesn't
+// need a Parameters<…>[2] cast.
+export type ObservableOptions = {
+ context?: HttpContext;
+ observe?: 'body' | 'response' | 'events';
+ reportProgress?: boolean;
+ transferCache?: { includeHeaders?: string[] } | boolean;
+ withCredentials?: boolean;
+ keepalive?: boolean;
+ redirect?: RequestRedirect;
+ mode?: RequestMode;
+ credentials?: RequestCredentials;
+ priority?: RequestPriority;
+ cache?: RequestCache;
+ timeout?: number;
+};
diff --git a/__test__/snapshots/generate-native/static-template/rest.util.ts b/__test__/snapshots/generate-native/static-template/rest.util.ts
new file mode 100644
index 0000000..6b32066
--- /dev/null
+++ b/__test__/snapshots/generate-native/static-template/rest.util.ts
@@ -0,0 +1,325 @@
+import { HttpClient, HttpParams, httpResource } from '@angular/common/http';
+import type {
+ HttpEvent,
+ HttpResourceOptions,
+ HttpResourceRef,
+ HttpResponse,
+} from '@angular/common/http';
+import {
+ InjectionToken,
+ inject,
+ makeEnvironmentProviders,
+ type EnvironmentProviders,
+} from '@angular/core';
+import type { Observable } from 'rxjs';
+import type {
+ BaseHttpResourceOptionsWithDefault,
+ BaseHttpResourceOptionsWithDefaultAndParse,
+ BaseHttpResourceOptionsWithParse,
+ CommonRequest,
+ HttpResourceOptionsUnion,
+ ObservableOptions,
+ QueryParamValue,
+} from './rest.model';
+
+type QueryParamRecord = Record;
+type ResponseType = 'blob' | 'text' | 'arraybuffer';
+
+export const OPENAPI_NG_BASE_PATH = new InjectionToken(
+ 'OPENAPI_NG_BASE_PATH',
+);
+
+export function provideOpenapiNg(config: {
+ basePath: string;
+}): EnvironmentProviders {
+ return makeEnvironmentProviders([
+ { provide: OPENAPI_NG_BASE_PATH, useValue: config.basePath },
+ ]);
+}
+
+export function httpParams(params: QueryParamRecord): HttpParams {
+ let resolved = new HttpParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined) {
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ resolved = resolved.append(key, String(item));
+ }
+ } else {
+ resolved = resolved.set(key, String(value));
+ }
+ }
+ }
+ return resolved;
+}
+
+export interface RequestFnValue {
+ request(request: Request): CommonRequest;
+ observable(
+ request: Request,
+ options: ObservableOptions & { observe: 'response' },
+ ): Observable>;
+ observable(
+ request: Request,
+ options: ObservableOptions & { observe: 'events' },
+ ): Observable>;
+ observable(request: Request, options?: ObservableOptions): Observable;
+ resource(
+ reactiveReq: () => Request | undefined,
+ options: BaseHttpResourceOptionsWithDefault,
+ ): HttpResourceRef;
+ resource(
+ reactiveReq: () => Request | undefined,
+ options: BaseHttpResourceOptionsWithDefaultAndParse,
+ ): HttpResourceRef;
+ resource(
+ reactiveReq: () => Request | undefined,
+ options?: HttpResourceOptionsUnion,
+ ): HttpResourceRef;
+ resource(
+ reactiveReq: () => Request | undefined,
+ options: BaseHttpResourceOptionsWithParse,
+ ): HttpResourceRef