From 2f79898822671dea5217d4a6b8d5fce8a0e74c8a Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 15 May 2026 11:56:24 +0300 Subject: [PATCH 1/9] feat: add $dynamicRef/$dynamicAnchor schema resolution for OpenAPI 3.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../openapi-ts-tests/main/test/3.1.x.test.ts | 42 +++++ .../3.1.x/dynamicref-external-ref/index.ts | 3 + .../dynamicref-external-ref/types.gen.ts | 33 ++++ .../index.ts | 3 + .../types.gen.ts | 82 ++++++++ .../index.ts | 3 + .../types.gen.ts | 55 ++++++ .../dynamicref-non-identifier-key/index.ts | 3 + .../types.gen.ts | 33 ++++ .../dynamicref-paginated-response/index.ts | 3 + .../types.gen.ts | 68 +++++++ .../index.ts | 3 + .../types.gen.ts | 38 ++++ .../shared/src/openApi/3.1.x/parser/schema.ts | 178 +++++++++++++++++- .../shared/src/openApi/shared/types/schema.ts | 7 + .../src/json-schema/draft-2020-12/spec.ts | 16 ++ specs/3.1.x/dynamicref-external-ref.yaml | 37 ++++ .../dynamicref-generic-schema-binding.yaml | 94 +++++++++ ...dynamicref-nested-workspace-resources.yaml | 93 +++++++++ .../3.1.x/dynamicref-non-identifier-key.yaml | 38 ++++ .../3.1.x/dynamicref-paginated-response.yaml | 88 +++++++++ .../dynamicref-recursive-category-tree.yaml | 51 +++++ 22 files changed, 963 insertions(+), 8 deletions(-) create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-external-ref/index.ts create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-external-ref/types.gen.ts create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-generic-schema-binding/index.ts create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-generic-schema-binding/types.gen.ts create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-nested-workspace-resources/index.ts create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-nested-workspace-resources/types.gen.ts create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-non-identifier-key/index.ts create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-non-identifier-key/types.gen.ts create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-paginated-response/index.ts create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-paginated-response/types.gen.ts create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-recursive-category-tree/index.ts create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-recursive-category-tree/types.gen.ts create mode 100644 specs/3.1.x/dynamicref-external-ref.yaml create mode 100644 specs/3.1.x/dynamicref-generic-schema-binding.yaml create mode 100644 specs/3.1.x/dynamicref-nested-workspace-resources.yaml create mode 100644 specs/3.1.x/dynamicref-non-identifier-key.yaml create mode 100644 specs/3.1.x/dynamicref-paginated-response.yaml create mode 100644 specs/3.1.x/dynamicref-recursive-category-tree.yaml diff --git a/packages/openapi-ts-tests/main/test/3.1.x.test.ts b/packages/openapi-ts-tests/main/test/3.1.x.test.ts index 5e47f3c481..5437a638dd 100644 --- a/packages/openapi-ts-tests/main/test/3.1.x.test.ts +++ b/packages/openapi-ts-tests/main/test/3.1.x.test.ts @@ -1015,6 +1015,48 @@ describe(`OpenAPI ${version}`, () => { }), description: 'anyOf string and binary string', }, + { + config: createConfig({ + input: 'dynamicref-recursive-category-tree.yaml', + output: 'dynamicref-recursive-category-tree', + }), + description: 'resolves $dynamicRef in recursive category tree', + }, + { + config: createConfig({ + input: 'dynamicref-generic-schema-binding.yaml', + output: 'dynamicref-generic-schema-binding', + }), + description: 'resolves $dynamicRef in generic schema binding', + }, + { + config: createConfig({ + input: 'dynamicref-nested-workspace-resources.yaml', + output: 'dynamicref-nested-workspace-resources', + }), + description: 'resolves $dynamicRef in nested workspace resources', + }, + { + config: createConfig({ + input: 'dynamicref-paginated-response.yaml', + output: 'dynamicref-paginated-response', + }), + description: 'resolves $dynamicRef in paginated response template', + }, + { + config: createConfig({ + input: 'dynamicref-external-ref.yaml', + output: 'dynamicref-external-ref', + }), + description: 'handles external $dynamicRef without crashing', + }, + { + config: createConfig({ + input: 'dynamicref-non-identifier-key.yaml', + output: 'dynamicref-non-identifier-key', + }), + description: 'handles non-identifier schema keys with $dynamicAnchor', + }, ]; it.each(scenarios)('$description', async ({ config }) => { diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-external-ref/index.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-external-ref/index.ts new file mode 100644 index 0000000000..f529688166 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-external-ref/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { ClientOptions, Container, GetContainersData, GetContainersErrors, GetContainersResponse, GetContainersResponses } from './types.gen'; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-external-ref/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-external-ref/types.gen.ts new file mode 100644 index 0000000000..84aa5d3e9b --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-external-ref/types.gen.ts @@ -0,0 +1,33 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.dynamicref.test' | (string & {}); +}; + +export type Container = { + id: string; + item: unknown; +}; + +export type GetContainersData = { + body?: never; + path?: never; + query?: never; + url: '/containers'; +}; + +export type GetContainersErrors = { + /** + * Error response + */ + default: unknown; +}; + +export type GetContainersResponses = { + /** + * Container list + */ + 200: Array; +}; + +export type GetContainersResponse = GetContainersResponses[keyof GetContainersResponses]; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-generic-schema-binding/index.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-generic-schema-binding/index.ts new file mode 100644 index 0000000000..95ba1b2985 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-generic-schema-binding/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { ClientOptions, Group, ListGroupsData, ListGroupsErrors, ListGroupsResponse, ListGroupsResponses, ListUsersData, ListUsersErrors, ListUsersResponse, ListUsersResponses, PaginatedGroupResponse, PaginatedTemplate, PaginatedUserResponse, User } from './types.gen'; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-generic-schema-binding/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-generic-schema-binding/types.gen.ts new file mode 100644 index 0000000000..7a9ceff56e --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-generic-schema-binding/types.gen.ts @@ -0,0 +1,82 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.dynamicref.test' | (string & {}); +}; + +export type User = { + id: string; + email: string; +}; + +export type Group = { + id: string; + name: string; +}; + +export type PaginatedTemplate = { + items: Array; + total: number; + page: number; + pageSize: number; +}; + +export type PaginatedUserResponse = { + items: Array; + total: number; + page: number; + pageSize: number; +}; + +export type PaginatedGroupResponse = { + items: Array; + total: number; + page: number; + pageSize: number; +}; + +export type ListUsersData = { + body?: never; + path?: never; + query?: never; + url: '/users'; +}; + +export type ListUsersErrors = { + /** + * Error response + */ + default: unknown; +}; + +export type ListUsersResponses = { + /** + * User page + */ + 200: PaginatedUserResponse; +}; + +export type ListUsersResponse = ListUsersResponses[keyof ListUsersResponses]; + +export type ListGroupsData = { + body?: never; + path?: never; + query?: never; + url: '/groups'; +}; + +export type ListGroupsErrors = { + /** + * Error response + */ + default: unknown; +}; + +export type ListGroupsResponses = { + /** + * Group page + */ + 200: PaginatedGroupResponse; +}; + +export type ListGroupsResponse = ListGroupsResponses[keyof ListGroupsResponses]; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-nested-workspace-resources/index.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-nested-workspace-resources/index.ts new file mode 100644 index 0000000000..f59a0c4756 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-nested-workspace-resources/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { BaseFolder, BaseResource, ClientOptions, Document, GetWorkspaceData, GetWorkspaceErrors, GetWorkspaceResponse, GetWorkspaceResponses, WorkspaceFolder, WorkspaceResource, WorkspaceResponse } from './types.gen'; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-nested-workspace-resources/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-nested-workspace-resources/types.gen.ts new file mode 100644 index 0000000000..3f25974010 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-nested-workspace-resources/types.gen.ts @@ -0,0 +1,55 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.dynamicref.test' | (string & {}); +}; + +export type Document = { + kind: 'document'; + id: string; + title: string; +}; + +export type BaseFolder = { + kind: 'folder'; + id: string; + name: string; + children: Array; + shortcuts: Array; +}; + +export type BaseResource = Document | unknown; + +export type WorkspaceFolder = BaseFolder & { + permissions: Array<'read' | 'write' | 'admin'>; +}; + +export type WorkspaceResource = Document | WorkspaceFolder; + +export type WorkspaceResponse = { + root: WorkspaceFolder; + related: Array; +}; + +export type GetWorkspaceData = { + body?: never; + path?: never; + query?: never; + url: '/workspaces/current'; +}; + +export type GetWorkspaceErrors = { + /** + * Error response + */ + default: unknown; +}; + +export type GetWorkspaceResponses = { + /** + * Workspace with nested folders and linked resources + */ + 200: WorkspaceResponse; +}; + +export type GetWorkspaceResponse = GetWorkspaceResponses[keyof GetWorkspaceResponses]; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-non-identifier-key/index.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-non-identifier-key/index.ts new file mode 100644 index 0000000000..5fa24cd9d5 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-non-identifier-key/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { BaseCategory, ClientOptions, GetTreeData, GetTreeErrors, GetTreeResponse, GetTreeResponses } from './types.gen'; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-non-identifier-key/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-non-identifier-key/types.gen.ts new file mode 100644 index 0000000000..1ad5491302 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-non-identifier-key/types.gen.ts @@ -0,0 +1,33 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.dynamicref.test' | (string & {}); +}; + +export type BaseCategory = { + id: string; + children: Array; +}; + +export type GetTreeData = { + body?: never; + path?: never; + query?: never; + url: '/tree'; +}; + +export type GetTreeErrors = { + /** + * Error response + */ + default: unknown; +}; + +export type GetTreeResponses = { + /** + * Tree node + */ + 200: BaseCategory; +}; + +export type GetTreeResponse = GetTreeResponses[keyof GetTreeResponses]; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-paginated-response/index.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-paginated-response/index.ts new file mode 100644 index 0000000000..0591554f62 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-paginated-response/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { ClientOptions, Group, ListGroupsData, ListGroupsErrors, ListGroupsResponse, ListGroupsResponses, ListUsersData, ListUsersErrors, ListUsersResponse, ListUsersResponses, PaginatedTemplate, User } from './types.gen'; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-paginated-response/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-paginated-response/types.gen.ts new file mode 100644 index 0000000000..9e05dd8da8 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-paginated-response/types.gen.ts @@ -0,0 +1,68 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.dynamicref.test' | (string & {}); +}; + +export type User = { + id: string; + email: string; +}; + +export type Group = { + id: string; + name: string; +}; + +export type PaginatedTemplate = { + items: Array; + total: number; + page: number; + pageSize: number; +}; + +export type ListUsersData = { + body?: never; + path?: never; + query?: never; + url: '/users'; +}; + +export type ListUsersErrors = { + /** + * Error response + */ + default: unknown; +}; + +export type ListUsersResponses = { + /** + * User page + */ + 200: PaginatedTemplate; +}; + +export type ListUsersResponse = ListUsersResponses[keyof ListUsersResponses]; + +export type ListGroupsData = { + body?: never; + path?: never; + query?: never; + url: '/groups'; +}; + +export type ListGroupsErrors = { + /** + * Error response + */ + default: unknown; +}; + +export type ListGroupsResponses = { + /** + * Group page + */ + 200: PaginatedTemplate; +}; + +export type ListGroupsResponse = ListGroupsResponses[keyof ListGroupsResponses]; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-recursive-category-tree/index.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-recursive-category-tree/index.ts new file mode 100644 index 0000000000..c7036d9417 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-recursive-category-tree/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { BaseCategory, ClientOptions, GetCategoryTreeData, GetCategoryTreeErrors, GetCategoryTreeResponse, GetCategoryTreeResponses, LocalizedCategory } from './types.gen'; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-recursive-category-tree/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-recursive-category-tree/types.gen.ts new file mode 100644 index 0000000000..62c8c3c064 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-recursive-category-tree/types.gen.ts @@ -0,0 +1,38 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.dynamicref.test' | (string & {}); +}; + +export type BaseCategory = { + id: string; + children: Array; +}; + +export type LocalizedCategory = BaseCategory & { + displayName: string; + locale: string; +}; + +export type GetCategoryTreeData = { + body?: never; + path?: never; + query?: never; + url: '/categories/tree'; +}; + +export type GetCategoryTreeErrors = { + /** + * Error response + */ + default: unknown; +}; + +export type GetCategoryTreeResponses = { + /** + * Localized recursive category tree + */ + 200: LocalizedCategory; +}; + +export type GetCategoryTreeResponse = GetCategoryTreeResponses[keyof GetCategoryTreeResponses]; diff --git a/packages/shared/src/openApi/3.1.x/parser/schema.ts b/packages/shared/src/openApi/3.1.x/parser/schema.ts index 5ad794f63a..c6f304ef05 100644 --- a/packages/shared/src/openApi/3.1.x/parser/schema.ts +++ b/packages/shared/src/openApi/3.1.x/parser/schema.ts @@ -1357,6 +1357,97 @@ function parseUnknown({ return irSchema; } +/** + * Builds a dynamic scope map by scanning a schema for $dynamicAnchor declarations. + * This implements the static approximation of JSON Schema 2020-12 dynamic scope resolution. + * + * The scope maps anchor names (e.g., "itemType") to their resolved $ref values. + * Anchors are found at the top level of the schema and in $defs. + * + * @example + * Schema with $dynamicAnchor at top level: + * { + * "$dynamicAnchor": "itemType", + * "$ref": "#/components/schemas/User" + * } + * Results in: { itemType: "#/components/schemas/User" } + * + * @example + * Schema with $dynamicAnchor in $defs: + * { + * "$defs": { + * "itemType": { + * "$dynamicAnchor": "itemType", + * "$ref": "#/components/schemas/User" + * } + * } + * } + * Results in: { itemType: "#/components/schemas/User" } + */ +function hasDynamicRefBindings(schema: OpenAPIV3_1.SchemaObject): boolean { + if (!schema.$defs) return false; + return Object.values(schema.$defs).some( + (defSchema) => + defSchema && + typeof defSchema === 'object' && + !Array.isArray(defSchema) && + (defSchema as OpenAPIV3_1.SchemaObject).$dynamicAnchor && + (defSchema as OpenAPIV3_1.SchemaObject).$ref, + ); +} + +function buildDynamicScope( + schema: OpenAPIV3_1.SchemaObject, + schemaRef?: string, + allSchemas?: Record, +): Record { + const scope: Record = {}; + + if (schema.$dynamicAnchor) { + if (schema.$ref) { + scope[schema.$dynamicAnchor] = schema.$ref; + } else if (schemaRef) { + scope[schema.$dynamicAnchor] = schemaRef; + } + } + + 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; + } + } + } + } + + if (allSchemas && schemaRef) { + const ownAnchor = schema.$dynamicAnchor; + const siblingAnchors: Record = {}; + + for (const [siblingName, siblingSchema] of Object.entries(allSchemas)) { + if (siblingName === refToName(schemaRef)) continue; + if ( + siblingSchema && + typeof siblingSchema === 'object' && + typeof siblingSchema.$dynamicAnchor === 'string' + ) { + const anchor = siblingSchema.$dynamicAnchor; + siblingAnchors[anchor] = [...(siblingAnchors[anchor] ?? []), siblingName]; + } + } + + for (const [anchor, siblingNames] of Object.entries(siblingAnchors)) { + if (!(anchor in scope) && anchor !== ownAnchor && siblingNames.length === 1) { + scope[anchor] = `#/components/schemas/${siblingNames[0]}`; + } + } + } + + return scope; +} + export function schemaToIrSchema({ context, schema, @@ -1369,7 +1460,19 @@ export function schemaToIrSchema({ if (!state) { state = { circularReferenceTracker: new Set(), + dynamicScope: buildDynamicScope(schema), }; + } else { + // When encountering a schema with $defs, augment the dynamic scope. + // This allows response-level $defs to be in scope when following $refs + // to referenced schemas. Later $defs take precedence. + const newBindings = buildDynamicScope(schema); + if (Object.keys(newBindings).length > 0) { + state.dynamicScope = { + ...state.dynamicScope, + ...newBindings, + }; + } } if (state.$ref) { @@ -1384,6 +1487,31 @@ export function schemaToIrSchema({ }); } + if (schema.$dynamicRef) { + // Extract the anchor name from the $dynamicRef (e.g., "#itemType" -> "itemType") + const anchorName = schema.$dynamicRef.startsWith('#') + ? schema.$dynamicRef.slice(1) + : schema.$dynamicRef; + + // Look up the anchor in the dynamic scope + const resolvedRef = state.dynamicScope?.[anchorName]; + + if (resolvedRef) { + // Emit a synthetic $ref to the resolved type + return parseRef({ + context, + schema: { + ...schema, + $ref: resolvedRef, + } as SchemaWithRequired, + state, + }); + } + + // If no scope match found, fall back to unknown + return parseUnknown({ context, schema }); + } + if (schema.enum) { return parseEnum({ context, @@ -1454,12 +1582,46 @@ export function parseSchema({ context.ir.components.schemas = {}; } - context.ir.components.schemas[refToName($ref)] = schemaToIrSchema({ - context, - schema, - state: { - $ref, - circularReferenceTracker: new Set(), - }, - }); + const allSchemas = (context.spec as Record).components?.schemas as + | Record + | undefined; + + const dynamicScope = buildDynamicScope(schema, $ref, allSchemas); + + const shouldMaterialize = + schema.$ref && + schema.$defs && + hasDynamicRefBindings(schema) && + isTopLevelComponent(schema.$ref); + + if (shouldMaterialize) { + const refSchema = context.resolveRef(schema.$ref!); + const materializedSchema: OpenAPIV3_1.SchemaObject = { + ...refSchema, + ...schema, + }; + delete (materializedSchema as Record).$ref; + delete (materializedSchema as Record).$dynamicAnchor; + delete (materializedSchema as Record).$id; + + context.ir.components.schemas[refToName($ref)] = schemaToIrSchema({ + context, + schema: materializedSchema, + state: { + $ref, + circularReferenceTracker: new Set(), + dynamicScope, + }, + }); + } else { + context.ir.components.schemas[refToName($ref)] = schemaToIrSchema({ + context, + schema, + state: { + $ref, + circularReferenceTracker: new Set(), + dynamicScope, + }, + }); + } } diff --git a/packages/shared/src/openApi/shared/types/schema.ts b/packages/shared/src/openApi/shared/types/schema.ts index f52469bb96..7ab9097968 100644 --- a/packages/shared/src/openApi/shared/types/schema.ts +++ b/packages/shared/src/openApi/shared/types/schema.ts @@ -16,6 +16,13 @@ export interface SchemaState { * properties from other schemas in the composition. */ inAllOf?: boolean; + /** + * Map of dynamic anchor names to their resolved type references. This is used + * to resolve $dynamicRef keywords according to JSON Schema 2020-12 dynamic + * scope rules. The map stores anchor names (e.g., "itemType") to $ref values + * (e.g., "#/components/schemas/User"). + */ + dynamicScope?: Record; } export type SchemaWithRequired< diff --git a/packages/spec-types/src/json-schema/draft-2020-12/spec.ts b/packages/spec-types/src/json-schema/draft-2020-12/spec.ts index 9b203ab7ed..e5e303acd5 100644 --- a/packages/spec-types/src/json-schema/draft-2020-12/spec.ts +++ b/packages/spec-types/src/json-schema/draft-2020-12/spec.ts @@ -13,6 +13,22 @@ export interface BaseDocument * The `$ref` keyword may be used to create recursive schemas that refer to themselves. */ $ref?: string; + /** + * The `$dynamicRef` keyword is like `$ref`, but enables dynamic scope resolution based on `$dynamicAnchor` declarations. This allows template schemas to resolve types based on the context in which they are referenced, enabling generic type support. + * + * {@link https://json-schema.org/draft/2020-12/json-schema-core#section-7.7 Dynamic References} + */ + $dynamicRef?: string; + /** + * The `$dynamicAnchor` keyword marks a location in a schema that can be resolved by `$dynamicRef`. It associates a name with a schema node, allowing that node to be dynamically resolved in the scope of the referencing schema. + * + * {@link https://json-schema.org/draft/2020-12/json-schema-core#section-7.7 Dynamic References} + */ + $dynamicAnchor?: string; + /** + * The `$defs` keyword is used to define schema definitions that can be referenced elsewhere in the schema using `$ref`, `$dynamicRef`, or other reference keywords. This allows for schema reuse and helps reduce duplication. + */ + $defs?: Record; /** * `allOf`: (AND) Must be valid against _all_ of the {@link https://json-schema.org/learn/glossary#subschema subschemas} * diff --git a/specs/3.1.x/dynamicref-external-ref.yaml b/specs/3.1.x/dynamicref-external-ref.yaml new file mode 100644 index 0000000000..33b302b9a8 --- /dev/null +++ b/specs/3.1.x/dynamicref-external-ref.yaml @@ -0,0 +1,37 @@ +openapi: 3.1.0 +info: + title: DynamicRef External Reference Test + version: 0.1.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.dynamicref.test +security: [] +paths: + /containers: + get: + summary: Get containers + operationId: getContainers + tags: [Containers] + responses: + '200': + description: Container list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Container' + default: + description: Error response +components: + schemas: + Container: + type: object + required: [id, item] + properties: + id: + type: string + item: + $dynamicRef: 'other.json#node' diff --git a/specs/3.1.x/dynamicref-generic-schema-binding.yaml b/specs/3.1.x/dynamicref-generic-schema-binding.yaml new file mode 100644 index 0000000000..26fa977374 --- /dev/null +++ b/specs/3.1.x/dynamicref-generic-schema-binding.yaml @@ -0,0 +1,94 @@ +openapi: 3.1.0 +info: + title: DynamicRef Generic Schema Binding API + version: 0.1.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.dynamicref.test +security: [] +paths: + /users: + get: + summary: List users + operationId: listUsers + tags: [Users] + responses: + '200': + description: User page + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedUserResponse' + default: + description: Error response + /groups: + get: + summary: List groups + operationId: listGroups + tags: [Groups] + responses: + '200': + description: Group page + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedGroupResponse' + default: + description: Error response +components: + schemas: + User: + type: object + required: [id, email] + properties: + id: + type: string + email: + type: string + format: email + Group: + type: object + required: [id, name] + properties: + id: + type: string + name: + type: string + PaginatedTemplate: + $id: https://example.com/schemas/PaginatedTemplate + $defs: + itemType: + $dynamicAnchor: itemType + not: {} + type: object + required: [items, total, page, pageSize] + properties: + items: + type: array + items: + $dynamicRef: '#itemType' + total: + type: integer + minimum: 0 + page: + type: integer + minimum: 1 + pageSize: + type: integer + minimum: 1 + PaginatedUserResponse: + $id: https://example.com/schemas/PaginatedUserResponse + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/User' + $ref: '#/components/schemas/PaginatedTemplate' + PaginatedGroupResponse: + $id: https://example.com/schemas/PaginatedGroupResponse + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Group' + $ref: '#/components/schemas/PaginatedTemplate' diff --git a/specs/3.1.x/dynamicref-nested-workspace-resources.yaml b/specs/3.1.x/dynamicref-nested-workspace-resources.yaml new file mode 100644 index 0000000000..f4d46534f2 --- /dev/null +++ b/specs/3.1.x/dynamicref-nested-workspace-resources.yaml @@ -0,0 +1,93 @@ +openapi: 3.1.0 +info: + title: DynamicRef Nested Workspace API + version: 0.1.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.dynamicref.test +security: [] +paths: + /workspaces/current: + get: + summary: Get workspace + operationId: getWorkspace + tags: [Workspace] + responses: + '200': + description: Workspace with nested folders and linked resources + content: + application/json: + schema: + $ref: '#/components/schemas/WorkspaceResponse' + default: + description: Error response +components: + schemas: + Document: + type: object + required: [kind, id, title] + properties: + kind: + const: document + id: + type: string + title: + type: string + BaseFolder: + $id: https://example.com/schemas/BaseFolder + $dynamicAnchor: folder + type: object + required: [kind, id, name, children, shortcuts] + properties: + kind: + const: folder + id: + type: string + name: + type: string + children: + type: array + items: + oneOf: + - $ref: '#/components/schemas/Document' + - $dynamicRef: '#folder' + shortcuts: + type: array + items: + $dynamicRef: '#resource' + BaseResource: + $id: https://example.com/schemas/BaseResource + $dynamicAnchor: resource + oneOf: + - $ref: '#/components/schemas/Document' + - $dynamicRef: '#folder' + WorkspaceFolder: + $id: https://example.com/schemas/WorkspaceFolder + $dynamicAnchor: folder + allOf: + - $ref: '#/components/schemas/BaseFolder' + - type: object + required: [permissions] + properties: + permissions: + type: array + items: + enum: [read, write, admin] + WorkspaceResource: + $id: https://example.com/schemas/WorkspaceResource + $dynamicAnchor: resource + oneOf: + - $ref: '#/components/schemas/Document' + - $ref: '#/components/schemas/WorkspaceFolder' + WorkspaceResponse: + type: object + required: [root, related] + properties: + root: + $ref: '#/components/schemas/WorkspaceFolder' + related: + type: array + items: + $ref: '#/components/schemas/WorkspaceResource' diff --git a/specs/3.1.x/dynamicref-non-identifier-key.yaml b/specs/3.1.x/dynamicref-non-identifier-key.yaml new file mode 100644 index 0000000000..88b888ca67 --- /dev/null +++ b/specs/3.1.x/dynamicref-non-identifier-key.yaml @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: DynamicRef Non-Identifier Key Test + version: 0.1.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.dynamicref.test +security: [] +paths: + /tree: + get: + summary: Get tree + operationId: getTree + tags: [Tree] + responses: + '200': + description: Tree node + content: + application/json: + schema: + $ref: '#/components/schemas/base-category' + default: + description: Error response +components: + schemas: + base-category: + $dynamicAnchor: category + type: object + required: [id, children] + properties: + id: + type: string + children: + type: array + items: + $dynamicRef: '#category' diff --git a/specs/3.1.x/dynamicref-paginated-response.yaml b/specs/3.1.x/dynamicref-paginated-response.yaml new file mode 100644 index 0000000000..0f72ec673e --- /dev/null +++ b/specs/3.1.x/dynamicref-paginated-response.yaml @@ -0,0 +1,88 @@ +openapi: 3.1.0 +info: + title: DynamicRef Paginated Response API + version: 0.1.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.dynamicref.test +security: [] +paths: + /users: + get: + summary: List users + operationId: listUsers + tags: [Users] + responses: + '200': + description: User page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/User' + $ref: '#/components/schemas/PaginatedTemplate' + default: + description: Error response + /groups: + get: + summary: List groups + operationId: listGroups + tags: [Groups] + responses: + '200': + description: Group page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Group' + $ref: '#/components/schemas/PaginatedTemplate' + default: + description: Error response +components: + schemas: + User: + type: object + required: [id, email] + properties: + id: + type: string + email: + type: string + format: email + Group: + type: object + required: [id, name] + properties: + id: + type: string + name: + type: string + PaginatedTemplate: + $id: https://example.com/schemas/PaginatedTemplate + $defs: + itemType: + $dynamicAnchor: itemType + not: {} + type: object + required: [items, total, page, pageSize] + properties: + items: + type: array + items: + $dynamicRef: '#itemType' + total: + type: integer + minimum: 0 + page: + type: integer + minimum: 1 + pageSize: + type: integer + minimum: 1 diff --git a/specs/3.1.x/dynamicref-recursive-category-tree.yaml b/specs/3.1.x/dynamicref-recursive-category-tree.yaml new file mode 100644 index 0000000000..0c506b0c50 --- /dev/null +++ b/specs/3.1.x/dynamicref-recursive-category-tree.yaml @@ -0,0 +1,51 @@ +openapi: 3.1.0 +info: + title: DynamicRef Recursive Category API + version: 0.1.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.dynamicref.test +security: [] +paths: + /categories/tree: + get: + summary: Get category tree + operationId: getCategoryTree + tags: [Categories] + responses: + '200': + description: Localized recursive category tree + content: + application/json: + schema: + $ref: '#/components/schemas/LocalizedCategory' + default: + description: Error response +components: + schemas: + BaseCategory: + $id: https://example.com/schemas/BaseCategory + $dynamicAnchor: category + type: object + required: [id, children] + properties: + id: + type: string + children: + type: array + items: + $dynamicRef: '#category' + LocalizedCategory: + $id: https://example.com/schemas/LocalizedCategory + $dynamicAnchor: category + allOf: + - $ref: '#/components/schemas/BaseCategory' + - type: object + required: [displayName, locale] + properties: + displayName: + type: string + locale: + type: string From 30d6e0140b5e4ebc42af172460905f1c8ed51d5f Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 15 May 2026 12:26:27 +0300 Subject: [PATCH 2/9] fix: handle dynamicRef scope isolation --- .../openapi-ts-tests/main/test/3.1.x.test.ts | 7 + .../types.gen.ts | 14 +- .../3.1.x/dynamicref-scope-isolation/index.ts | 3 + .../dynamicref-scope-isolation/types.gen.ts | 38 ++++++ .../shared/src/openApi/3.1.x/parser/schema.ts | 129 +++++++++--------- .../shared/src/openApi/shared/types/schema.ts | 14 +- .../src/json-schema/draft-2020-12/spec.ts | 20 +-- specs/3.1.x/dynamicref-scope-isolation.yaml | 50 +++++++ 8 files changed, 194 insertions(+), 81 deletions(-) create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-scope-isolation/index.ts create mode 100644 packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-scope-isolation/types.gen.ts create mode 100644 specs/3.1.x/dynamicref-scope-isolation.yaml diff --git a/packages/openapi-ts-tests/main/test/3.1.x.test.ts b/packages/openapi-ts-tests/main/test/3.1.x.test.ts index 5437a638dd..5dcf7ccc32 100644 --- a/packages/openapi-ts-tests/main/test/3.1.x.test.ts +++ b/packages/openapi-ts-tests/main/test/3.1.x.test.ts @@ -1057,6 +1057,13 @@ describe(`OpenAPI ${version}`, () => { }), description: 'handles non-identifier schema keys with $dynamicAnchor', }, + { + config: createConfig({ + input: 'dynamicref-scope-isolation.yaml', + output: 'dynamicref-scope-isolation', + }), + description: 'keeps $dynamicRef bindings isolated between sibling schemas', + }, ]; it.each(scenarios)('$description', async ({ config }) => { diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-paginated-response/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-paginated-response/types.gen.ts index 9e05dd8da8..a9736ea5bf 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-paginated-response/types.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-paginated-response/types.gen.ts @@ -39,7 +39,12 @@ export type ListUsersResponses = { /** * User page */ - 200: PaginatedTemplate; + 200: { + items: Array; + total: number; + page: number; + pageSize: number; + }; }; export type ListUsersResponse = ListUsersResponses[keyof ListUsersResponses]; @@ -62,7 +67,12 @@ export type ListGroupsResponses = { /** * Group page */ - 200: PaginatedTemplate; + 200: { + items: Array; + total: number; + page: number; + pageSize: number; + }; }; export type ListGroupsResponse = ListGroupsResponses[keyof ListGroupsResponses]; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-scope-isolation/index.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-scope-isolation/index.ts new file mode 100644 index 0000000000..a4d3f58c04 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-scope-isolation/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { ClientOptions, GetScopeData, GetScopeErrors, GetScopeResponse, GetScopeResponses, ScopeIsolationResponse, User } from './types.gen'; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-scope-isolation/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-scope-isolation/types.gen.ts new file mode 100644 index 0000000000..13084ca617 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-scope-isolation/types.gen.ts @@ -0,0 +1,38 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.dynamicref.test' | (string & {}); +}; + +export type User = { + id: string; + email: string; +}; + +export type ScopeIsolationResponse = { + boundItems: Array; + unboundItem: unknown; +}; + +export type GetScopeData = { + body?: never; + path?: never; + query?: never; + url: '/scope'; +}; + +export type GetScopeErrors = { + /** + * Error response + */ + default: unknown; +}; + +export type GetScopeResponses = { + /** + * Scope isolation example + */ + 200: ScopeIsolationResponse; +}; + +export type GetScopeResponse = GetScopeResponses[keyof GetScopeResponses]; diff --git a/packages/shared/src/openApi/3.1.x/parser/schema.ts b/packages/shared/src/openApi/3.1.x/parser/schema.ts index c6f304ef05..d420c0ced4 100644 --- a/packages/shared/src/openApi/3.1.x/parser/schema.ts +++ b/packages/shared/src/openApi/3.1.x/parser/schema.ts @@ -1448,6 +1448,34 @@ function buildDynamicScope( return scope; } +function materializeDynamicRefBinding({ + context, + schema, +}: { + context: Context; + schema: OpenAPIV3_1.SchemaObject; +}): OpenAPIV3_1.SchemaObject | undefined { + if ( + !schema.$ref || + !schema.$defs || + !hasDynamicRefBindings(schema) || + !isTopLevelComponent(schema.$ref) + ) { + return; + } + + const refSchema = context.resolveRef(schema.$ref); + const materializedSchema: OpenAPIV3_1.SchemaObject = { + ...refSchema, + ...schema, + }; + delete (materializedSchema as Record).$ref; + delete (materializedSchema as Record).$dynamicAnchor; + delete (materializedSchema as Record).$id; + + return materializedSchema; +} + export function schemaToIrSchema({ context, schema, @@ -1457,33 +1485,37 @@ export function schemaToIrSchema({ schema: OpenAPIV3_1.SchemaObject; state: SchemaState | undefined; }): IR.SchemaObject { - if (!state) { - state = { - circularReferenceTracker: new Set(), - dynamicScope: buildDynamicScope(schema), - }; - } else { - // When encountering a schema with $defs, augment the dynamic scope. - // This allows response-level $defs to be in scope when following $refs - // to referenced schemas. Later $defs take precedence. - const newBindings = buildDynamicScope(schema); - if (Object.keys(newBindings).length > 0) { - state.dynamicScope = { - ...state.dynamicScope, - ...newBindings, + const currentState: SchemaState = state + ? { + ...state, + dynamicScope: { + ...state.dynamicScope, + ...buildDynamicScope(schema), + }, + } + : { + circularReferenceTracker: new Set(), + dynamicScope: buildDynamicScope(schema), }; - } + + if (currentState.$ref) { + currentState.circularReferenceTracker.add(currentState.$ref); } - if (state.$ref) { - state.circularReferenceTracker.add(state.$ref); + const materializedSchema = materializeDynamicRefBinding({ context, schema }); + if (materializedSchema) { + return schemaToIrSchema({ + context, + schema: materializedSchema, + state: currentState, + }); } if (schema.$ref) { return parseRef({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1494,7 +1526,7 @@ export function schemaToIrSchema({ : schema.$dynamicRef; // Look up the anchor in the dynamic scope - const resolvedRef = state.dynamicScope?.[anchorName]; + const resolvedRef = currentState.dynamicScope?.[anchorName]; if (resolvedRef) { // Emit a synthetic $ref to the resolved type @@ -1504,7 +1536,7 @@ export function schemaToIrSchema({ ...schema, $ref: resolvedRef, } as SchemaWithRequired, - state, + state: currentState, }); } @@ -1516,7 +1548,7 @@ export function schemaToIrSchema({ return parseEnum({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1524,7 +1556,7 @@ export function schemaToIrSchema({ return parseAllOf({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1532,7 +1564,7 @@ export function schemaToIrSchema({ return parseAnyOf({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1540,7 +1572,7 @@ export function schemaToIrSchema({ return parseOneOf({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1549,7 +1581,7 @@ export function schemaToIrSchema({ return parseType({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1558,7 +1590,7 @@ export function schemaToIrSchema({ return parseType({ context, schema: { ...schema, type: 'string' } as SchemaWithRequired, - state, + state: currentState, }); } @@ -1588,40 +1620,13 @@ export function parseSchema({ const dynamicScope = buildDynamicScope(schema, $ref, allSchemas); - const shouldMaterialize = - schema.$ref && - schema.$defs && - hasDynamicRefBindings(schema) && - isTopLevelComponent(schema.$ref); - - if (shouldMaterialize) { - const refSchema = context.resolveRef(schema.$ref!); - const materializedSchema: OpenAPIV3_1.SchemaObject = { - ...refSchema, - ...schema, - }; - delete (materializedSchema as Record).$ref; - delete (materializedSchema as Record).$dynamicAnchor; - delete (materializedSchema as Record).$id; - - context.ir.components.schemas[refToName($ref)] = schemaToIrSchema({ - context, - schema: materializedSchema, - state: { - $ref, - circularReferenceTracker: new Set(), - dynamicScope, - }, - }); - } else { - context.ir.components.schemas[refToName($ref)] = schemaToIrSchema({ - context, - schema, - state: { - $ref, - circularReferenceTracker: new Set(), - dynamicScope, - }, - }); - } + context.ir.components.schemas[refToName($ref)] = schemaToIrSchema({ + context, + schema, + state: { + $ref, + circularReferenceTracker: new Set(), + dynamicScope, + }, + }); } diff --git a/packages/shared/src/openApi/shared/types/schema.ts b/packages/shared/src/openApi/shared/types/schema.ts index 7ab9097968..1b40744700 100644 --- a/packages/shared/src/openApi/shared/types/schema.ts +++ b/packages/shared/src/openApi/shared/types/schema.ts @@ -9,13 +9,6 @@ export interface SchemaState { * avoid infinite loops when resolving schemas with circular references. */ circularReferenceTracker: Set; - /** - * True if current schema is part of an allOf composition. This is used to - * avoid emitting [key: string]: never for empty objects with - * additionalProperties: false inside allOf, which would override inherited - * properties from other schemas in the composition. - */ - inAllOf?: boolean; /** * Map of dynamic anchor names to their resolved type references. This is used * to resolve $dynamicRef keywords according to JSON Schema 2020-12 dynamic @@ -23,6 +16,13 @@ export interface SchemaState { * (e.g., "#/components/schemas/User"). */ dynamicScope?: Record; + /** + * True if current schema is part of an allOf composition. This is used to + * avoid emitting [key: string]: never for empty objects with + * additionalProperties: false inside allOf, which would override inherited + * properties from other schemas in the composition. + */ + inAllOf?: boolean; } export type SchemaWithRequired< diff --git a/packages/spec-types/src/json-schema/draft-2020-12/spec.ts b/packages/spec-types/src/json-schema/draft-2020-12/spec.ts index e5e303acd5..e289c44a5b 100644 --- a/packages/spec-types/src/json-schema/draft-2020-12/spec.ts +++ b/packages/spec-types/src/json-schema/draft-2020-12/spec.ts @@ -8,27 +8,27 @@ export interface BaseDocument */ $comment?: string; /** - * A schema can reference another schema using the `$ref` keyword. The value of `$ref` is a URI-reference that is resolved against the schema's {@link https://json-schema.org/understanding-json-schema/structuring#base-uri Base URI}. When evaluating a `$ref`, an implementation uses the resolved identifier to retrieve the referenced schema and applies that schema to the {@link https://json-schema.org/learn/glossary#instance instance}. - * - * The `$ref` keyword may be used to create recursive schemas that refer to themselves. + * The `$defs` keyword is used to define schema definitions that can be referenced elsewhere in the schema using `$ref`, `$dynamicRef`, or other reference keywords. This allows for schema reuse and helps reduce duplication. */ - $ref?: string; + $defs?: Record; /** - * The `$dynamicRef` keyword is like `$ref`, but enables dynamic scope resolution based on `$dynamicAnchor` declarations. This allows template schemas to resolve types based on the context in which they are referenced, enabling generic type support. + * The `$dynamicAnchor` keyword marks a location in a schema that can be resolved by `$dynamicRef`. It associates a name with a schema node, allowing that node to be dynamically resolved in the scope of the referencing schema. * * {@link https://json-schema.org/draft/2020-12/json-schema-core#section-7.7 Dynamic References} */ - $dynamicRef?: string; + $dynamicAnchor?: string; /** - * The `$dynamicAnchor` keyword marks a location in a schema that can be resolved by `$dynamicRef`. It associates a name with a schema node, allowing that node to be dynamically resolved in the scope of the referencing schema. + * The `$dynamicRef` keyword is like `$ref`, but enables dynamic scope resolution based on `$dynamicAnchor` declarations. This allows template schemas to resolve types based on the context in which they are referenced, enabling generic type support. * * {@link https://json-schema.org/draft/2020-12/json-schema-core#section-7.7 Dynamic References} */ - $dynamicAnchor?: string; + $dynamicRef?: string; /** - * The `$defs` keyword is used to define schema definitions that can be referenced elsewhere in the schema using `$ref`, `$dynamicRef`, or other reference keywords. This allows for schema reuse and helps reduce duplication. + * A schema can reference another schema using the `$ref` keyword. The value of `$ref` is a URI-reference that is resolved against the schema's {@link https://json-schema.org/understanding-json-schema/structuring#base-uri Base URI}. When evaluating a `$ref`, an implementation uses the resolved identifier to retrieve the referenced schema and applies that schema to the {@link https://json-schema.org/learn/glossary#instance instance}. + * + * The `$ref` keyword may be used to create recursive schemas that refer to themselves. */ - $defs?: Record; + $ref?: string; /** * `allOf`: (AND) Must be valid against _all_ of the {@link https://json-schema.org/learn/glossary#subschema subschemas} * diff --git a/specs/3.1.x/dynamicref-scope-isolation.yaml b/specs/3.1.x/dynamicref-scope-isolation.yaml new file mode 100644 index 0000000000..478ec7b57b --- /dev/null +++ b/specs/3.1.x/dynamicref-scope-isolation.yaml @@ -0,0 +1,50 @@ +openapi: 3.1.0 +info: + title: DynamicRef Scope Isolation API + version: 0.1.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.dynamicref.test +security: [] +paths: + /scope: + get: + summary: Get scope isolation example + operationId: getScope + tags: [Scope] + responses: + '200': + description: Scope isolation example + content: + application/json: + schema: + $ref: '#/components/schemas/ScopeIsolationResponse' + default: + description: Error response +components: + schemas: + User: + type: object + required: [id, email] + properties: + id: + type: string + email: + type: string + format: email + ScopeIsolationResponse: + type: object + required: [boundItems, unboundItem] + properties: + boundItems: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/User' + type: array + items: + $dynamicRef: '#itemType' + unboundItem: + $dynamicRef: '#itemType' From 2cce78c34f463a1b70a430a6cdf33bcc749a6d9c Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 15 May 2026 14:34:18 +0300 Subject: [PATCH 3/9] fix: preserve dynamic ref scope through refs --- .../types.gen.ts | 8 ++++++- .../types.gen.ts | 5 ++++- .../shared/src/openApi/3.1.x/parser/schema.ts | 21 +++++++++++++++++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-nested-workspace-resources/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-nested-workspace-resources/types.gen.ts index 3f25974010..8ee9767868 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-nested-workspace-resources/types.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-nested-workspace-resources/types.gen.ts @@ -20,7 +20,13 @@ export type BaseFolder = { export type BaseResource = Document | unknown; -export type WorkspaceFolder = BaseFolder & { +export type WorkspaceFolder = { + kind: 'folder'; + id: string; + name: string; + children: Array; + shortcuts: Array; +} & { permissions: Array<'read' | 'write' | 'admin'>; }; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-recursive-category-tree/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-recursive-category-tree/types.gen.ts index 62c8c3c064..01d29f1787 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-recursive-category-tree/types.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-recursive-category-tree/types.gen.ts @@ -9,7 +9,10 @@ export type BaseCategory = { children: Array; }; -export type LocalizedCategory = BaseCategory & { +export type LocalizedCategory = { + id: string; + children: Array; +} & { displayName: string; locale: string; }; diff --git a/packages/shared/src/openApi/3.1.x/parser/schema.ts b/packages/shared/src/openApi/3.1.x/parser/schema.ts index d420c0ced4..c27cd8f975 100644 --- a/packages/shared/src/openApi/3.1.x/parser/schema.ts +++ b/packages/shared/src/openApi/3.1.x/parser/schema.ts @@ -1144,6 +1144,24 @@ function parseRef({ // Fallback to preserving the ref if circular } + const refSchema = context.resolveRef(schema.$ref); + if ( + refSchema.$dynamicAnchor && + state.dynamicScope?.[refSchema.$dynamicAnchor] && + state.dynamicScope[refSchema.$dynamicAnchor] !== schema.$ref && + !state.circularReferenceTracker.has(schema.$ref) + ) { + const originalRef = state.$ref; + state.$ref = schema.$ref; + const irSchema = schemaToIrSchema({ + context, + schema: refSchema, + state, + }); + state.$ref = originalRef; + return irSchema; + } + let irSchema = initIrSchema({ schema }); parseSchemaMeta({ irSchema, schema }); @@ -1152,7 +1170,6 @@ function parseRef({ irRefSchema.$ref = schema.$ref; if (!state.circularReferenceTracker.has(schema.$ref)) { - const refSchema = context.resolveRef(schema.$ref); const originalRef = state.$ref; state.$ref = schema.$ref; schemaToIrSchema({ @@ -1489,8 +1506,8 @@ export function schemaToIrSchema({ ? { ...state, dynamicScope: { - ...state.dynamicScope, ...buildDynamicScope(schema), + ...state.dynamicScope, }, } : { From cb3587376b547fe04ba8d04eb88d75ee51bb3a6a Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 15 May 2026 15:53:16 +0300 Subject: [PATCH 4/9] fix: avoid sibling dynamic ref bindings --- .../shared/src/openApi/3.1.x/parser/schema.ts | 30 +------------------ 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/packages/shared/src/openApi/3.1.x/parser/schema.ts b/packages/shared/src/openApi/3.1.x/parser/schema.ts index c27cd8f975..d915abf9ec 100644 --- a/packages/shared/src/openApi/3.1.x/parser/schema.ts +++ b/packages/shared/src/openApi/3.1.x/parser/schema.ts @@ -1416,7 +1416,6 @@ function hasDynamicRefBindings(schema: OpenAPIV3_1.SchemaObject): boolean { function buildDynamicScope( schema: OpenAPIV3_1.SchemaObject, schemaRef?: string, - allSchemas?: Record, ): Record { const scope: Record = {}; @@ -1439,29 +1438,6 @@ function buildDynamicScope( } } - if (allSchemas && schemaRef) { - const ownAnchor = schema.$dynamicAnchor; - const siblingAnchors: Record = {}; - - for (const [siblingName, siblingSchema] of Object.entries(allSchemas)) { - if (siblingName === refToName(schemaRef)) continue; - if ( - siblingSchema && - typeof siblingSchema === 'object' && - typeof siblingSchema.$dynamicAnchor === 'string' - ) { - const anchor = siblingSchema.$dynamicAnchor; - siblingAnchors[anchor] = [...(siblingAnchors[anchor] ?? []), siblingName]; - } - } - - for (const [anchor, siblingNames] of Object.entries(siblingAnchors)) { - if (!(anchor in scope) && anchor !== ownAnchor && siblingNames.length === 1) { - scope[anchor] = `#/components/schemas/${siblingNames[0]}`; - } - } - } - return scope; } @@ -1631,11 +1607,7 @@ export function parseSchema({ context.ir.components.schemas = {}; } - const allSchemas = (context.spec as Record).components?.schemas as - | Record - | undefined; - - const dynamicScope = buildDynamicScope(schema, $ref, allSchemas); + const dynamicScope = buildDynamicScope(schema, $ref); context.ir.components.schemas[refToName($ref)] = schemaToIrSchema({ context, From 518fb1ddb1be935188be5794c275a98438bda0c4 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 15 May 2026 17:26:09 +0300 Subject: [PATCH 5/9] refactor: extract dynamicRef helpers, add docs, add changeset - 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 --- .changeset/dynamicref-support.md | 6 + .../src/openApi/3.1.x/parser/dynamicRef.ts | 115 +++++++++++++++ .../shared/src/openApi/3.1.x/parser/schema.ts | 132 +++--------------- .../openapi/typescript/plugins/typescript.mdx | 44 ++++++ 4 files changed, 185 insertions(+), 112 deletions(-) create mode 100644 .changeset/dynamicref-support.md create mode 100644 packages/shared/src/openApi/3.1.x/parser/dynamicRef.ts diff --git a/.changeset/dynamicref-support.md b/.changeset/dynamicref-support.md new file mode 100644 index 0000000000..ab6d4b37da --- /dev/null +++ b/.changeset/dynamicref-support.md @@ -0,0 +1,6 @@ +--- +"@hey-api/shared": patch +"@hey-api/spec-types": patch +--- + +add `$dynamicRef` / `$dynamicAnchor` schema resolution for OpenAPI 3.1 diff --git a/packages/shared/src/openApi/3.1.x/parser/dynamicRef.ts b/packages/shared/src/openApi/3.1.x/parser/dynamicRef.ts new file mode 100644 index 0000000000..a0bec56b46 --- /dev/null +++ b/packages/shared/src/openApi/3.1.x/parser/dynamicRef.ts @@ -0,0 +1,115 @@ +import type { OpenAPIV3_1 } from '@hey-api/spec-types'; + +import type { Context } from '../../../ir/context'; +import type { SchemaState } from '../../../openApi/shared/types/schema'; +import { isTopLevelComponent } from '../../../utils/ref'; + +const isSchemaObject = (value: unknown): value is OpenAPIV3_1.SchemaObject => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +export function hasDynamicRefBindings(schema: OpenAPIV3_1.SchemaObject): boolean { + if (!schema.$defs) return false; + return Object.values(schema.$defs).some( + (defSchema) => + isSchemaObject(defSchema) && + Boolean(defSchema.$dynamicAnchor) && + Boolean(defSchema.$ref), + ); +} + +export function buildDynamicScope( + schema: OpenAPIV3_1.SchemaObject, + schemaRef?: string, +): Record { + const scope: Record = {}; + + if (schema.$dynamicAnchor) { + if (schema.$ref) { + scope[schema.$dynamicAnchor] = schema.$ref; + } else if (schemaRef) { + scope[schema.$dynamicAnchor] = schemaRef; + } + } + + if (schema.$defs) { + for (const defSchema of Object.values(schema.$defs)) { + if (isSchemaObject(defSchema) && defSchema.$dynamicAnchor && defSchema.$ref) { + scope[defSchema.$dynamicAnchor] = defSchema.$ref; + } + } + } + + return scope; +} + +export function buildCurrentDynamicScope({ + inheritedScope, + schema, +}: { + inheritedScope?: Record; + schema: OpenAPIV3_1.SchemaObject; +}): Record { + return { + ...buildDynamicScope(schema), + ...inheritedScope, + }; +} + +export function resolveDynamicRef({ + dynamicRef, + dynamicScope, +}: { + dynamicRef: string; + dynamicScope?: Record; +}): string | undefined { + if (!dynamicRef.startsWith('#') || dynamicRef.includes('/')) { + return; + } + + return dynamicScope?.[dynamicRef.slice(1)]; +} + +export function materializeDynamicRefBinding({ + context, + schema, +}: { + context: Context; + schema: OpenAPIV3_1.SchemaObject; +}): OpenAPIV3_1.SchemaObject | undefined { + if ( + !schema.$ref || + !schema.$defs || + !hasDynamicRefBindings(schema) || + !isTopLevelComponent(schema.$ref) + ) { + return; + } + + const refSchema = context.resolveRef(schema.$ref); + const materializedSchema: OpenAPIV3_1.SchemaObject = { + ...refSchema, + ...schema, + }; + delete (materializedSchema as Record).$ref; + delete (materializedSchema as Record).$dynamicAnchor; + delete (materializedSchema as Record).$id; + + return materializedSchema; +} + +export function shouldInlineDynamicRefTarget({ + ref, + refSchema, + state, +}: { + ref: string; + refSchema: OpenAPIV3_1.SchemaObject; + state: SchemaState; +}): boolean { + return Boolean( + refSchema.$dynamicAnchor && + state.dynamicScope?.[refSchema.$dynamicAnchor] && + state.dynamicScope[refSchema.$dynamicAnchor] !== ref && + !state.circularReferenceTracker.has(ref), + ); +} diff --git a/packages/shared/src/openApi/3.1.x/parser/schema.ts b/packages/shared/src/openApi/3.1.x/parser/schema.ts index d915abf9ec..30a6583c71 100644 --- a/packages/shared/src/openApi/3.1.x/parser/schema.ts +++ b/packages/shared/src/openApi/3.1.x/parser/schema.ts @@ -15,6 +15,13 @@ import { discriminatorValues, } from '../../../openApi/shared/utils/discriminator'; import { isTopLevelComponent, refToName } from '../../../utils/ref'; +import { + buildCurrentDynamicScope, + buildDynamicScope, + materializeDynamicRefBinding, + resolveDynamicRef, + shouldInlineDynamicRefTarget, +} from './dynamicRef'; export function getSchemaTypes({ schema, @@ -1145,12 +1152,7 @@ function parseRef({ } const refSchema = context.resolveRef(schema.$ref); - if ( - refSchema.$dynamicAnchor && - state.dynamicScope?.[refSchema.$dynamicAnchor] && - state.dynamicScope[refSchema.$dynamicAnchor] !== schema.$ref && - !state.circularReferenceTracker.has(schema.$ref) - ) { + if (shouldInlineDynamicRefTarget({ ref: schema.$ref, refSchema, state })) { const originalRef = state.$ref; state.$ref = schema.$ref; const irSchema = schemaToIrSchema({ @@ -1374,101 +1376,6 @@ function parseUnknown({ return irSchema; } -/** - * Builds a dynamic scope map by scanning a schema for $dynamicAnchor declarations. - * This implements the static approximation of JSON Schema 2020-12 dynamic scope resolution. - * - * The scope maps anchor names (e.g., "itemType") to their resolved $ref values. - * Anchors are found at the top level of the schema and in $defs. - * - * @example - * Schema with $dynamicAnchor at top level: - * { - * "$dynamicAnchor": "itemType", - * "$ref": "#/components/schemas/User" - * } - * Results in: { itemType: "#/components/schemas/User" } - * - * @example - * Schema with $dynamicAnchor in $defs: - * { - * "$defs": { - * "itemType": { - * "$dynamicAnchor": "itemType", - * "$ref": "#/components/schemas/User" - * } - * } - * } - * Results in: { itemType: "#/components/schemas/User" } - */ -function hasDynamicRefBindings(schema: OpenAPIV3_1.SchemaObject): boolean { - if (!schema.$defs) return false; - return Object.values(schema.$defs).some( - (defSchema) => - defSchema && - typeof defSchema === 'object' && - !Array.isArray(defSchema) && - (defSchema as OpenAPIV3_1.SchemaObject).$dynamicAnchor && - (defSchema as OpenAPIV3_1.SchemaObject).$ref, - ); -} - -function buildDynamicScope( - schema: OpenAPIV3_1.SchemaObject, - schemaRef?: string, -): Record { - const scope: Record = {}; - - if (schema.$dynamicAnchor) { - if (schema.$ref) { - scope[schema.$dynamicAnchor] = schema.$ref; - } else if (schemaRef) { - scope[schema.$dynamicAnchor] = schemaRef; - } - } - - 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; - } - } - } - } - - return scope; -} - -function materializeDynamicRefBinding({ - context, - schema, -}: { - context: Context; - schema: OpenAPIV3_1.SchemaObject; -}): OpenAPIV3_1.SchemaObject | undefined { - if ( - !schema.$ref || - !schema.$defs || - !hasDynamicRefBindings(schema) || - !isTopLevelComponent(schema.$ref) - ) { - return; - } - - const refSchema = context.resolveRef(schema.$ref); - const materializedSchema: OpenAPIV3_1.SchemaObject = { - ...refSchema, - ...schema, - }; - delete (materializedSchema as Record).$ref; - delete (materializedSchema as Record).$dynamicAnchor; - delete (materializedSchema as Record).$id; - - return materializedSchema; -} - export function schemaToIrSchema({ context, schema, @@ -1481,10 +1388,14 @@ export function schemaToIrSchema({ const currentState: SchemaState = state ? { ...state, - dynamicScope: { - ...buildDynamicScope(schema), - ...state.dynamicScope, - }, + // circularReferenceTracker intentionally shares the same Set instance + // with the parent state so circular refs are detected across the + // entire parsing tree. dynamicScope is always a fresh object so + // inner scopes don't mutate parent scope. + dynamicScope: buildCurrentDynamicScope({ + inheritedScope: state.dynamicScope, + schema, + }), } : { circularReferenceTracker: new Set(), @@ -1513,13 +1424,10 @@ export function schemaToIrSchema({ } if (schema.$dynamicRef) { - // Extract the anchor name from the $dynamicRef (e.g., "#itemType" -> "itemType") - const anchorName = schema.$dynamicRef.startsWith('#') - ? schema.$dynamicRef.slice(1) - : schema.$dynamicRef; - - // Look up the anchor in the dynamic scope - const resolvedRef = currentState.dynamicScope?.[anchorName]; + const resolvedRef = resolveDynamicRef({ + dynamicRef: schema.$dynamicRef, + dynamicScope: currentState.dynamicScope, + }); if (resolvedRef) { // Emit a synthetic $ref to the resolved type diff --git a/web/src/content/docs/docs/openapi/typescript/plugins/typescript.mdx b/web/src/content/docs/docs/openapi/typescript/plugins/typescript.mdx index 2414ed6e11..fd9e87ea39 100644 --- a/web/src/content/docs/docs/openapi/typescript/plugins/typescript.mdx +++ b/web/src/content/docs/docs/openapi/typescript/plugins/typescript.mdx @@ -174,6 +174,50 @@ export default { +## Dynamic References + +[JSON Schema 2020-12](https://json-schema.org/draft/2020-12/json-schema-core#section-7.1) introduced `$dynamicRef` and `$dynamicAnchor` for dynamic scope-aware schema resolution. OpenAPI 3.1 uses JSON Schema 2020-12 as its schema dialect, so your specs may use these keywords. + +`@hey-api/openapi-ts` automatically resolves `$dynamicRef` and `$dynamicAnchor` in OpenAPI 3.1 specs. No configuration is needed. + +### Recursive types + +Schemas that reference themselves via `$dynamicRef` produce correct recursive TypeScript types. + +```typescript +// Before (without dynamic reference resolution) +export type BaseCategory = { children: Array }; + +// After +export type BaseCategory = { children: Array }; +``` + +### Generic wrapper types + +Schemas that use `$dynamicRef` with `$defs` to create reusable template patterns produce concrete types instead of type aliases. + +```typescript +// Before +export type PaginatedUserResponse = PaginatedTemplate; + +// After +export type PaginatedUserResponse = { + items: Array; + total: number; + page: number; + pageSize: number; +}; +``` + +### Ambiguous references + +When multiple schemas in `components.schemas` declare the same `$dynamicAnchor` name, the reference is ambiguous and falls back to `unknown`. This is the correct behavior — the static analysis cannot determine which schema should be used. + +### Limitations + +- `$defs` bindings that define the type inline (without a `$ref`) are not resolved. Only `$defs` entries with both `$dynamicAnchor` and `$ref` are supported. To work around this, factor the bound schema out into `components.schemas` and reference it with `$ref`. +- Each schema in `components.schemas` is generated once with its own scope. Full call-site-aware resolution (where a schema resolves differently per endpoint) is not supported. + ## Resolvers You can further customize this plugin's behavior using [resolvers](/openapi-ts/plugins/concepts/resolvers). From f30ec86c4074a2e50314e39e69b7b6ff073bcdb6 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 15 May 2026 17:52:35 +0300 Subject: [PATCH 6/9] test: add unit tests for dynamicRef helpers --- .../3.1.x/parser/__tests__/dynamicRef.test.ts | 515 ++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts diff --git a/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts b/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts new file mode 100644 index 0000000000..edd665448c --- /dev/null +++ b/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts @@ -0,0 +1,515 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + buildCurrentDynamicScope, + buildDynamicScope, + hasDynamicRefBindings, + materializeDynamicRefBinding, + resolveDynamicRef, + shouldInlineDynamicRefTarget, +} from '../dynamicRef'; + +describe('hasDynamicRefBindings', () => { + it('returns false when schema has no $defs', () => { + expect(hasDynamicRefBindings({ type: 'object' })).toBe(false); + }); + + it('returns false when $defs is empty', () => { + expect(hasDynamicRefBindings({ $defs: {} })).toBe(false); + }); + + it('returns false when $defs entry has $dynamicAnchor but no $ref', () => { + expect( + hasDynamicRefBindings({ + $defs: { + itemType: { + $dynamicAnchor: 'itemType', + type: 'object', + properties: { id: { type: 'string' } }, + }, + }, + }), + ).toBe(false); + }); + + it('returns false when $defs entry has $ref but no $dynamicAnchor', () => { + expect( + hasDynamicRefBindings({ + $defs: { + itemType: { + $ref: '#/components/schemas/User', + }, + }, + }), + ).toBe(false); + }); + + it('returns true when $defs entry has both $dynamicAnchor and $ref', () => { + expect( + hasDynamicRefBindings({ + $defs: { + itemType: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }, + }), + ).toBe(true); + }); + + it('returns true when at least one $defs entry has both', () => { + expect( + hasDynamicRefBindings({ + $defs: { + noAnchor: { type: 'string' }, + noRef: { $dynamicAnchor: 'x' }, + valid: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }, + }), + ).toBe(true); + }); + + it('ignores non-object $defs entries', () => { + expect( + hasDynamicRefBindings({ + $defs: { + a: null, + b: 'string', + c: 42, + d: true, + }, + }), + ).toBe(false); + }); + + it('ignores array $defs entries', () => { + expect( + hasDynamicRefBindings({ + $defs: { + a: [{ type: 'string' }], + }, + }), + ).toBe(false); + }); +}); + +describe('buildDynamicScope', () => { + it('returns empty scope for plain schema', () => { + expect(buildDynamicScope({ type: 'object' })).toEqual({}); + }); + + it('records own $dynamicAnchor with $ref', () => { + expect( + buildDynamicScope({ + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }), + ).toEqual({ itemType: '#/components/schemas/User' }); + }); + + it('records own $dynamicAnchor with schemaRef fallback', () => { + expect( + buildDynamicScope({ $dynamicAnchor: 'category' }, '#/components/schemas/BaseCategory'), + ).toEqual({ category: '#/components/schemas/BaseCategory' }); + }); + + it('does not record $dynamicAnchor when no $ref or schemaRef', () => { + expect(buildDynamicScope({ $dynamicAnchor: 'itemType' })).toEqual({}); + }); + + it('prefers $ref over schemaRef', () => { + expect( + buildDynamicScope( + { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + '#/components/schemas/Fallback', + ), + ).toEqual({ itemType: '#/components/schemas/User' }); + }); + + it('records $defs bindings', () => { + expect( + buildDynamicScope({ + $defs: { + itemType: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }, + }), + ).toEqual({ itemType: '#/components/schemas/User' }); + }); + + it('skips $defs entries without both $dynamicAnchor and $ref', () => { + expect( + buildDynamicScope({ + $defs: { + a: { $dynamicAnchor: 'a' }, + b: { $ref: '#/components/schemas/User' }, + c: { type: 'string' }, + }, + }), + ).toEqual({}); + }); + + it('combines own anchor and $defs bindings', () => { + expect( + buildDynamicScope({ + $dynamicAnchor: 'self', + $ref: '#/components/schemas/Self', + $defs: { + other: { + $dynamicAnchor: 'other', + $ref: '#/components/schemas/Other', + }, + }, + }), + ).toEqual({ + self: '#/components/schemas/Self', + other: '#/components/schemas/Other', + }); + }); + + it('ignores non-object $defs entries', () => { + expect( + buildDynamicScope({ + $defs: { + a: null, + b: 'string', + c: [{ type: 'string' }], + }, + }), + ).toEqual({}); + }); +}); + +describe('buildCurrentDynamicScope', () => { + it('returns own scope when no inherited scope', () => { + expect( + buildCurrentDynamicScope({ + schema: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }), + ).toEqual({ itemType: '#/components/schemas/User' }); + }); + + it('merges own scope with inherited', () => { + expect( + buildCurrentDynamicScope({ + inheritedScope: { parent: '#/components/schemas/Parent' }, + schema: { + $dynamicAnchor: 'child', + $ref: '#/components/schemas/Child', + }, + }), + ).toEqual({ + child: '#/components/schemas/Child', + parent: '#/components/schemas/Parent', + }); + }); + + it('inherited scope wins for same key', () => { + expect( + buildCurrentDynamicScope({ + inheritedScope: { itemType: '#/components/schemas/Parent' }, + schema: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/Child', + }, + }), + ).toEqual({ itemType: '#/components/schemas/Parent' }); + }); + + it('returns empty scope for plain schema with no inherited', () => { + expect(buildCurrentDynamicScope({ schema: { type: 'object' } })).toEqual({}); + }); + + it('returns only inherited scope when schema has no dynamic bindings', () => { + expect( + buildCurrentDynamicScope({ + inheritedScope: { itemType: '#/components/schemas/User' }, + schema: { type: 'object' }, + }), + ).toEqual({ itemType: '#/components/schemas/User' }); + }); +}); + +describe('resolveDynamicRef', () => { + it('resolves plain anchor name', () => { + expect( + resolveDynamicRef({ + dynamicRef: '#itemType', + dynamicScope: { itemType: '#/components/schemas/User' }, + }), + ).toBe('#/components/schemas/User'); + }); + + it('returns undefined for external ref', () => { + expect( + resolveDynamicRef({ + dynamicRef: 'other.json#node', + dynamicScope: { node: '#/components/schemas/X' }, + }), + ).toBeUndefined(); + }); + + it('returns undefined for JSON pointer fragment', () => { + expect( + resolveDynamicRef({ + dynamicRef: '#/defs/itemType', + dynamicScope: { '/defs/itemType': '#/components/schemas/X' }, + }), + ).toBeUndefined(); + }); + + it('returns undefined for non-# ref', () => { + expect( + resolveDynamicRef({ + dynamicRef: 'itemType', + dynamicScope: { itemType: '#/components/schemas/User' }, + }), + ).toBeUndefined(); + }); + + it('returns undefined when no scope', () => { + expect( + resolveDynamicRef({ + dynamicRef: '#itemType', + }), + ).toBeUndefined(); + }); + + it('returns undefined when anchor not in scope', () => { + expect( + resolveDynamicRef({ + dynamicRef: '#missing', + dynamicScope: { itemType: '#/components/schemas/User' }, + }), + ).toBeUndefined(); + }); + + it('returns undefined for bare #', () => { + expect( + resolveDynamicRef({ + dynamicRef: '#', + dynamicScope: { '': '#/components/schemas/X' }, + }), + ).toBe('#/components/schemas/X'); + }); +}); + +describe('materializeDynamicRefBinding', () => { + const mockResolveRef = vi.fn(); + + const createContext = () => + ({ + resolveRef: mockResolveRef, + }) as any; + + beforeEach(() => { + mockResolveRef.mockReset(); + }); + + it('returns undefined when schema has no $ref', () => { + const result = materializeDynamicRefBinding({ + context: createContext(), + schema: { type: 'object' }, + }); + expect(result).toBeUndefined(); + }); + + it('returns undefined when schema has no $defs', () => { + const result = materializeDynamicRefBinding({ + context: createContext(), + schema: { $ref: '#/components/schemas/Template' }, + }); + expect(result).toBeUndefined(); + }); + + it('returns undefined when $defs has no dynamic ref bindings', () => { + const result = materializeDynamicRefBinding({ + context: createContext(), + schema: { + $ref: '#/components/schemas/Template', + $defs: { + a: { type: 'string' }, + }, + }, + }); + expect(result).toBeUndefined(); + }); + + it('returns undefined when $ref is not a top-level component', () => { + const result = materializeDynamicRefBinding({ + context: createContext(), + schema: { + $ref: '#/properties/foo', + $defs: { + itemType: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }, + }, + }); + expect(result).toBeUndefined(); + }); + + it('materializes when all conditions are met', () => { + mockResolveRef.mockReturnValue({ + type: 'object', + properties: { + items: { + type: 'array', + items: { $dynamicRef: '#itemType' }, + }, + total: { type: 'integer' }, + }, + }); + + const result = materializeDynamicRefBinding({ + context: createContext(), + schema: { + $ref: '#/components/schemas/PaginatedTemplate', + $defs: { + itemType: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }, + }, + }); + + expect(result).toBeDefined(); + expect(result!.$ref).toBeUndefined(); + expect(result!.$dynamicAnchor).toBeUndefined(); + expect(result!.$id).toBeUndefined(); + expect(result!.type).toBe('object'); + expect(result!.$defs).toBeDefined(); + expect(mockResolveRef).toHaveBeenCalledWith('#/components/schemas/PaginatedTemplate'); + }); + + it('caller schema properties override refSchema', () => { + mockResolveRef.mockReturnValue({ + type: 'object', + description: 'original', + }); + + const result = materializeDynamicRefBinding({ + context: createContext(), + schema: { + $ref: '#/components/schemas/Template', + description: 'overridden', + $defs: { + itemType: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }, + }, + }); + + expect(result!.description).toBe('overridden'); + }); +}); + +describe('shouldInlineDynamicRefTarget', () => { + it('returns true when all conditions are met', () => { + expect( + shouldInlineDynamicRefTarget({ + ref: '#/components/schemas/Template', + refSchema: { + $dynamicAnchor: 'itemType', + type: 'object', + }, + state: { + circularReferenceTracker: new Set(), + dynamicScope: { itemType: '#/components/schemas/User' }, + }, + }), + ).toBe(true); + }); + + it('returns false when refSchema has no $dynamicAnchor', () => { + expect( + shouldInlineDynamicRefTarget({ + ref: '#/components/schemas/Template', + refSchema: { type: 'object' }, + state: { + circularReferenceTracker: new Set(), + dynamicScope: { itemType: '#/components/schemas/User' }, + }, + }), + ).toBe(false); + }); + + it('returns false when dynamicScope has no matching anchor', () => { + expect( + shouldInlineDynamicRefTarget({ + ref: '#/components/schemas/Template', + refSchema: { + $dynamicAnchor: 'itemType', + type: 'object', + }, + state: { + circularReferenceTracker: new Set(), + dynamicScope: {}, + }, + }), + ).toBe(false); + }); + + it('returns false when dynamicScope is undefined', () => { + expect( + shouldInlineDynamicRefTarget({ + ref: '#/components/schemas/Template', + refSchema: { + $dynamicAnchor: 'itemType', + type: 'object', + }, + state: { + circularReferenceTracker: new Set(), + }, + }), + ).toBe(false); + }); + + it('returns false when scope anchor maps to same ref (self-reference)', () => { + expect( + shouldInlineDynamicRefTarget({ + ref: '#/components/schemas/Template', + refSchema: { + $dynamicAnchor: 'itemType', + type: 'object', + }, + state: { + circularReferenceTracker: new Set(), + dynamicScope: { itemType: '#/components/schemas/Template' }, + }, + }), + ).toBe(false); + }); + + it('returns false when ref is in circular reference tracker', () => { + expect( + shouldInlineDynamicRefTarget({ + ref: '#/components/schemas/Template', + refSchema: { + $dynamicAnchor: 'itemType', + type: 'object', + }, + state: { + circularReferenceTracker: new Set(['#/components/schemas/Template']), + dynamicScope: { itemType: '#/components/schemas/User' }, + }, + }), + ).toBe(false); + }); +}); From c7539349f69a90ef2b441331a9bd12c07a967a22 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 15 May 2026 18:05:54 +0300 Subject: [PATCH 7/9] fix: fix typecheck errors in dynamicRef test file --- .../3.1.x/parser/__tests__/dynamicRef.test.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts b/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts index edd665448c..cc9213177e 100644 --- a/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts +++ b/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts @@ -76,10 +76,10 @@ describe('hasDynamicRefBindings', () => { expect( hasDynamicRefBindings({ $defs: { - a: null, - b: 'string', - c: 42, - d: true, + a: null as any, + b: 'string' as any, + c: 42 as any, + d: true as any, }, }), ).toBe(false); @@ -89,7 +89,7 @@ describe('hasDynamicRefBindings', () => { expect( hasDynamicRefBindings({ $defs: { - a: [{ type: 'string' }], + a: [{ type: 'string' }] as any, }, }), ).toBe(false); @@ -179,9 +179,9 @@ describe('buildDynamicScope', () => { expect( buildDynamicScope({ $defs: { - a: null, - b: 'string', - c: [{ type: 'string' }], + a: null as any, + b: 'string' as any, + c: [{ type: 'string' }] as any, }, }), ).toEqual({}); @@ -388,9 +388,9 @@ describe('materializeDynamicRefBinding', () => { }); expect(result).toBeDefined(); - expect(result!.$ref).toBeUndefined(); - expect(result!.$dynamicAnchor).toBeUndefined(); - expect(result!.$id).toBeUndefined(); + expect((result as any).$ref).toBeUndefined(); + expect((result as any).$dynamicAnchor).toBeUndefined(); + expect((result as any).$id).toBeUndefined(); expect(result!.type).toBe('object'); expect(result!.$defs).toBeDefined(); expect(mockResolveRef).toHaveBeenCalledWith('#/components/schemas/PaginatedTemplate'); From 1f08f522cbdb8826f803d5bd4fa641c560a8c2c8 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Sat, 16 May 2026 01:10:13 +0300 Subject: [PATCH 8/9] refactor: deduplicate $defs walking and reject bare # in resolveDynamicRef - 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 --- .../3.1.x/parser/__tests__/dynamicRef.test.ts | 34 +++++++++------ .../src/openApi/3.1.x/parser/dynamicRef.ts | 42 +++++++++++-------- 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts b/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts index cc9213177e..c783110439 100644 --- a/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts +++ b/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts @@ -24,8 +24,8 @@ describe('hasDynamicRefBindings', () => { $defs: { itemType: { $dynamicAnchor: 'itemType', - type: 'object', properties: { id: { type: 'string' } }, + type: 'object', }, }, }), @@ -160,18 +160,18 @@ describe('buildDynamicScope', () => { it('combines own anchor and $defs bindings', () => { expect( buildDynamicScope({ - $dynamicAnchor: 'self', - $ref: '#/components/schemas/Self', $defs: { other: { $dynamicAnchor: 'other', $ref: '#/components/schemas/Other', }, }, + $dynamicAnchor: 'self', + $ref: '#/components/schemas/Self', }), ).toEqual({ - self: '#/components/schemas/Self', other: '#/components/schemas/Other', + self: '#/components/schemas/Self', }); }); @@ -296,12 +296,20 @@ describe('resolveDynamicRef', () => { }); it('returns undefined for bare #', () => { + expect( + resolveDynamicRef({ + dynamicRef: '#', + }), + ).toBeUndefined(); + }); + + it('returns undefined for bare # even with empty-string scope key', () => { expect( resolveDynamicRef({ dynamicRef: '#', dynamicScope: { '': '#/components/schemas/X' }, }), - ).toBe('#/components/schemas/X'); + ).toBeUndefined(); }); }); @@ -337,10 +345,10 @@ describe('materializeDynamicRefBinding', () => { const result = materializeDynamicRefBinding({ context: createContext(), schema: { - $ref: '#/components/schemas/Template', $defs: { a: { type: 'string' }, }, + $ref: '#/components/schemas/Template', }, }); expect(result).toBeUndefined(); @@ -350,13 +358,13 @@ describe('materializeDynamicRefBinding', () => { const result = materializeDynamicRefBinding({ context: createContext(), schema: { - $ref: '#/properties/foo', $defs: { itemType: { $dynamicAnchor: 'itemType', $ref: '#/components/schemas/User', }, }, + $ref: '#/properties/foo', }, }); expect(result).toBeUndefined(); @@ -364,26 +372,26 @@ describe('materializeDynamicRefBinding', () => { it('materializes when all conditions are met', () => { mockResolveRef.mockReturnValue({ - type: 'object', properties: { items: { - type: 'array', items: { $dynamicRef: '#itemType' }, + type: 'array', }, total: { type: 'integer' }, }, + type: 'object', }); const result = materializeDynamicRefBinding({ context: createContext(), schema: { - $ref: '#/components/schemas/PaginatedTemplate', $defs: { itemType: { $dynamicAnchor: 'itemType', $ref: '#/components/schemas/User', }, }, + $ref: '#/components/schemas/PaginatedTemplate', }, }); @@ -398,21 +406,21 @@ describe('materializeDynamicRefBinding', () => { it('caller schema properties override refSchema', () => { mockResolveRef.mockReturnValue({ - type: 'object', description: 'original', + type: 'object', }); const result = materializeDynamicRefBinding({ context: createContext(), schema: { - $ref: '#/components/schemas/Template', - description: 'overridden', $defs: { itemType: { $dynamicAnchor: 'itemType', $ref: '#/components/schemas/User', }, }, + $ref: '#/components/schemas/Template', + description: 'overridden', }, }); diff --git a/packages/shared/src/openApi/3.1.x/parser/dynamicRef.ts b/packages/shared/src/openApi/3.1.x/parser/dynamicRef.ts index a0bec56b46..ade86ef8f0 100644 --- a/packages/shared/src/openApi/3.1.x/parser/dynamicRef.ts +++ b/packages/shared/src/openApi/3.1.x/parser/dynamicRef.ts @@ -7,14 +7,21 @@ import { isTopLevelComponent } from '../../../utils/ref'; const isSchemaObject = (value: unknown): value is OpenAPIV3_1.SchemaObject => Boolean(value) && typeof value === 'object' && !Array.isArray(value); +function getDynamicDefsBindings( + schema: OpenAPIV3_1.SchemaObject, +): Array<[anchor: string, ref: string]> { + if (!schema.$defs) return []; + const entries: Array<[string, string]> = []; + for (const defSchema of Object.values(schema.$defs)) { + if (isSchemaObject(defSchema) && defSchema.$dynamicAnchor && defSchema.$ref) { + entries.push([defSchema.$dynamicAnchor, defSchema.$ref]); + } + } + return entries; +} + export function hasDynamicRefBindings(schema: OpenAPIV3_1.SchemaObject): boolean { - if (!schema.$defs) return false; - return Object.values(schema.$defs).some( - (defSchema) => - isSchemaObject(defSchema) && - Boolean(defSchema.$dynamicAnchor) && - Boolean(defSchema.$ref), - ); + return getDynamicDefsBindings(schema).length > 0; } export function buildDynamicScope( @@ -31,12 +38,8 @@ export function buildDynamicScope( } } - if (schema.$defs) { - for (const defSchema of Object.values(schema.$defs)) { - if (isSchemaObject(defSchema) && defSchema.$dynamicAnchor && defSchema.$ref) { - scope[defSchema.$dynamicAnchor] = defSchema.$ref; - } - } + for (const [anchor, ref] of getDynamicDefsBindings(schema)) { + scope[anchor] = ref; } return scope; @@ -66,7 +69,12 @@ export function resolveDynamicRef({ return; } - return dynamicScope?.[dynamicRef.slice(1)]; + const anchorName = dynamicRef.slice(1); + if (!anchorName) { + return; + } + + return dynamicScope?.[anchorName]; } export function materializeDynamicRefBinding({ @@ -108,8 +116,8 @@ export function shouldInlineDynamicRefTarget({ }): boolean { return Boolean( refSchema.$dynamicAnchor && - state.dynamicScope?.[refSchema.$dynamicAnchor] && - state.dynamicScope[refSchema.$dynamicAnchor] !== ref && - !state.circularReferenceTracker.has(ref), + state.dynamicScope?.[refSchema.$dynamicAnchor] && + state.dynamicScope[refSchema.$dynamicAnchor] !== ref && + !state.circularReferenceTracker.has(ref), ); } From 2248a9b6a159506113708e43a0db3c77bfe5876b Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Sat, 16 May 2026 01:40:55 +0300 Subject: [PATCH 9/9] perf: eliminate redundant resolveRef call for circular component refs --- .../shared/src/openApi/3.1.x/parser/schema.ts | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/openApi/3.1.x/parser/schema.ts b/packages/shared/src/openApi/3.1.x/parser/schema.ts index 30a6583c71..7fdb85f89b 100644 --- a/packages/shared/src/openApi/3.1.x/parser/schema.ts +++ b/packages/shared/src/openApi/3.1.x/parser/schema.ts @@ -1151,19 +1151,6 @@ function parseRef({ // Fallback to preserving the ref if circular } - const refSchema = context.resolveRef(schema.$ref); - if (shouldInlineDynamicRefTarget({ ref: schema.$ref, refSchema, state })) { - const originalRef = state.$ref; - state.$ref = schema.$ref; - const irSchema = schemaToIrSchema({ - context, - schema: refSchema, - state, - }); - state.$ref = originalRef; - return irSchema; - } - let irSchema = initIrSchema({ schema }); parseSchemaMeta({ irSchema, schema }); @@ -1172,6 +1159,20 @@ function parseRef({ irRefSchema.$ref = schema.$ref; if (!state.circularReferenceTracker.has(schema.$ref)) { + const refSchema = context.resolveRef(schema.$ref); + + if (shouldInlineDynamicRefTarget({ ref: schema.$ref, refSchema, state })) { + const originalRef = state.$ref; + state.$ref = schema.$ref; + const inlinedSchema = schemaToIrSchema({ + context, + schema: refSchema, + state, + }); + state.$ref = originalRef; + return inlinedSchema; + } + const originalRef = state.$ref; state.$ref = schema.$ref; schemaToIrSchema({