Skip to content

feat: add $dynamicRef / $dynamicAnchor schema resolution for OpenAPI 3.1#3889

Open
aqeelat wants to merge 9 commits into
hey-api:mainfrom
aqeelat:feat/dynamicref-support
Open

feat: add $dynamicRef / $dynamicAnchor schema resolution for OpenAPI 3.1#3889
aqeelat wants to merge 9 commits into
hey-api:mainfrom
aqeelat:feat/dynamicref-support

Conversation

@aqeelat
Copy link
Copy Markdown

@aqeelat aqeelat commented May 15, 2026

Closes #3886

What changed

  • spec-types: Add $dynamicRef, $dynamicAnchor, $defs to JSON Schema 2020-12 type definitions
  • SchemaState: Add dynamicScope field for dynamic anchor → type ref propagation through the parser
  • buildDynamicScope(): Builds dynamic scope from own $dynamicAnchor and $defs bindings
  • $dynamicRef dispatch: New branch in schemaToIrSchema() resolves $dynamicRef through dynamic scope to a synthetic $ref
  • Generic pagination materialization: When a schema has both $ref and $defs with dynamic bindings, the target schema is materialized inline with the caller's scope instead of producing a type alias
  • Docs: Document $dynamicRef / $dynamicAnchor support on the TypeScript plugin page
  • Tests: Unit tests for all dynamicRef.ts helpers + 6 integration test fixtures with snapshots

Supported patterns

Pattern Result
Self-referential $dynamicAnchor (e.g. BaseCategory.children) Recursive concrete type (was Array<unknown>)
Generic pagination via $defs (e.g. PaginatedUserResponse) Materialized with concrete item types (was type alias to template)
Inline route-response $dynamicRef bindings Resolved with concrete types (was unknown)
Non-identifier schema keys Correctly normalized

Unsupported (falls back to unknown)

