feat: add $dynamicRef / $dynamicAnchor schema resolution for OpenAPI 3.1#3889
feat: add $dynamicRef / $dynamicAnchor schema resolution for OpenAPI 3.1#3889aqeelat wants to merge 2 commits into
Conversation
|
|
|
@aqeelat is attempting to deploy a commit to the Hey API Team on Vercel. A member of the Team first needs to authorize it. |
🦋 Changeset detectedLatest commit: 215d89c The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Important
The implementation is sound and the test fixtures give good coverage, but the PR description no longer matches what the code does (sibling scope was removed by cb35873), and buildDynamicScope silently drops inline $defs bindings — both worth tightening before merge.
TL;DR — Resolves JSON Schema 2020-12 $dynamicRef / $dynamicAnchor in the OpenAPI 3.1 parser so recursive trees and generic pagination templates produce concrete types instead of unknown. The fix is purely additive — it only triggers when $dynamicRef is present.
Key changes
- Add
dynamicScopetoSchemaState— anchor name → resolved$refmap, propagated through the parser per JSON Schema dynamic-scope rules. buildDynamicScope()— scans the current schema's own$dynamicAnchorand its$defsfor anchor bindings.$dynamicRefdispatch inschemaToIrSchema— resolves the anchor through the active scope to a synthetic$ref, falling back tounknownwhen unresolved.materializeDynamicRefBinding()— when a schema has both$refand a$defsbinding, the referenced template is inlined with the caller's scope so the resulting type isn't a stale alias.$dynamicAnchor-aware inlining insideparseRef— when the referenced component declares a matching$dynamicAnchor, its body is re-parsed under the caller's scope rather than emitted as a name ref.- 6 fixtures + snapshots covering recursive trees, generic pagination (named and inline-route), nested workspace resources, scope isolation, external refs, and non-identifier component keys.
Summary | 25 files | 4 commits | base: main ← feat/dynamicref-support
Inline $defs bindings without $ref are silently dropped
Before: an unsupported, undocumented edge case.
After: still unsupported, but the dropping is silent —buildDynamicScopeonly records bindings whose value is{$dynamicAnchor, $ref}. Inline schemas ({$dynamicAnchor: itemType, type: object, properties: {...}}) are valid JSON Schema 2020-12 bindings but won't enter the scope.
This is a real-world pattern (you don't always factor your bound schema out into components.schemas). Either widen the binding shape to accept inline schemas (materialize them as anonymous IR schemas), or document the limitation alongside the existing inline-route caveat.
packages/shared/src/openApi/3.1.x/parser/schema.ts
PR description has drifted from the implementation
Before: description advertises "Sibling scope — unique anchor" with
BaseFolder.shortcuts → Array<BaseResource>and says the inline paginated-response fixture still producesunknownitems.
After: commitcb35873(fix: avoid sibling dynamic ref bindings) removed sibling-scope resolution, so thenested-workspace-resourcessnapshot now showsshortcuts: Array<unknown>. Meanwhile, thepaginated-responsesnapshot DOES resolve inline-route bindings (the200response is a concrete object withitems: Array<User>), so the "Limitations" bullet about it is stale too.
Worth syncing the PR body and the supported-patterns table with what actually ships — otherwise reviewers and downstream users will be misled about which patterns work.
$dynamicRef anchor parsing is permissive
Before: N/A (new code).
After:schema.$dynamicRef.startsWith('#') ? slice(1) : full string. For a JSON-pointer-form dynamic ref like#/defs/x, the slice yields/defs/xwhich never matches a scope key and falls through tounknown. Functionally fine, but the heuristic conflates plain-name anchors with JSON pointers.
Tightening to "starts with # and contains no /" would make the intent explicit and the failure mode (e.g. logging an unsupported form) easier to diagnose later.
packages/shared/src/openApi/3.1.x/parser/schema.ts
Claude Opus | 𝕏
| if (schema.$defs) { | ||
| for (const [, defSchema] of Object.entries(schema.$defs)) { | ||
| if (defSchema && typeof defSchema === 'object' && !Array.isArray(defSchema)) { | ||
| const defSchemaObj = defSchema as OpenAPIV3_1.SchemaObject; | ||
| if (defSchemaObj.$dynamicAnchor && defSchemaObj.$ref) { | ||
| scope[defSchemaObj.$dynamicAnchor] = defSchemaObj.$ref; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
$defs bindings are only added to the scope when the inner schema has both $dynamicAnchor AND $ref. Inline-schema bindings — {$dynamicAnchor: itemType, type: object, properties: {...}} — are a valid 2020-12 pattern and currently get silently dropped, leaving the $dynamicRef to fall back to unknown. Either materialize inline bindings as anonymous IR schemas (storing the def path in a parallel map) or call out the limitation explicitly so users know to factor their binding out into components.schemas.
| // Extract the anchor name from the $dynamicRef (e.g., "#itemType" -> "itemType") | ||
| const anchorName = schema.$dynamicRef.startsWith('#') | ||
| ? schema.$dynamicRef.slice(1) | ||
| : schema.$dynamicRef; |
There was a problem hiding this comment.
startsWith('#') accepts JSON-pointer fragments like #/defs/x; slice(1) then yields /defs/x, which silently misses scope lookup and falls back to unknown. Consider tightening to schema.$dynamicRef.startsWith('#') && !schema.$dynamicRef.includes('/') so plain-name anchors and pointers/external refs take visibly different branches.
| !Array.isArray(defSchema) && | ||
| (defSchema as OpenAPIV3_1.SchemaObject).$dynamicAnchor && | ||
| (defSchema as OpenAPIV3_1.SchemaObject).$ref, | ||
| ); |
There was a problem hiding this comment.
hasDynamicRefBindings and buildDynamicScope both walk Object.values(schema.$defs) with the same per-value checks but encode the predicate differently. Factoring this into a single helper (e.g. findDynamicRefBindings(schema) returning the entries once) avoids the two implementations drifting and removes the duplicate inline type cast.
| : { | ||
| circularReferenceTracker: new Set(), | ||
| dynamicScope: buildDynamicScope(schema), | ||
| }; |
There was a problem hiding this comment.
Spreading state here shares circularReferenceTracker (same Set instance) with the parent, which is the intended behaviour, but the asymmetry with dynamicScope (always a fresh object) is worth a comment — otherwise it reads like a shallow-copy mistake and a future refactor could accidentally clone the tracker and silently break circular-ref detection.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3889 +/- ##
==========================================
+ Coverage 37.77% 37.90% +0.12%
==========================================
Files 580 581 +1
Lines 20781 20842 +61
Branches 6031 6070 +39
==========================================
+ Hits 7850 7900 +50
- Misses 10528 10534 +6
- Partials 2403 2408 +5
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
@hey-api/codegen-core
@hey-api/json-schema-ref-parser
@hey-api/nuxt
@hey-api/openapi-ts
@hey-api/shared
@hey-api/spec-types
@hey-api/types
@hey-api/vite-plugin
commit: |
|
@aqeelat still a draft? |
|
@mrlubos give me a few minutes. I need to add add more tests. |
|
Not a draft anymore. Latest push adds:
The only remaining failure is the pre-existing flaky timeout in |
There was a problem hiding this comment.
TL;DR — Adds resolution for JSON Schema 2020-12 $dynamicRef / $dynamicAnchor in the OpenAPI 3.1 parser, with the resolution happening in the IR before any plugin runs. Recursive $dynamicRef, generic templates via $defs bindings, and scope isolation between sibling schemas are all covered by integration snapshots, and the new helpers in dynamicRef.ts are unit-tested.
Key changes
- Extend
SchemaStatewithdynamicScope— new field carrying the active anchor-to-$refmap through recursion, with the parentcircularReferenceTrackerSet intentionally shared anddynamicScopealways cloned per call. - New
dynamicRef.tsparser module —buildDynamicScope,buildCurrentDynamicScope,resolveDynamicRef,materializeDynamicRefBinding,shouldInlineDynamicRefTargetcleanly separate the dynamic-scope logic fromschema.ts. schemaToIrSchemadispatch — handles$dynamicRefby rewriting it to a synthetic$ref(when resolvable) or falling back tounknown; materializes{$ref, $defs}template instantiations inline so generic wrappers emit concrete types.parseRefinlining — when a target component carries$dynamicAnchorand the caller's scope binds it elsewhere, the target is parsed inline rather than emitted as a named alias.- Spec-types — adds
$defs,$dynamicAnchor,$dynamicRefto the 2020-12 JSON Schema definitions. - Specs, snapshots, docs, changeset — 6 new spec fixtures + snapshots covering recursive, generic binding, paginated, scope isolation, external ref, and non-identifier keys; user-facing docs under the TypeScript plugin page.
Summary | 29 files | 6 commits | base: main ← feat/dynamicref-support
Resolution model: IR-level rewrite, no plugin changes
Before:
$dynamicRefwas unknown to the parser; downstream plugins sawunknownor stale type aliases.
After: Dynamic scope is computed and consumed entirely in the 3.1 parser, emitting either a synthetic$ref, an inline materialization, orunknown— plugins are unaffected.
The dispatch order in schemaToIrSchema is materializeDynamicRefBinding → $ref → $dynamicRef → other keywords. Materialization fires only when all four guards hold (top-level component target, $ref + $defs, $defs carries an anchor+$ref binding), and unresolved $dynamicRef falls back to parseUnknown rather than leaking the raw keyword.
packages/shared/src/openApi/3.1.x/parser/schema.ts · packages/shared/src/openApi/3.1.x/parser/dynamicRef.ts
Scope shape and shared mutable state
Before:
schemaToIrSchemamutated the caller'sstatedirectly, with$refstashed/restored around recursive calls.
After: Every entry intoschemaToIrSchemaconstructs a freshcurrentState(spread of parent + freshly mergeddynamicScope), but thecircularReferenceTrackerSet is shared by reference so cycle detection still spans the whole tree.
The split — clone dynamicScope per call, share circularReferenceTracker by identity — is the right call: dynamic scope is lexically inherited (children inherit, siblings do not), while cycle detection is structurally global. The existing state.$ref and state.inAllOf mutate/restore patterns in parseRef and parseAllOf remain balanced because the wrapping is shallow and stash-restore happens on the same object that was mutated.
packages/shared/src/openApi/shared/types/schema.ts
Test and snapshot coverage
Before: No
$dynamicReffixtures; recursive schemas degraded toArray<unknown>and generic templates degraded to type aliases.
After: 6 integration fixtures plus a 500-line unit-test file exercise each helper independently and assert the snapshot shape end-to-end.
The scope-isolation snapshot (unboundItem: unknown alongside boundItems: Array<User>) is the most important one — it proves the per-call dynamicScope clone does its job. The recursive-category snapshot confirms BaseCategory.children: Array<BaseCategory> resolves through shouldInlineDynamicRefTarget's self-reference guard. The BaseResource = Document | unknown and BaseFolder.shortcuts: Array<unknown> outputs in the nested-workspace snapshot are the expected "ambiguous anchor → unknown" fallback documented in the plugin page.
packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts · packages/openapi-ts-tests/main/test/3.1.x.test.ts
Claude Opus | 𝕏
| it('returns undefined for bare #', () => { | ||
| expect( | ||
| resolveDynamicRef({ | ||
| dynamicRef: '#', | ||
| dynamicScope: { '': '#/components/schemas/X' }, | ||
| }), | ||
| ).toBe('#/components/schemas/X'); | ||
| }); |
There was a problem hiding this comment.
The test description says "returns undefined for bare #" but the assertion expects '#/components/schemas/X'. The current implementation does match the empty-string key in scope because '#'.slice(1) === ''. Either tighten resolveDynamicRef to reject dynamicRef === '#' (which the rest of the resolver implies — a bare # has no anchor name to resolve) and assert .toBeUndefined(), or rename the test to reflect the actual behavior.
| it('returns undefined for bare #', () => { | |
| expect( | |
| resolveDynamicRef({ | |
| dynamicRef: '#', | |
| dynamicScope: { '': '#/components/schemas/X' }, | |
| }), | |
| ).toBe('#/components/schemas/X'); | |
| }); | |
| it('resolves bare # against empty-string scope key', () => { | |
| expect( | |
| resolveDynamicRef({ | |
| dynamicRef: '#', | |
| dynamicScope: { '': '#/components/schemas/X' }, | |
| }), | |
| ).toBe('#/components/schemas/X'); | |
| }); |
| // Fallback to preserving the ref if circular | ||
| } | ||
|
|
||
| const refSchema = context.resolveRef<OpenAPIV3_1.SchemaObject>(schema.$ref); |
There was a problem hiding this comment.
context.resolveRef(schema.$ref) is now called unconditionally on every ref before the circular-tracker check, where previously it was only called inside the !circularReferenceTracker.has branch (line 1174). For circular component refs this is a redundant lookup on the hot path. Consider folding the resolution into the existing if (!state.circularReferenceTracker.has(schema.$ref)) block and only calling shouldInlineDynamicRefTarget there — the circular case can't inline anyway.
|
@aqeelat you can ignore the flaky test, that happens only locally as far as I know |
|
@mrlubos quick question on the test types — I'm testing the runtime guard in Currently using
Any preference on the idiomatic approach here? |
|
@aqeelat |
|
Addressed review feedback in 2248a9b: moved |
17f3774 to
f66b295
Compare
Add TypeScript generic type generation from OpenAPI $dynamicRef / $dynamicAnchor patterns in OpenAPI 3.1 specs. Two resolution paths: - Recursive/nested schemas: resolve via dynamic scope materialization to produce correct concrete recursive types (was Array<unknown>) - Generic/template schemas: detect template params from $defs entries with $dynamicAnchor but no $ref, emit TypeScript generics (e.g. PaginatedTemplate<User>) instead of inlining or degrading to unknown. Circular generic bindings fall back to materialization. Changes: - spec-types: add $dynamicRef, $dynamicAnchor, $defs to JSON Schema 2020-12 type definitions - IR types: add typeParams and typeArgs fields to IRSchemaObject - Parser: add dynamic scope tracking, template detection, and generic ref construction (getTemplateParams, buildGenericRef, getDynamicDefsBindings) - TypeScript visitor: handle #typeParam/ refs and $ref + typeArgs generic references - TypeScript export: emit .generic() on type aliases with type params - Tests: unit tests for dynamicRef helpers + 3 integration fixtures - Docs: update TypeScript plugin docs for generic wrapper types Closes hey-api#3886
f66b295 to
f8ab693
Compare

Closes #3886
What changed
$dynamicRef,$dynamicAnchor,$defsto JSON Schema 2020-12 type definitionstypeParamsandtypeArgsfields toIRSchemaObjectdynamicScopeandtypeParamsfields for dynamic anchor → type ref propagation and template parameter tracking through the parserdynamicRef.tsmodule (new file):buildDynamicScope()— builds dynamic scope from own$dynamicAnchorand$defsbindingsbuildCurrentDynamicScope()— merges inherited + current scope; current scope wins on shadowinggetTemplateParams()— detects generic template schemas by finding$defsentries with$dynamicAnchorbut no$refbuildGenericRef()— constructs IR nodes with$ref+typeArgsfor generic type references; preservesnullfrom nullable schemasanchorToParamName()— converts anchor names to valid TypeScript identifiers viatoCase(anchor, 'PascalCase')+ ID_Start/ID_Continue sanitizationmaterializeDynamicRefBinding()— materializes inline$dynamicRefbindingsresolveDynamicRef()— resolves a$dynamicRefstring against the current dynamic scopeshouldInlineDynamicRefTarget()— determines when a$reftarget should be inlinedschema.ts: New$dynamicRefdispatch branch inschemaToIrSchema()— resolves through dynamic scope (recursive/concrete cases) or as a type parameter reference inside generic templates;parseRef()updated to inline dynamic ref targets when scope dictatesinterceptto handle#typeParam/refs (type parameters) and$ref+typeArgs(generic references)exportAstto emit.generic()on type aliases with type parametersdynamicRef.tshelpers + 3 integration test fixtures with snapshots (petstore showcase, external-ref, scope-isolation)Supported patterns
$dynamicAnchor(e.g.BaseCategory.children)Array<unknown>)$defs(e.g.PaginatedTemplate<ItemType>)PaginatedUserResponse)PaginatedTemplate<User>type: ['object', 'null']+$defs)PaginatedTemplate<User> | nullShelterFolder = ShelterFolderTemplate<ShelterFolder, ShelterResource>)$dynamicRefbindingsunknown)item-type)ItemType)Unsupported (falls back to
unknown)$dynamicRef(e.g.other.json#node)@hey-api/json-schema-ref-parser) only processes$ref, not$dynamicRef— external files are never fetched/rewritten. A follow-up issue will be created for this after merge.$dynamicAnchor(multiple same-named anchors)$defsbindings without$ref$defsentries with both$dynamicAnchorand$refare resolvedDesign
$dynamicRefpatterns are resolved via dynamic scope materialization (no plugin changes needed). Generic/template$dynamicRefpatterns are preserved as TypeScript generics through IRtypeParams/typeArgsfields with TypeScript visitor support.$dynamicRefis present in the spec. Current behavior for such specs isunknown; any improvement is strictly better.$dynamicRefvalues containing/(JSON pointer fragments) are not resolved.typealiases for generics — Generic templates usetypealiases (notinterface) since ts-dsl already supports generics on aliases and no declaration merging is needed on generated types.toCase(anchor, 'PascalCase')and then sanitized against JavaScriptID_Start/ID_Continuerules to ensure valid TypeScript identifiers (e.g.,item-type→ItemType,folderType→FolderType).