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/openapi-ts-tests/main/test/3.1.x.test.ts b/packages/openapi-ts-tests/main/test/3.1.x.test.ts index 5e47f3c481..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 @@ -1015,6 +1015,55 @@ 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', + }, + { + 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-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..8ee9767868 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-nested-workspace-resources/types.gen.ts @@ -0,0 +1,61 @@ +// 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 = { + kind: 'folder'; + id: string; + name: string; + children: Array; + shortcuts: Array; +} & { + 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..a9736ea5bf --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-paginated-response/types.gen.ts @@ -0,0 +1,78 @@ +// 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: { + items: Array; + total: number; + page: number; + pageSize: number; + }; +}; + +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: { + 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-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..01d29f1787 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-recursive-category-tree/types.gen.ts @@ -0,0 +1,41 @@ +// 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 = { + id: string; + children: Array; +} & { + 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/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/__tests__/dynamicRef.test.ts b/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts new file mode 100644 index 0000000000..c783110439 --- /dev/null +++ b/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts @@ -0,0 +1,523 @@ +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', + properties: { id: { type: 'string' } }, + type: 'object', + }, + }, + }), + ).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 as any, + b: 'string' as any, + c: 42 as any, + d: true as any, + }, + }), + ).toBe(false); + }); + + it('ignores array $defs entries', () => { + expect( + hasDynamicRefBindings({ + $defs: { + a: [{ type: 'string' }] as any, + }, + }), + ).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({ + $defs: { + other: { + $dynamicAnchor: 'other', + $ref: '#/components/schemas/Other', + }, + }, + $dynamicAnchor: 'self', + $ref: '#/components/schemas/Self', + }), + ).toEqual({ + other: '#/components/schemas/Other', + self: '#/components/schemas/Self', + }); + }); + + it('ignores non-object $defs entries', () => { + expect( + buildDynamicScope({ + $defs: { + a: null as any, + b: 'string' as any, + c: [{ type: 'string' }] as any, + }, + }), + ).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: '#', + }), + ).toBeUndefined(); + }); + + it('returns undefined for bare # even with empty-string scope key', () => { + expect( + resolveDynamicRef({ + dynamicRef: '#', + dynamicScope: { '': '#/components/schemas/X' }, + }), + ).toBeUndefined(); + }); +}); + +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: { + $defs: { + a: { type: 'string' }, + }, + $ref: '#/components/schemas/Template', + }, + }); + expect(result).toBeUndefined(); + }); + + it('returns undefined when $ref is not a top-level component', () => { + const result = materializeDynamicRefBinding({ + context: createContext(), + schema: { + $defs: { + itemType: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }, + $ref: '#/properties/foo', + }, + }); + expect(result).toBeUndefined(); + }); + + it('materializes when all conditions are met', () => { + mockResolveRef.mockReturnValue({ + properties: { + items: { + items: { $dynamicRef: '#itemType' }, + type: 'array', + }, + total: { type: 'integer' }, + }, + type: 'object', + }); + + const result = materializeDynamicRefBinding({ + context: createContext(), + schema: { + $defs: { + itemType: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }, + $ref: '#/components/schemas/PaginatedTemplate', + }, + }); + + expect(result).toBeDefined(); + 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'); + }); + + it('caller schema properties override refSchema', () => { + mockResolveRef.mockReturnValue({ + description: 'original', + type: 'object', + }); + + const result = materializeDynamicRefBinding({ + context: createContext(), + schema: { + $defs: { + itemType: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }, + $ref: '#/components/schemas/Template', + description: 'overridden', + }, + }); + + 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); + }); +}); 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..ade86ef8f0 --- /dev/null +++ b/packages/shared/src/openApi/3.1.x/parser/dynamicRef.ts @@ -0,0 +1,123 @@ +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); + +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 { + return getDynamicDefsBindings(schema).length > 0; +} + +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; + } + } + + for (const [anchor, ref] of getDynamicDefsBindings(schema)) { + scope[anchor] = 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; + } + + const anchorName = dynamicRef.slice(1); + if (!anchorName) { + return; + } + + return dynamicScope?.[anchorName]; +} + +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 5ad794f63a..7fdb85f89b 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, @@ -1153,6 +1160,19 @@ function parseRef({ 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({ @@ -1366,29 +1386,71 @@ export function schemaToIrSchema({ schema: OpenAPIV3_1.SchemaObject; state: SchemaState | undefined; }): IR.SchemaObject { - if (!state) { - state = { - circularReferenceTracker: new Set(), - }; + const currentState: SchemaState = state + ? { + ...state, + // 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(), + 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, }); } + if (schema.$dynamicRef) { + const resolvedRef = resolveDynamicRef({ + dynamicRef: schema.$dynamicRef, + dynamicScope: currentState.dynamicScope, + }); + + if (resolvedRef) { + // Emit a synthetic $ref to the resolved type + return parseRef({ + context, + schema: { + ...schema, + $ref: resolvedRef, + } as SchemaWithRequired, + state: currentState, + }); + } + + // If no scope match found, fall back to unknown + return parseUnknown({ context, schema }); + } + if (schema.enum) { return parseEnum({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1396,7 +1458,7 @@ export function schemaToIrSchema({ return parseAllOf({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1404,7 +1466,7 @@ export function schemaToIrSchema({ return parseAnyOf({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1412,7 +1474,7 @@ export function schemaToIrSchema({ return parseOneOf({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1421,7 +1483,7 @@ export function schemaToIrSchema({ return parseType({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1430,7 +1492,7 @@ export function schemaToIrSchema({ return parseType({ context, schema: { ...schema, type: 'string' } as SchemaWithRequired, - state, + state: currentState, }); } @@ -1454,12 +1516,15 @@ export function parseSchema({ context.ir.components.schemas = {}; } + const dynamicScope = buildDynamicScope(schema, $ref); + 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..1b40744700 100644 --- a/packages/shared/src/openApi/shared/types/schema.ts +++ b/packages/shared/src/openApi/shared/types/schema.ts @@ -9,6 +9,13 @@ export interface SchemaState { * avoid infinite loops when resolving schemas with circular references. */ circularReferenceTracker: Set; + /** + * 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; /** * True if current schema is part of an allOf composition. This is used to * avoid emitting [key: string]: never for empty objects with 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..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 @@ -7,6 +7,22 @@ export interface BaseDocument * The `$comment` {@link https://json-schema.org/learn/glossary#keyword keyword} is strictly intended for adding comments to a schema. Its value must always be a string. Unlike the annotations `title`, `description`, and `examples`, JSON schema {@link https://json-schema.org/learn/glossary#implementation implementations} aren't allowed to attach any meaning or behavior to it whatsoever, and may even strip them at any time. Therefore, they are useful for leaving notes to future editors of a JSON schema, but should not be used to communicate to users of the schema. */ $comment?: 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; + /** + * 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 `$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; /** * 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}. * 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 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' 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).