Pattern Reason
External $dynamicRef (e.g. other.json#node) The bundler (@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.
Ambiguous $dynamicAnchor (multiple same-named anchors) Static analysis cannot determine which schema
Inline $defs bindings without $ref Only $defs entries with both $dynamicAnchor and $ref are resolved

Design

  • No plugin changes$dynamicRef is resolved to normal $ref in the IR before any plugin sees it.
  • No config flag — purely additive. Only triggers when $dynamicRef is present in the spec. Current behavior for such specs is unknown; any improvement is strictly better.
  • Plain-name anchors only$dynamicRef values containing / (JSON pointer fragments) are not resolved.

Limitations

  • Each components.schemas entry is generated once with its own scope. Full call-site-aware resolution would require generic type parameter synthesis — deferred to a future phase.

aqeelat added 4 commits May 15, 2026 13:34
Resolves JSON Schema 2020-12 dynamic reference semantics in the
OpenAPI 3.1 parser so generated TypeScript preserves concrete types
instead of degrading to unknown.

Supported patterns:
- Self-referential recursive schemas (BaseCategory.children → BaseCategory[])
- Generic pagination binding via $defs (PaginatedUserResponse.items → User[])
- Sibling scope scan for unique $dynamicAnchor declarations
- External $dynamicRef graceful fallback to unknown
- Non-identifier schema keys (kebab-case) with $dynamicAnchor

Implementation:
- Add $dynamicRef, $dynamicAnchor, $defs to spec-type BaseDocument
- Add dynamicScope to SchemaState for scope propagation
- Add buildDynamicScope() with sibling scan (unique anchors only)
- Materialize $ref targets when caller has $defs dynamic bindings
- Add $dynamicRef dispatch branch in schemaToIrSchema()
- 6 test fixtures + snapshots covering all patterns and edge cases

No plugin changes needed — $dynamicRef is resolved to normal $ref in
the IR before any plugin sees it.
@bolt-new-by-stackblitz
Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 15, 2026

@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-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 15, 2026

🦋 Changeset detected

Latest commit: 2248a9b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@hey-api/shared Patch
@hey-api/spec-types Patch
@hey-api/openapi-python Patch
@hey-api/openapi-ts Patch

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

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 dynamicScope to SchemaState — anchor name → resolved $ref map, propagated through the parser per JSON Schema dynamic-scope rules.
  • buildDynamicScope() — scans the current schema's own $dynamicAnchor and its $defs for anchor bindings.
  • $dynamicRef dispatch in schemaToIrSchema — resolves the anchor through the active scope to a synthetic $ref, falling back to unknown when unresolved.
  • materializeDynamicRefBinding() — when a schema has both $ref and a $defs binding, the referenced template is inlined with the caller's scope so the resulting type isn't a stale alias.
  • $dynamicAnchor-aware inlining inside parseRef — 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: mainfeat/dynamicref-support


Inline $defs bindings without $ref are silently dropped

Before: an unsupported, undocumented edge case.
After: still unsupported, but the dropping is silent — buildDynamicScope only 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 produces unknown items.
After: commit cb35873 (fix: avoid sibling dynamic ref bindings) removed sibling-scope resolution, so the nested-workspace-resources snapshot now shows shortcuts: Array<unknown>. Meanwhile, the paginated-response snapshot DOES resolve inline-route bindings (the 200 response is a concrete object with items: 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/x which never matches a scope key and falls through to unknown. 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

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +1430 to +1438
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;
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown

codecov Bot commented May 15, 2026

Codecov Report

❌ Patch coverage is 71.01449% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 37.90%. Comparing base (dee264d) to head (2248a9b).
⚠️ Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
packages/shared/src/openApi/3.1.x/parser/schema.ts 0.00% 14 Missing and 6 partials ⚠️
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     
Flag Coverage Δ
unittests 37.90% <71.01%> (+0.12%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 15, 2026

Open in StackBlitz

@hey-api/codegen-core

npm i https://pkg.pr.new/@hey-api/codegen-core@3889

@hey-api/json-schema-ref-parser

npm i https://pkg.pr.new/@hey-api/json-schema-ref-parser@3889

@hey-api/nuxt

npm i https://pkg.pr.new/@hey-api/nuxt@3889

@hey-api/openapi-ts

npm i https://pkg.pr.new/@hey-api/openapi-ts@3889

@hey-api/shared

npm i https://pkg.pr.new/@hey-api/shared@3889

@hey-api/spec-types

npm i https://pkg.pr.new/@hey-api/spec-types@3889

@hey-api/types

npm i https://pkg.pr.new/@hey-api/types@3889

@hey-api/vite-plugin

npm i https://pkg.pr.new/@hey-api/vite-plugin@3889

commit: 2248a9b

@mrlubos
Copy link
Copy Markdown
Member

mrlubos commented May 15, 2026

@aqeelat still a draft?

- Extract dynamic ref helpers into separate dynamicRef.ts module
- Tighten resolveDynamicRef to reject JSON pointer fragments
- Add comment about circularReferenceTracker sharing intent
- Document $dynamicRef / $dynamicAnchor support in TypeScript plugin docs
- Add changeset for @hey-api/shared and @hey-api/spec-types
@aqeelat
Copy link
Copy Markdown
Author

aqeelat commented May 15, 2026

@mrlubos give me a few minutes. I need to add add more tests.

@aqeelat
Copy link
Copy Markdown
Author

aqeelat commented May 15, 2026

Not a draft anymore. Latest push adds:

  • Unit tests for all dynamicRef.ts helpers (41 test cases)
  • PR description synced with actual implementation
  • Changeset added
  • Docs on the TypeScript plugin page
  • Tightened resolveDynamicRef to reject JSON pointer fragments
  • Comment explaining circularReferenceTracker sharing

The only remaining failure is the pre-existing flaky timeout in internal.test.ts.

@aqeelat aqeelat marked this pull request as ready for review May 15, 2026 14:53
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. docs 📃 Documentation updates. feature 🚀 Feature request. labels May 15, 2026
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SchemaState with dynamicScope — new field carrying the active anchor-to-$ref map through recursion, with the parent circularReferenceTracker Set intentionally shared and dynamicScope always cloned per call.
  • New dynamicRef.ts parser modulebuildDynamicScope, buildCurrentDynamicScope, resolveDynamicRef, materializeDynamicRefBinding, shouldInlineDynamicRefTarget cleanly separate the dynamic-scope logic from schema.ts.
  • schemaToIrSchema dispatch — handles $dynamicRef by rewriting it to a synthetic $ref (when resolvable) or falling back to unknown; materializes {$ref, $defs} template instantiations inline so generic wrappers emit concrete types.
  • parseRef inlining — when a target component carries $dynamicAnchor and the caller's scope binds it elsewhere, the target is parsed inline rather than emitted as a named alias.
  • Spec-types — adds $defs, $dynamicAnchor, $dynamicRef to 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: mainfeat/dynamicref-support


Resolution model: IR-level rewrite, no plugin changes

Before: $dynamicRef was unknown to the parser; downstream plugins saw unknown or stale type aliases.
After: Dynamic scope is computed and consumed entirely in the 3.1 parser, emitting either a synthetic $ref, an inline materialization, or unknown — 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: schemaToIrSchema mutated the caller's state directly, with $ref stashed/restored around recursive calls.
After: Every entry into schemaToIrSchema constructs a fresh currentState (spread of parent + freshly merged dynamicScope), but the circularReferenceTracker Set 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 $dynamicRef fixtures; recursive schemas degraded to Array<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

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +298 to +305
it('returns undefined for bare #', () => {
expect(
resolveDynamicRef({
dynamicRef: '#',
dynamicScope: { '': '#/components/schemas/X' },
}),
).toBe('#/components/schemas/X');
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mrlubos
Copy link
Copy Markdown
Member

mrlubos commented May 15, 2026

@aqeelat you can ignore the flaky test, that happens only locally as far as I know

@aqeelat
Copy link
Copy Markdown
Author

aqeelat commented May 15, 2026

@mrlubos quick question on the test types — I'm testing the runtime guard in dynamicRef.ts that filters out invalid `` values (null, strings, arrays, etc.) before processing. The OpenAPIV3_1.SchemaObject type doesn't allow these, so the test needs a cast.

Currently using as any on individual values but I'd prefer something cleaner. Options I've considered:

  1. A small helper like schemaWithDefs($defs: Record<string, unknown>) with a single as unknown as OpenAPIV3_1.SchemaObject cast
  2. Just keep as any on the values since the intent is clear from the test name

Any preference on the idiomatic approach here?

@mrlubos
Copy link
Copy Markdown
Member

mrlubos commented May 15, 2026

@aqeelat as any is fine if it's too much trouble to type properly

aqeelat added 2 commits May 16, 2026 01:10
…icRef

- Extract getDynamicDefsBindings() helper shared by hasDynamicRefBindings
  and buildDynamicScope to prevent predicate drift
- Reject bare # (empty anchor name) in resolveDynamicRef
- Split bare # test into two cases: no scope and empty-string scope key
@aqeelat
Copy link
Copy Markdown
Author

aqeelat commented May 15, 2026

Addressed review feedback in 2248a9b: moved resolveRef and shouldInlineDynamicRefTarget inside the !circularReferenceTracker.has guard so circular component refs skip the redundant resolution entirely. No behavior change — all tests pass (150/151, 1 pre-existing timeout).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs 📃 Documentation updates. feature 🚀 Feature request. size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: support $dynamicRef / $dynamicAnchor for OpenAPI 3.1 schemas

2 participants