Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ jobs:
node-version: 'lts/*'
- if: contains(matrix.generator, 'typescript-fetch')
run: |
npm install -g typescript@7.0.2 vite-plus@1.5.0
npm install -g typescript@7.0.2 vite-plus@1.5.1
tsc --version
- uses: actions/setup-go@v6
if: matrix.generator == 'go-http'
Expand Down
43 changes: 40 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Enable full `vp check` and validate freshly generated clients before and after
packing, without a formatter preparation step. Exclude `dist/` from formatting
and provide a `fmt` script for manual edits.
- Upgrade generated TypeScript clients to Vite+ `^1.5.0`, including Vite `8.3.5`,
Vitest `5.1.0`, Rolldown `1.2.16`, Oxfmt `0.76.0`, Oxlint `1.91.0`,
- Upgrade generated TypeScript clients to Vite+ `^1.5.0`, resolving Vite `8.3.7`,
Vitest `5.1.0`, Rolldown `1.2.18`, Oxfmt `0.76.0`, Oxlint `1.91.0`,
oxlint-tsgolint `7.0.2005`, and tsdown `0.23.4`. Pin CI validation and
documented setup to Vite+ `1.5.0` and TypeScript `7.0.2`, and report the resolved
documented setup to Vite+ `1.5.1` and TypeScript `7.0.2`, and report the resolved
tool versions during client builds. Vite+ projects require Node.js
`^22.18.0 || ^24.11.0 || >=26.0.0`.

- Upgrade `sigil-stitch` to 0.7.5 for structured TypeScript namespace-qualified
types with import-aware nested arguments.
- Retain inline scalar enum values in `IrTypeExpr::ScalarEnum` for union selection.
Rust callers exhaustively matching the public IR enum must handle this variant;
its `representation_nullable` fact retains an extracted null permission at the
original enum boundary, while `representation_is_constraint` distinguishes
authored types from synthetic untyped-enum fallbacks. Other generators retain
their existing representation and enum policies.

### Fixed

- Convert camelCase TypeScript untagged-union members in both JSON directions,
including mixed client/search tools and containing requests. Add configurable
`firstMatch`, `strict`, and wire-preserving `raw` selection policies instead
of treating every multi-member union converter as an identity cast. Retain
nested converter/strict-policy delegation, concrete-before-fallback priority
through nullable and `allOf` aliases, binary model values, and adjacent-tagged
unit variants. Convert inline container members and concrete values in open
nested dictionaries, and preserve array/null shapes through single-member
`allOf` aliases. Use the same value plan for models, nested HTTP bodies,
multipart JSON parts, response/error payloads, and tagged payloads/type guards.
Retain scalar enum and typed additional-value checks, productive recursive
matching, honest raw alias types, and readonly dictionary contracts. Required
nullable JSON bodies distinguish a supplied `null` from an omitted body.
Optional bodies also delegate supplied null, without an unformatted inline
conditional. Intersect nested enum constraints at their original nullability
boundary and retain authored concrete null matches beside opaque fallbacks.
Untyped numeric enums no longer inherit a synthetic string constraint;
extracted enum null permission restores a typed nullable conversion boundary.
Nullable `oneOf`/`anyOf` wrappers retain outer enum constraints; explicit null
alternatives do not bypass strict candidate counts or nested strict errors.
Emit compound conversions and predicates as static internal helpers with
direct Vite+-compatible layout, preserving readonly inputs and public exports.
- Preserve tagged TypeScript models named `Record` or `Readonly` by qualifying
built-in utility references without renaming the generated public model.


## [0.3.0]

