diff --git a/.changeset/dynamicref-support.md b/.changeset/dynamicref-support.md new file mode 100644 index 0000000000..b8f82a52da --- /dev/null +++ b/.changeset/dynamicref-support.md @@ -0,0 +1,6 @@ +--- +"@hey-api/shared": minor +"@hey-api/spec-types": minor +--- + +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..3d9c2651fa 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,34 @@ describe(`OpenAPI ${version}`, () => { }), description: 'anyOf string and binary string', }, + { + config: createConfig({ + input: 'dynamicref-petstore-showcase.yaml', + output: 'dynamicref-petstore-showcase', + }), + description: 'resolves $dynamicRef in petstore showcase', + }, + { + config: createConfig({ + input: 'dynamicref-external-ref.yaml', + output: 'dynamicref-external-ref', + }), + description: 'handles external $dynamicRef without crashing', + }, + { + config: createConfig({ + input: 'dynamicref-scope-isolation.yaml', + output: 'dynamicref-scope-isolation', + }), + description: 'keeps $dynamicRef bindings isolated between sibling schemas', + }, + { + config: createConfig({ + input: 'dynamicref-circular-oneof.yaml', + output: 'dynamicref-circular-oneof', + }), + description: 'detects circular $dynamicRef through oneOf', + }, ]; it.each(scenarios)('$description', async ({ config }) => { diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-oneof/index.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-oneof/index.ts new file mode 100644 index 0000000000..825fdc122b --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-oneof/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { ClientOptions, GetTreeData, GetTreeResponse, GetTreeResponses, TreeNode, TreeNodeLeaf, TreeNodeTemplate } from './types.gen'; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-oneof/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-oneof/types.gen.ts new file mode 100644 index 0000000000..2d9d5a4ccd --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-oneof/types.gen.ts @@ -0,0 +1,38 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.example.com' | (string & {}); +}; + +export type TreeNodeTemplate = { + id: string; + label: string; + child?: NodeType; +}; + +export type TreeNode = TreeNodeLeaf | { + id: string; + label: string; + child?: TreeNode; +}; + +export type TreeNodeLeaf = { + id: string; + label: string; +}; + +export type GetTreeData = { + body?: never; + path?: never; + query?: never; + url: '/tree'; +}; + +export type GetTreeResponses = { + /** + * Tree nodes + */ + 200: Array; +}; + +export type GetTreeResponse = GetTreeResponses[keyof GetTreeResponses]; 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-petstore-showcase/index.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-petstore-showcase/index.ts new file mode 100644 index 0000000000..824f645da2 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-petstore-showcase/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { ApiEnvelopeTemplate, BaseSpeciesCategory, ClientOptions, CreatePetData, CreatePetErrors, CreatePetResponse, CreatePetResponses, Document, GetPetData, GetPetErrors, GetPetResponse, GetPetResponses, GetShelterResourcesData, GetShelterResourcesErrors, GetShelterResourcesResponse, GetShelterResourcesResponses, GetSpeciesTreeData, GetSpeciesTreeErrors, GetSpeciesTreeResponse, GetSpeciesTreeResponses, Link, ListOwnersData, ListOwnersErrors, ListOwnersResponse, ListOwnersResponses, ListPetsData, ListPetsErrors, ListPetsResponse, ListPetsResponses, LocalizedSpeciesCategory, Owner, PaginatedOwnerItems, PaginatedPetItems, PaginatedTemplate, Pet, PetCreateRequest, PetFields, ShelterFolder, ShelterFolderTemplate, ShelterResource } from './types.gen'; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-petstore-showcase/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-petstore-showcase/types.gen.ts new file mode 100644 index 0000000000..947380b6c8 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-petstore-showcase/types.gen.ts @@ -0,0 +1,236 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.petstore.example' | (string & {}); +}; + +export type Link = { + href?: string; +}; + +export type PetFields = { + name: string; + species: string; + status: 'available' | 'pending' | 'adopted'; + tag?: Array; +}; + +export type PetCreateRequest = PetFields; + +export type Pet = { + id: string; +} & PetFields; + +export type Owner = { + id: string; + name: string; + email: string; +}; + +export type ApiEnvelopeTemplate = { + data: DataType; + requestId: string; + links?: { + [key: string]: Link; + }; +}; + +export type PaginatedTemplate = { + items: Array; + total: number; + page: number; + pageSize: number; +}; + +export type PaginatedPetItems = PaginatedTemplate; + +export type PaginatedOwnerItems = PaginatedTemplate; + +export type BaseSpeciesCategory = { + id: string; + label: string; + children: Array; +}; + +export type LocalizedSpeciesCategory = { + id: string; + label: string; + children: Array; +} & { + locale: string; + displayName: string; +}; + +export type Document = { + kind: 'document'; + id: string; + title: string; +}; + +export type ShelterFolderTemplate = { + kind: 'folder'; + id: string; + name: string; + children: Array; + shortcuts?: Array; +}; + +export type ShelterFolder = { + kind: 'folder'; + id: string; + name: string; + children: Array; + shortcuts?: Array; +} & { + accessLevel: 'public' | 'staff' | 'admin'; +}; + +export type ShelterResource = Document | ShelterFolder; + +export type ListPetsData = { + body?: never; + path?: never; + query?: { + page?: number; + pageSize?: number; + }; + url: '/pets'; +}; + +export type ListPetsErrors = { + /** + * Bad request + */ + 400: unknown; +}; + +export type ListPetsResponses = { + /** + * Paginated list of pets + */ + 200: ApiEnvelopeTemplate; +}; + +export type ListPetsResponse = ListPetsResponses[keyof ListPetsResponses]; + +export type CreatePetData = { + body: PetFields; + path?: never; + query?: never; + url: '/pets'; +}; + +export type CreatePetErrors = { + /** + * Bad request + */ + 400: unknown; +}; + +export type CreatePetResponses = { + /** + * Created pet + */ + 201: ApiEnvelopeTemplate; +}; + +export type CreatePetResponse = CreatePetResponses[keyof CreatePetResponses]; + +export type GetPetData = { + body?: never; + path: { + petId: string; + }; + query?: never; + url: '/pets/{petId}'; +}; + +export type GetPetErrors = { + /** + * Not found + */ + 404: unknown; +}; + +export type GetPetResponses = { + /** + * A single pet + */ + 200: ApiEnvelopeTemplate; +}; + +export type GetPetResponse = GetPetResponses[keyof GetPetResponses]; + +export type ListOwnersData = { + body?: never; + path?: never; + query?: { + page?: number; + pageSize?: number; + }; + url: '/owners'; +}; + +export type ListOwnersErrors = { + /** + * Bad request + */ + 400: unknown; +}; + +export type ListOwnersResponses = { + /** + * Paginated list of owners + */ + 200: ApiEnvelopeTemplate; +}; + +export type ListOwnersResponse = ListOwnersResponses[keyof ListOwnersResponses]; + +export type GetSpeciesTreeData = { + body?: never; + path?: never; + query?: never; + url: '/species/tree'; +}; + +export type GetSpeciesTreeErrors = { + /** + * Bad request + */ + 400: unknown; +}; + +export type GetSpeciesTreeResponses = { + /** + * Localized recursive species category tree + */ + 200: LocalizedSpeciesCategory; +}; + +export type GetSpeciesTreeResponse = GetSpeciesTreeResponses[keyof GetSpeciesTreeResponses]; + +export type GetShelterResourcesData = { + body?: never; + path: { + shelterId: string; + }; + query?: never; + url: '/shelters/{shelterId}/resources'; +}; + +export type GetShelterResourcesErrors = { + /** + * Not found + */ + 404: unknown; +}; + +export type GetShelterResourcesResponses = { + /** + * Shelter resource tree + */ + 200: ShelterResource; +}; + +export type GetShelterResourcesResponse = GetShelterResourcesResponses[keyof GetShelterResourcesResponses]; 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/openapi-ts/src/plugins/@hey-api/typescript/shared/export.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/export.ts index 0c3974d497..fa6db9cafb 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/export.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/export.ts @@ -59,5 +59,12 @@ export function exportAst({ .export() .$if(plugin.config.comments && createSchemaComment(schema), (t, v) => t.doc(v)) .type(final.type); + + if (schema.typeParams?.length) { + for (const param of schema.typeParams) { + node.generic(param.paramName); + } + } + plugin.node(node); } diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/visitor.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/visitor.ts index 9de7b08af3..6e24f55232 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/visitor.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/visitor.ts @@ -1,4 +1,4 @@ -import { fromRef } from '@hey-api/codegen-core'; +import { fromRef, ref } from '@hey-api/codegen-core'; import type { SchemaExtractor, SchemaVisitor } from '@hey-api/shared'; import { pathToJsonPointer } from '@hey-api/shared'; @@ -74,6 +74,29 @@ export function createVisitor( }; }, intercept(schema, ctx, walk) { + if (schema.$ref?.startsWith('#typeParam/')) { + const paramName = schema.$ref.slice('#typeParam/'.length); + return { + meta: defaultMeta(schema), + type: $.type(paramName), + }; + } + + if (schema.$ref && schema.typeArgs?.length) { + const symbol = ctx.plugin.referenceSymbol({ + category: 'type', + resource: 'definition', + resourceId: schema.$ref, + }); + const argTypes = schema.typeArgs.map( + (arg) => walk(arg, { path: ref([]), plugin: ctx.plugin }).type, + ); + return { + meta: defaultMeta(schema), + type: $.type(symbol).generics(...argTypes), + }; + } + if (schemaExtractor && !schema.$ref) { const extracted = schemaExtractor({ meta: { diff --git a/packages/shared/src/ir/types.ts b/packages/shared/src/ir/types.ts index 7fd6e8a58b..212f1480d3 100644 --- a/packages/shared/src/ir/types.ts +++ b/packages/shared/src/ir/types.ts @@ -210,6 +210,20 @@ export interface IRSchemaObject | 'undefined' | 'unknown' | 'void'; + /** + * Type arguments for generic schema references. Set on `$ref` schemas that + * instantiate a generic template (e.g., `PaginatedTemplate`). + */ + typeArgs?: Array; + /** + * Type parameters for generic schema templates. Set on template schemas + * (e.g., `PaginatedTemplate`) to indicate generic parameters derived from + * `$dynamicAnchor` declarations. + */ + typeParams?: ReadonlyArray<{ + anchor: string; + paramName: string; + }>; } type IRSecurityObject = OpenAPIV3_1.SecuritySchemeObject; 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..70bfd53725 --- /dev/null +++ b/packages/shared/src/openApi/3.1.x/parser/__tests__/dynamicRef.test.ts @@ -0,0 +1,956 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + buildCurrentDynamicScope, + buildDynamicScope, + buildGenericRef, + containsRefTo, + getDynamicDefsBindings, + getTemplateParams, + 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 (outer) scope wins for same key per JSON Schema 2020-12 §8.2.3.2', () => { + 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'); + }); + + it('merges $defs from refSchema and caller schema', () => { + mockResolveRef.mockReturnValue({ + $defs: { + helper: { type: 'string' }, + placeholder: { $dynamicAnchor: 'itemType', not: {} }, + }, + type: 'object', + }); + + const result = materializeDynamicRefBinding({ + context: createContext(), + schema: { + $defs: { + itemType: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }, + $ref: '#/components/schemas/Template', + }, + }); + + expect(result!.$defs).toEqual({ + helper: { type: 'string' }, + itemType: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + placeholder: { $dynamicAnchor: 'itemType', not: {} }, + }); + }); +}); + +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); + }); +}); + +describe('getDynamicDefsBindings', () => { + it('returns empty for schema without $defs', () => { + expect(getDynamicDefsBindings({ type: 'object' })).toEqual([]); + }); + + it('returns empty for empty $defs', () => { + expect(getDynamicDefsBindings({ $defs: {} })).toEqual([]); + }); + + it('returns entries with both $dynamicAnchor and $ref', () => { + expect( + getDynamicDefsBindings({ + $defs: { + itemType: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }, + }), + ).toEqual([['itemType', '#/components/schemas/User']]); + }); + + it('skips entries missing $dynamicAnchor or $ref', () => { + expect( + getDynamicDefsBindings({ + $defs: { + a: { $dynamicAnchor: 'a' } as any, + b: { $ref: '#/components/schemas/X' } as any, + c: { type: 'string' } as any, + valid: { + $dynamicAnchor: 'valid', + $ref: '#/components/schemas/Y', + }, + }, + }), + ).toEqual([['valid', '#/components/schemas/Y']]); + }); + + it('returns multiple bindings', () => { + expect( + getDynamicDefsBindings({ + $defs: { + dataType: { + $dynamicAnchor: 'dataType', + $ref: '#/components/schemas/Data', + }, + itemType: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }, + }), + ).toEqual([ + ['dataType', '#/components/schemas/Data'], + ['itemType', '#/components/schemas/User'], + ]); + }); + + it('ignores non-object $defs entries', () => { + expect( + getDynamicDefsBindings({ + $defs: { + a: null as any, + b: 'str' as any, + c: [{}] as any, + }, + }), + ).toEqual([]); + }); +}); + +describe('getTemplateParams', () => { + it('returns empty for schema without $defs', () => { + expect(getTemplateParams({ type: 'object' })).toEqual([]); + }); + + it('returns empty for empty $defs', () => { + expect(getTemplateParams({ $defs: {} })).toEqual([]); + }); + + it('detects template params from $defs with $dynamicAnchor but no $ref', () => { + expect( + getTemplateParams({ + $defs: { + itemType: { + $dynamicAnchor: 'itemType', + not: {}, + }, + }, + }), + ).toEqual([{ anchor: 'itemType', paramName: 'ItemType' }]); + }); + + it('capitalizes anchor name to derive paramName', () => { + expect( + getTemplateParams({ + $defs: { + folderType: { + $dynamicAnchor: 'folderType', + not: {}, + }, + }, + }), + ).toEqual([{ anchor: 'folderType', paramName: 'FolderType' }]); + }); + + it('skips entries that have $ref (those are bindings, not template params)', () => { + expect( + getTemplateParams({ + $defs: { + itemType: { + $dynamicAnchor: 'itemType', + $ref: '#/components/schemas/User', + }, + }, + }), + ).toEqual([]); + }); + + it('returns multiple template params', () => { + expect( + getTemplateParams({ + $defs: { + dataType: { + $dynamicAnchor: 'dataType', + not: {}, + }, + itemType: { + $dynamicAnchor: 'itemType', + not: {}, + }, + }, + }), + ).toEqual([ + { anchor: 'dataType', paramName: 'DataType' }, + { anchor: 'itemType', paramName: 'ItemType' }, + ]); + }); + + it('ignores non-object $defs entries', () => { + expect( + getTemplateParams({ + $defs: { + a: null as any, + b: 'str' as any, + c: [{}] as any, + }, + }), + ).toEqual([]); + }); + + it('handles single-character anchor', () => { + expect( + getTemplateParams({ + $defs: { + t: { + $dynamicAnchor: 't', + not: {}, + }, + }, + }), + ).toEqual([{ anchor: 't', paramName: 'T' }]); + }); + + it('sanitizes anchors with separator characters', () => { + expect( + getTemplateParams({ + $defs: { + itemType: { + $dynamicAnchor: 'item-type', + not: {}, + }, + }, + }), + ).toEqual([{ anchor: 'item-type', paramName: 'ItemType' }]); + }); + + it('deduplicates paramName collisions with numeric suffix', () => { + expect( + getTemplateParams({ + $defs: { + itemType: { + $dynamicAnchor: 'item_type', + not: {}, + }, + itemType2: { + $dynamicAnchor: 'item-type', + not: {}, + }, + }, + }), + ).toEqual([ + { anchor: 'item_type', paramName: 'ItemType' }, + { anchor: 'item-type', paramName: 'ItemType2' }, + ]); + }); +}); + +describe('buildGenericRef', () => { + it('builds IR with typeArgs matching template params order', () => { + const result = buildGenericRef({ + bindings: [['itemType', '#/components/schemas/User']], + schema: { $ref: '#/components/schemas/PaginatedTemplate' }, + targetRef: '#/components/schemas/PaginatedTemplate', + templateParams: [{ anchor: 'itemType', paramName: 'ItemType' }], + }); + + expect(result.$ref).toBe('#/components/schemas/PaginatedTemplate'); + expect(result.typeArgs).toEqual([{ $ref: '#/components/schemas/User' }]); + }); + + it('uses unknown for template params without bindings', () => { + const result = buildGenericRef({ + bindings: [], + schema: { $ref: '#/components/schemas/PaginatedTemplate' }, + targetRef: '#/components/schemas/PaginatedTemplate', + templateParams: [{ anchor: 'itemType', paramName: 'ItemType' }], + }); + + expect(result.typeArgs).toEqual([{ type: 'unknown' }]); + }); + + it('handles multiple type args', () => { + const result = buildGenericRef({ + bindings: [ + ['itemType', '#/components/schemas/User'], + ['dataType', '#/components/schemas/Data'], + ], + schema: { $ref: '#/components/schemas/PairTemplate' }, + targetRef: '#/components/schemas/PairTemplate', + templateParams: [ + { anchor: 'itemType', paramName: 'ItemType' }, + { anchor: 'dataType', paramName: 'DataType' }, + ], + }); + + expect(result.typeArgs).toEqual([ + { $ref: '#/components/schemas/User' }, + { $ref: '#/components/schemas/Data' }, + ]); + }); + + it('mixes bound and unbound params', () => { + const result = buildGenericRef({ + bindings: [['dataType', '#/components/schemas/Data']], + schema: { $ref: '#/components/schemas/PairTemplate' }, + targetRef: '#/components/schemas/PairTemplate', + templateParams: [ + { anchor: 'itemType', paramName: 'ItemType' }, + { anchor: 'dataType', paramName: 'DataType' }, + ], + }); + + expect(result.typeArgs).toEqual([{ type: 'unknown' }, { $ref: '#/components/schemas/Data' }]); + }); + + it('omits typeArgs when no template params', () => { + const result = buildGenericRef({ + bindings: [], + schema: { $ref: '#/components/schemas/Plain' }, + targetRef: '#/components/schemas/Plain', + templateParams: [], + }); + + expect(result.typeArgs).toBeUndefined(); + }); + + it('preserves schema metadata fields', () => { + const result = buildGenericRef({ + bindings: [['itemType', '#/components/schemas/User']], + schema: { + $ref: '#/components/schemas/Template', + deprecated: true, + description: 'A paginated list', + title: 'Paginated', + }, + targetRef: '#/components/schemas/Template', + templateParams: [{ anchor: 'itemType', paramName: 'ItemType' }], + }); + + expect(result.deprecated).toBe(true); + expect(result.description).toBe('A paginated list'); + expect(result.title).toBe('Paginated'); + }); + + it('preserves nullability from generic ref schemas', () => { + const result = buildGenericRef({ + bindings: [['itemType', '#/components/schemas/User']], + schema: { + $ref: '#/components/schemas/Template', + type: ['object', 'null'], + }, + targetRef: '#/components/schemas/Template', + templateParams: [{ anchor: 'itemType', paramName: 'ItemType' }], + }); + + expect(result).toEqual({ + items: [ + { + $ref: '#/components/schemas/Template', + typeArgs: [{ $ref: '#/components/schemas/User' }], + }, + { type: 'null' }, + ], + logicalOperator: 'or', + }); + }); +}); + +describe('containsRefTo', () => { + const targetRef = '#/components/schemas/Foo'; + + it('detects direct $ref match', () => { + expect(containsRefTo({ $ref: targetRef }, targetRef)).toBe(true); + }); + + it('returns false for non-matching $ref', () => { + expect(containsRefTo({ $ref: '#/components/schemas/Bar' }, targetRef)).toBe(false); + }); + + it('detects $ref inside allOf', () => { + expect( + containsRefTo( + { + allOf: [{ $ref: targetRef }, { type: 'object' }], + }, + targetRef, + ), + ).toBe(true); + }); + + it('detects $ref inside anyOf', () => { + expect( + containsRefTo( + { + anyOf: [{ type: 'null' }, { $ref: targetRef }], + }, + targetRef, + ), + ).toBe(true); + }); + + it('detects $ref inside oneOf', () => { + expect( + containsRefTo( + { + oneOf: [{ $ref: targetRef }, { type: 'string' }], + }, + targetRef, + ), + ).toBe(true); + }); + + it('detects nested cycles through allOf containing oneOf', () => { + expect( + containsRefTo( + { + allOf: [ + { + oneOf: [{ $ref: targetRef }, { type: 'string' }], + }, + { type: 'object' }, + ], + }, + targetRef, + ), + ).toBe(true); + }); + + it('detects two-hop allOf chain', () => { + expect( + containsRefTo( + { + allOf: [ + { + allOf: [{ $ref: targetRef }], + }, + ], + }, + targetRef, + ), + ).toBe(true); + }); + + it('returns false for null schema', () => { + expect(containsRefTo(null, targetRef)).toBe(false); + }); + + it('returns false for undefined schema', () => { + expect(containsRefTo(undefined, targetRef)).toBe(false); + }); + + it('returns false for schema without composites', () => { + expect(containsRefTo({ type: 'string' }, targetRef)).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..3ffaf51647 --- /dev/null +++ b/packages/shared/src/openApi/3.1.x/parser/dynamicRef.ts @@ -0,0 +1,242 @@ +import type { OpenAPIV3_1 } from '@hey-api/spec-types'; + +import type { Context } from '../../../ir/context'; +import type { IR } from '../../../ir/types'; +import { addItemsToSchema } from '../../../ir/utils'; +import type { SchemaState } from '../../../openApi/shared/types/schema'; +import { toCase } from '../../../utils/naming/naming'; +import { isTopLevelComponent } from '../../../utils/ref'; + +const isSchemaObject = (value: unknown): value is OpenAPIV3_1.SchemaObject => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +export 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; +} + +function anchorToParamName(anchor: string): string { + const name = toCase(anchor, 'PascalCase'); + let sanitized = ''; + + for (const char of name) { + if (!sanitized) { + sanitized += /^[$_\p{ID_Start}]$/u.test(char) ? char : '_'; + } else { + sanitized += /^[$\u200c\u200d\p{ID_Continue}]$/u.test(char) ? char : '_'; + } + } + + return sanitized || '_'; +} + +export function getTemplateParams( + schema: OpenAPIV3_1.SchemaObject, +): ReadonlyArray<{ anchor: string; paramName: string }> { + if (!schema.$defs) return []; + const params: Array<{ anchor: string; paramName: string }> = []; + const seen = new Set(); + for (const defSchema of Object.values(schema.$defs)) { + if (isSchemaObject(defSchema) && defSchema.$dynamicAnchor && !defSchema.$ref) { + let paramName = anchorToParamName(defSchema.$dynamicAnchor); + if (seen.has(paramName)) { + let i = 2; + while (seen.has(`${paramName}${i}`)) i++; + paramName = `${paramName}${i}`; + } + seen.add(paramName); + params.push({ anchor: defSchema.$dynamicAnchor, paramName }); + } + } + return params; +} + +export function buildGenericRef({ + bindings, + schema, + targetRef, + templateParams, +}: { + bindings: ReadonlyArray<[anchor: string, ref: string]>; + schema: OpenAPIV3_1.SchemaObject; + targetRef: string; + templateParams: ReadonlyArray<{ anchor: string; paramName: string }>; +}): IR.SchemaObject { + const bindingMap = new Map(bindings); + const typeArgs: Array = []; + + for (const { anchor } of templateParams) { + const ref = bindingMap.get(anchor); + if (ref) { + typeArgs.push({ $ref: ref }); + } else { + typeArgs.push({ type: 'unknown' }); + } + } + + const irSchema = initGenericRefIrSchema(schema); + const irRefSchema: IR.SchemaObject = { $ref: targetRef }; + + if (typeArgs.length) { + irRefSchema.typeArgs = typeArgs; + } + + if (schema.type && typeof schema.type !== 'string' && schema.type.includes('null')) { + return addItemsToSchema({ + items: [irRefSchema, { type: 'null' }], + schema: irSchema, + }); + } + + return { + ...irSchema, + ...irRefSchema, + }; +} + +function initGenericRefIrSchema( + schema: OpenAPIV3_1.SchemaObject, +): Pick { + const result: Pick = {}; + if (schema.deprecated !== undefined) result.deprecated = schema.deprecated; + if (schema.description !== undefined) result.description = schema.description; + if (schema.required !== undefined) result.required = schema.required; + if (schema.title !== undefined) result.title = schema.title; + return result; +} + +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, + }; + if (refSchema.$defs && schema.$defs) { + materializedSchema.$defs = { + ...refSchema.$defs, + ...schema.$defs, + }; + } + 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), + ); +} + +export function containsRefTo( + schema: OpenAPIV3_1.SchemaObject | undefined | null, + ref: string, +): boolean { + if (!schema) return false; + if (schema.$ref === ref) return true; + const composites: Array | undefined = schema.allOf ?? schema.anyOf ?? schema.oneOf; + if (Array.isArray(composites)) { + for (const item of composites) { + if (isSchemaObject(item)) { + if (item.$ref === ref) return true; + if (item.allOf || item.anyOf || item.oneOf) { + if (containsRefTo(item, ref)) return true; + } + } + } + } + return false; +} 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..c4bcde00d0 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,17 @@ import { discriminatorValues, } from '../../../openApi/shared/utils/discriminator'; import { isTopLevelComponent, refToName } from '../../../utils/ref'; +import { + buildCurrentDynamicScope, + buildDynamicScope, + buildGenericRef, + containsRefTo, + getDynamicDefsBindings, + getTemplateParams, + materializeDynamicRefBinding, + resolveDynamicRef, + shouldInlineDynamicRefTarget, +} from './dynamicRef'; export function getSchemaTypes({ schema, @@ -1153,6 +1164,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 +1390,109 @@ 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. Outer (inherited) scope + // wins on collision per JSON Schema 2020-12 §8.2.3.2. + 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) { + if (isTopLevelComponent(schema.$ref!) && schema.$defs) { + const targetSchema = context.resolveRef(schema.$ref!); + const templateParams = getTemplateParams(targetSchema); + + if (templateParams.length > 0) { + const bindings = getDynamicDefsBindings(schema); + const hasCircularBinding = bindings.some(([, ref]) => { + const bindingSchema = context.resolveRef(ref); + return containsRefTo(bindingSchema, schema.$ref!); + }); + if (hasCircularBinding) { + return schemaToIrSchema({ + context, + schema: materializedSchema, + state: currentState, + }); + } + return buildGenericRef({ + bindings, + schema, + targetRef: schema.$ref!, + templateParams, + }); + } + } + + 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) { + return parseRef({ + context, + schema: { + ...schema, + $ref: resolvedRef, + } as SchemaWithRequired, + state: currentState, + }); + } + + if (currentState.typeParams) { + const anchorName = + schema.$dynamicRef.startsWith('#') && !schema.$dynamicRef.includes('/') + ? schema.$dynamicRef.slice(1) + : null; + if (anchorName) { + const param = currentState.typeParams.find((p) => p.anchor === anchorName); + if (param) { + return { $ref: `#typeParam/${param.paramName}` }; + } + } + } + + return parseUnknown({ context, schema }); + } + if (schema.enum) { return parseEnum({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1396,7 +1500,7 @@ export function schemaToIrSchema({ return parseAllOf({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1404,7 +1508,7 @@ export function schemaToIrSchema({ return parseAnyOf({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1412,7 +1516,7 @@ export function schemaToIrSchema({ return parseOneOf({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1421,7 +1525,7 @@ export function schemaToIrSchema({ return parseType({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1430,7 +1534,7 @@ export function schemaToIrSchema({ return parseType({ context, schema: { ...schema, type: 'string' } as SchemaWithRequired, - state, + state: currentState, }); } @@ -1454,12 +1558,23 @@ export function parseSchema({ context.ir.components.schemas = {}; } - context.ir.components.schemas[refToName($ref)] = schemaToIrSchema({ + const dynamicScope = buildDynamicScope(schema, $ref); + const typeParams = getTemplateParams(schema); + + const irSchema = schemaToIrSchema({ context, schema, state: { $ref, circularReferenceTracker: new Set(), + dynamicScope, + typeParams: typeParams.length > 0 ? typeParams : undefined, }, }); + + if (typeParams.length > 0) { + irSchema.typeParams = typeParams; + } + + context.ir.components.schemas[refToName($ref)] = irSchema; } diff --git a/packages/shared/src/openApi/shared/types/schema.ts b/packages/shared/src/openApi/shared/types/schema.ts index f52469bb96..120e603be6 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 @@ -16,6 +23,16 @@ export interface SchemaState { * properties from other schemas in the composition. */ inAllOf?: boolean; + /** + * Type parameters detected in the current generic template schema. Each entry + * maps a `$dynamicAnchor` name to the derived TypeScript type parameter name. + * Only set when parsing a schema with `$defs` entries that have + * `$dynamicAnchor` but no concrete `$ref` (i.e., template placeholder slots). + */ + typeParams?: ReadonlyArray<{ + anchor: string; + paramName: string; + }>; } 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..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-circular-oneof.yaml b/specs/3.1.x/dynamicref-circular-oneof.yaml new file mode 100644 index 0000000000..f4b0822da9 --- /dev/null +++ b/specs/3.1.x/dynamicref-circular-oneof.yaml @@ -0,0 +1,62 @@ +openapi: 3.1.0 +info: + title: DynamicRef Circular oneOf Test + description: > + Tests that hasCircularBinding detects cycles routed through oneOf, + not just direct $ref and single-level allOf. + version: 0.1.0 +servers: + - url: https://api.example.com +security: [] + +paths: + /tree: + get: + summary: Get tree nodes + operationId: getTree + tags: [Tree] + responses: + '200': + description: Tree nodes + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TreeNode' + +components: + schemas: + TreeNodeTemplate: + $id: https://example.com/schemas/TreeNodeTemplate + $defs: + nodeType: + $dynamicAnchor: nodeType + not: {} + type: object + required: [id, label] + properties: + id: + type: string + label: + type: string + child: + $dynamicRef: '#nodeType' + + TreeNode: + oneOf: + - $ref: '#/components/schemas/TreeNodeLeaf' + - $defs: + nodeType: + $dynamicAnchor: nodeType + $ref: '#/components/schemas/TreeNode' + $ref: '#/components/schemas/TreeNodeTemplate' + + TreeNodeLeaf: + type: object + required: [id, label] + properties: + id: + type: string + label: + type: string 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-petstore-showcase.yaml b/specs/3.1.x/dynamicref-petstore-showcase.yaml new file mode 100644 index 0000000000..b7bafaccad --- /dev/null +++ b/specs/3.1.x/dynamicref-petstore-showcase.yaml @@ -0,0 +1,369 @@ +openapi: 3.1.0 +info: + title: DynamicRef Petstore Showcase API + description: > + A combined showcase fixture exercising all $dynamicRef patterns in a + realistic SDK-oriented API: generic pagination, generic response envelopes, + recursive category trees, nested resource graphs, non-identifier schema + keys, and typed request/response bodies. + version: 0.1.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.petstore.example +security: [] + +paths: + /pets: + get: + summary: List pets + operationId: listPets + tags: [Pets] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: Paginated list of pets + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/PaginatedPetItems' + $ref: '#/components/schemas/ApiEnvelopeTemplate' + '400': + description: Bad request + + post: + summary: Create a pet + operationId: createPet + tags: [Pets] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PetCreateRequest' + responses: + '201': + description: Created pet + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/pet' + $ref: '#/components/schemas/ApiEnvelopeTemplate' + '400': + description: Bad request + + /pets/{petId}: + get: + summary: Get a pet by ID + operationId: getPet + tags: [Pets] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: A single pet + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/pet' + $ref: '#/components/schemas/ApiEnvelopeTemplate' + '404': + description: Not found + + /owners: + get: + summary: List owners + operationId: listOwners + tags: [Owners] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: Paginated list of owners + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/PaginatedOwnerItems' + $ref: '#/components/schemas/ApiEnvelopeTemplate' + '400': + description: Bad request + + /species/tree: + get: + summary: Get species category tree + operationId: getSpeciesTree + tags: [Species] + responses: + '200': + description: Localized recursive species category tree + content: + application/json: + schema: + $ref: '#/components/schemas/LocalizedSpeciesCategory' + '400': + description: Bad request + + /shelters/{shelterId}/resources: + get: + summary: Get shelter resource tree + operationId: getShelterResources + tags: [Shelters] + parameters: + - name: shelterId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Shelter resource tree + content: + application/json: + schema: + $ref: '#/components/schemas/ShelterResource' + '404': + description: Not found + +components: + schemas: + Link: + type: object + properties: + href: + type: string + format: uri + + PetFields: + type: object + required: [name, species, status] + properties: + name: + type: string + maxLength: 100 + species: + type: string + status: + type: string + enum: [available, pending, adopted] + tag: + type: array + items: + type: string + + PetCreateRequest: + $ref: '#/components/schemas/PetFields' + + pet: + $id: https://example.com/schemas/pet + allOf: + - type: object + required: [id] + properties: + id: + type: string + format: uuid + - $ref: '#/components/schemas/PetFields' + + Owner: + type: object + required: [id, name, email] + properties: + id: + type: string + format: uuid + name: + type: string + email: + type: string + format: email + + ApiEnvelopeTemplate: + $id: https://example.com/schemas/ApiEnvelopeTemplate + $defs: + dataType: + $dynamicAnchor: dataType + not: {} + type: object + required: [data, requestId] + properties: + data: + $dynamicRef: '#dataType' + requestId: + type: string + links: + type: object + additionalProperties: + $ref: '#/components/schemas/Link' + + 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 + + PaginatedPetItems: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/pet' + $ref: '#/components/schemas/PaginatedTemplate' + + PaginatedOwnerItems: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Owner' + $ref: '#/components/schemas/PaginatedTemplate' + + BaseSpeciesCategory: + $id: https://example.com/schemas/BaseSpeciesCategory + $dynamicAnchor: speciesCategory + type: object + required: [id, label, children] + properties: + id: + type: string + label: + type: string + children: + type: array + items: + $dynamicRef: '#speciesCategory' + + LocalizedSpeciesCategory: + $id: https://example.com/schemas/LocalizedSpeciesCategory + $dynamicAnchor: speciesCategory + allOf: + - $ref: '#/components/schemas/BaseSpeciesCategory' + - type: object + required: [locale, displayName] + properties: + locale: + type: string + displayName: + type: string + + Document: + type: object + required: [kind, id, title] + properties: + kind: + const: document + id: + type: string + title: + type: string + + ShelterFolderTemplate: + $id: https://example.com/schemas/ShelterFolderTemplate + $defs: + folderType: + $dynamicAnchor: folderType + not: {} + resourceType: + $dynamicAnchor: resourceType + not: {} + type: object + required: [kind, id, name, children] + properties: + kind: + const: folder + id: + type: string + name: + type: string + children: + type: array + items: + oneOf: + - $ref: '#/components/schemas/Document' + - $dynamicRef: '#folderType' + shortcuts: + type: array + items: + $dynamicRef: '#resourceType' + + shelter-folder: + $id: https://example.com/schemas/shelter-folder + allOf: + - $defs: + folderType: + $dynamicAnchor: folderType + $ref: '#/components/schemas/shelter-folder' + resourceType: + $dynamicAnchor: resourceType + $ref: '#/components/schemas/ShelterResource' + $ref: '#/components/schemas/ShelterFolderTemplate' + - type: object + required: [accessLevel] + properties: + accessLevel: + type: string + enum: [public, staff, admin] + + ShelterResource: + type: object + oneOf: + - $ref: '#/components/schemas/Document' + - $ref: '#/components/schemas/shelter-folder' 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 4f140a262f..63f04849c4 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,79 @@ 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 TypeScript generics with type parameters instead of type aliases. + +```typescript +// Before +export type PaginatedUserResponse = PaginatedTemplate; + +// After +export type PaginatedTemplate = { + items?: Array; + total?: number; +}; + +export type PaginatedUserResponse = PaginatedTemplate; +export type PaginatedGroupResponse = PaginatedTemplate; +``` + +### 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 + +#### External `$dynamicRef` + +`$dynamicRef` values that point to external files (e.g., `$dynamicRef: 'other.json#node'`) fall back to `unknown`. The schema bundler only resolves `$ref` URIs, so external files referenced by `$dynamicRef` are never fetched. + +```typescript +// Current output for $dynamicRef: 'other.json#node' +export type Container = { item: unknown }; +``` + +**Workaround**: Move the referenced schemas into the main spec file under `components.schemas` and use internal `$dynamicRef` + `$dynamicAnchor` instead of external references. + +To see this feature supported, upvote [#3902](https://github.com/hey-api/openapi-ts/issues/3902). + +#### Shared component schemas with endpoint-specific bindings + +Each schema in `components.schemas` is generated once with a single scope. If the same named component is referenced by multiple endpoints that each provide different `$defs` bindings, only one binding applies. The common pattern — putting `$defs` bindings on inline response schemas rather than on the named component — works correctly: + +```yaml +# This works: bindings on the inline response schema +responses: + '200': + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/User' + $ref: '#/components/schemas/ApiEnvelopeTemplate' +``` + ## Resolvers You can further customize this plugin's behavior using [resolvers](/docs/openapi/typescript/plugins/concepts/resolvers).