### Breaking Changes
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.148"
serde_norway = "0.9.42"
serde_plain = "1.0.2"
sigil-stitch = "0.7.4"
sigil-stitch = "0.7.5"
similar = "2.7.0"
snafu = "0.9.2"
toml = "0.9.8"
Expand Down
7 changes: 4 additions & 3 deletions docs/src/golden_testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ rewrites. Full `vp check` runs on the original generated output, then runs again
after packing. The consumer type check uses the generated package's installed
compiler rather than the global `tsc` selection.
The current global Vite+ CLI also needs TypeScript installed alongside the CLI
for declaration packing (`npm install -g typescript@7.0.2 vite-plus@1.5.0`). CI
for declaration packing (`npm install -g typescript@7.0.2 vite-plus@1.5.1`). CI
reuses that bootstrap and its generator cache in the existing TypeScript/Vite+
golden-build job; no separate consumer job is added. `just ci` includes these checks too. See
`tests/generated-clients/typescript/README.md` for details.
Expand Down Expand Up @@ -195,8 +195,9 @@ suite. The independent JVM Client Tests CI matrix and `just ci` run these checks
- Development and ordinary CI validation select current Rust `stable` (locally
validated with Rust 1.99.0). MSRV checks explicitly select Rust 1.90.0; they do
not substitute for stable validation.
- Local TypeScript validation uses TypeScript 7.0.2 and Vite+ 1.5.0 on Node
26.13.0. The native validators use the active `tsc` and `vp` on `PATH`. To select
- Local TypeScript validation uses TypeScript 7.0.2 and Vite+ 1.5.1 on Node
26.13.0, resolving Vite 8.3.7, Rolldown 1.2.18 and Oxfmt 0.76.0. The native
validators use the active `tsc` and `vp` on `PATH`. To select
that compiler explicitly, `npm exec --package=typescript@7.0.2 -- just
golden-typescript::build-tsc` supplies it without changing generated packages.
- Current local JVM validation uses Homebrew JDK 27.0.1 with Gradle 9.8.2, respecting
Expand Down
35 changes: 35 additions & 0 deletions docs/src/ir_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ pub enum IrTypeExpr {
Primitive(IrPrimitive),
StringLiteral(String),
StringEnum(Vec<String>),
ScalarEnum {
representation: Box<IrTypeExpr>,
representation_is_constraint: bool,
representation_nullable: bool,
values: Vec<serde_json::Value>,
},
Array(Box<IrTypeExpr>),
Map(Box<IrTypeExpr>),
Nullable(Box<IrTypeExpr>),
Expand All @@ -132,6 +138,35 @@ For object properties, the outer nullable wrapper is recorded in
container does not make its contents nullable, and nullability is independent
of whether a property is required.

Inline scalar enum facts survive all three lowerers, including property, array
item, and dictionary value positions. `ScalarEnum` retains finite values together
with the pre-existing `representation`; existing string literal/enum expressions
already carry their values. TypeScript uses these facts for structural union
selection. Other generators consume the retained representation without changing
their enum policy. Rust callers exhaustively matching `IrTypeExpr` must account
for this variant.

Finite-value constraints apply to the original node: a nullable type with
`enum: [1]` still excludes null during selection, while `enum: [1, null]` permits
it. Extracting `IrProperty.nullable` does not widen that constraint. The
`representation_nullable` fact records a removed nullable wrapper at its exact
enum boundary: an outer enum intersects the inner constraints without moving
null permission across another enum. It defaults to false when constructing a
new `ScalarEnum` whose complete nullable representation is still present.
Nested finite constraints are intersected rather than replaced by outer values.
`representation_is_constraint` distinguishes an authored type or constrained
wrapper from the legacy string fallback used for an enum with no declared type.
The latter keeps its existing emitted type, but numeric/boolean/null enum values
are matched directly rather than intersected with a synthetic string check.
The `representation()` view exposes the legacy type beneath scalar metadata for
generators that do not use finite values. OpenAPI 3.1/3.2 nullable `oneOf`/`anyOf`
wrappers retain enums attached to the wrapper, including inside arrays and maps.
TypeScript conversion restores an extracted nullable wrapper around its original
delegate; matching metadata does not imply that a nonnullable delegate accepts
null. Null already accepted by the inner union still reaches its strict policy.
Inner finite constraints remain inside the restored wrapper so null excluded by
that inner constraint can still be admitted exclusively by the outer wrapper.

OpenAPI 3.0 boolean `exclusiveMinimum` / `exclusiveMaximum` flags retain both
`true` and `false` in validation metadata. The public 3.0 `ObjectSchema` fields
use `ExclusiveBound` to preserve those booleans as well as the legacy numeric
Expand Down
123 changes: 120 additions & 3 deletions docs/src/ts_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ include_build_scripts = true
emit_enum_constants = true
emit_type_guards = true
property_naming = "camelCase"
untagged_union_strategy = "firstMatch"
indent = " "
```

Expand Down Expand Up @@ -204,12 +205,128 @@ Works with all schema kinds:
- **Tagged unions** (internal, adjacent, external): dual type aliases + switch/if-chain converters
- **Intersections** (allOf): dual type aliases; compatible open objects share one
conversion plan, while other intersections use spread-based converters
- **Unions** (oneOf): dual type aliases + cast-through converters

Referenced types that are themselves convertible (objects, intersections, unions) get their converter functions called recursively. Enums and simple aliases pass through unchanged.
- **Untagged unions** (oneOf/anyOf): dual type aliases + member-selecting converters

Models, JSON request bodies, JSON multipart parts, successful responses, and lazy
error values use one value plan per generation. It projects public and wire types
and delegates conversion recursively through aliases, properties, arrays, and
dictionaries. An unchanged type does not remove a `strict` runtime check: even an
inline `integer | number` union must still detect overlapping matches.

Compound conversions and structural predicates use statically generated helper
functions in internal runtime modules, not a runtime schema interpreter. They
are not re-exported by the public barrel. Conversion helpers retain separate
dependency modules so importing a model does not load unrelated model graphs.
Helper inputs accept readonly arrays
without widening public API results or changing model array mutability. Source
layout is emitted directly; full Vite+ checks need no formatter preparation.

Defaults to `"preserve"` (no renaming, single interface, no converters).

### `untagged_union_strategy`

Controls untagged-union conversion when `property_naming = "camelCase"`:

- `"firstMatch"` (default): select the first structurally matching member and
call its converter. Concrete members precede unconstrained JSON/object/map
fallbacks, with schema order preserved within each group. Unmatched values
pass through unchanged rather than making imperfect specifications unusable.
- `"strict"`: use the same structural selection, but throw `TypeError` for
multiple concrete matches or no matching member. A raw fallback is considered
only after the concrete members, so it does not itself create ambiguity.
- `"raw"`: do not select or convert union members. Both `Name` and `Name$Wire`
expose the wire representation; pass original wire keys such as `input_schema`,
not ergonomic keys such as `inputSchema`. This is an explicit escape hatch,
not an identity cast pretending to return a renamed model. This applies to the
entire union subtree, including inline container unions and aliases; ordinary
enclosing objects and sibling properties still convert normally.

```toml
[generators.typescript-fetch]
property_naming = "camelCase"
untagged_union_strategy = "strict"
```

The same choice is available through a CLI override:

```sh
openapi-nexus generate --input openapi.yaml --generators typescript-fetch \
--generator-config typescript-fetch.property_naming=camelCase \
--generator-config typescript-fetch.untagged_union_strategy=strict
```

Selection checks generated value kinds, required own fields, declared property
shapes, typed additional values, and scalar literal/enum values in the appropriate
wire or ergonomic naming direction. Named fields are excluded from the additional
value check, even though TypeScript widens the dictionary's index type to include
them.
Nested enum constraints intersect at their original nullable boundaries; an outer
enum does not replace or widen an inner enum.
Untyped scalar enums match their finite values without treating the legacy
string type fallback as an authored constraint.
Arrays and dictionaries inspect their element/value shapes; dictionary keys and
external variant tags stay verbatim. This is conversion dispatch, not full JSON
Schema validation: formats, numeric bounds, and other validation keywords are
not checked. Productive recursive references use statically generated matching
functions rather than becoming unrestricted guards. These helpers are private to
the generated runtime, not public barrel exports; selected member converters
still handle recursive payload conversion. Same-value circular type aliases are
not made representable by this mechanism.
Binary properties match their generated `Blob | File` representation rather
than strings. Adjacent-tagged unit variants do not require a content property;
payload variants retain their content checks.

A named nested union can provide both concrete matches and a raw fallback.
Its concrete matches take priority over fallbacks, and both paths delegate to
the nested converter so its strict ambiguity policy is not bypassed. Nullable
and `allOf` wrappers do not turn an unrestricted map/JSON fallback into a
concrete match. A dictionary of open nested unions retains a concrete selection
path when at least one entry matches concretely, even when other entries are
opaque. Empty or entirely opaque dictionaries remain fallbacks. Both paths
delegate through the dictionary converter, preserving inner strict errors.

Matching is pure: select/count the original member candidates before running a
converter, then invoke only the selected delegate. A nested strict error propagates
instead of triggering a retry with another member. Repeated nullable wrappers are
idempotent; they do not create extra candidates or promote an opaque fallback.
An explicit null alternative does not bypass ambiguity among other nullable
members, or suppress a selected nested converter's strict error.
An unrestricted fallback beside authored nullable members does not erase those
members' concrete null matches. Null permission added only by an opaque wrapper
remains a fallback rather than creating an extra concrete candidate.
Property, array and dictionary nullable wrappers delegate null when the inner
value already accepts it. Null admitted only by the outer wrapper still bypasses
a non-nullable inner converter.
This remains true when property nullability was extracted through scalar enum
metadata: conversion restores the wrapper rather than passing null directly to
the nonnullable delegate.

Inline unions inside a selected array or dictionary member use the same
selection policy recursively, rather than returning their contents unchanged.
A single-member `allOf` alias delegates without spreading its result,
preserving primitive, array, and null values too.

For example, a mixed `ClientTool | SearchTool` union can distinguish a required
`input_schema` / `inputSchema` field from a search member's required literal
`type`, then convert `inputSchema` and `maxUses` to `input_schema` and `max_uses`.
Conversions also work through containing arrays, dictionaries, and model aliases.
Declared additional-property behavior remains owned by the selected member's
converter. Direct tagged payloads and optional type guards use the same type and
conversion projections. Preserve mode does not add JSON converters.

Tagged declarations and guards namespace-qualify built-in utilities when a model
name shadows them, including open external wrappers. A tagged schema named
`Record` or `Readonly` keeps its public model and converter names; its generated
utility types use `globalThis.Record` or `globalThis.Readonly` instead. Planned
dictionaries and structural matching casts use the same qualification.

A required JSON body must be supplied, but a schema-permitted `null` is a value,
not a missing body. Such bodies serialize as JSON `null`; omitted bodies still
throw `RequiredError` before sending a request.
Optional JSON bodies skip conversion only when omitted (`undefined`). A supplied
null uses the same planned conversion and strict ambiguity checks as a required
body or direct model conversion.

### Additional properties

Objects declaring `additionalProperties` expose a readonly string dictionary in
Expand Down
Loading
Loading