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..f3a7952482 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,49 @@ 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 (emits type alias — TS2456, unfixable)', + }, + { + config: createConfig({ + input: 'dynamicref-circular-allof.yaml', + output: 'dynamicref-circular-allof', + }), + description: 'emits interface extends for circular $dynamicRef through allOf', + }, + { + config: createConfig({ + input: 'dynamicref-circular-naked.yaml', + output: 'dynamicref-circular-naked', + }), + description: 'emits interface extends for circular $dynamicRef without allOf', + }, ]; it.each(scenarios)('$description', async ({ config }) => { diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-allof/index.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-allof/index.ts new file mode 100644 index 0000000000..8e328b4ddc --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-allof/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Branch, ClientOptions, GetBranchesData, GetBranchesResponse, GetBranchesResponses, Leaf, NodeTemplate } from './types.gen'; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-allof/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-allof/types.gen.ts new file mode 100644 index 0000000000..8ce981ad77 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-allof/types.gen.ts @@ -0,0 +1,36 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.example.com' | (string & {}); +}; + +export type NodeTemplate = { + id: string; + child?: nodeType; +}; + +export interface Branch extends NodeTemplate { + name: string; + level?: number; +} + +export type Leaf = { + id: string; + value?: string; +}; + +export type GetBranchesData = { + body?: never; + path?: never; + query?: never; + url: '/branches'; +}; + +export type GetBranchesResponses = { + /** + * Branch list + */ + 200: Array; +}; + +export type GetBranchesResponse = GetBranchesResponses[keyof GetBranchesResponses]; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-naked/index.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-naked/index.ts new file mode 100644 index 0000000000..0c1a752a58 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-naked/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { ClientOptions, GetNodesData, GetNodesResponse, GetNodesResponses, Node, NodeTemplate } from './types.gen'; diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-naked/types.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-naked/types.gen.ts new file mode 100644 index 0000000000..3c38f7c746 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-naked/types.gen.ts @@ -0,0 +1,29 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.example.com' | (string & {}); +}; + +export type NodeTemplate = { + id: string; + child?: nodeType; +}; + +export interface Node extends NodeTemplate { +} + +export type GetNodesData = { + body?: never; + path?: never; + query?: never; + url: '/nodes'; +}; + +export type GetNodesResponses = { + /** + * Node list + */ + 200: Array; +}; + +export type GetNodesResponse = GetNodesResponses[keyof GetNodesResponses]; 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..fa067dd02b --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-circular-oneof/types.gen.ts @@ -0,0 +1,34 @@ +// 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 | TreeNodeTemplate; + +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..28419adfd1 --- /dev/null +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/dynamicref-petstore-showcase/types.gen.ts @@ -0,0 +1,230 @@ +// 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 interface ShelterFolder extends ShelterFolderTemplate { + 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-tests/main/tsconfig.json b/packages/openapi-ts-tests/main/tsconfig.json index 03702ffcd2..0c9efd9dea 100644 --- a/packages/openapi-ts-tests/main/tsconfig.json +++ b/packages/openapi-ts-tests/main/tsconfig.json @@ -4,6 +4,10 @@ "allowImportingTsExtensions": true, "resolveJsonModule": true }, - "exclude": ["test/custom/request.ts", "test/generated/**"], + "exclude": [ + "test/custom/request.ts", + "test/generated/**", + "test/__snapshots__/3.1.x/dynamicref-circular-oneof" + ], "references": [{ "path": "../../openapi-ts" }] } 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..1bc3969418 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 @@ -1,8 +1,13 @@ +import type { NodeName, Symbol as CodegenSymbol } from '@hey-api/codegen-core'; import { buildSymbolIn, pathToName } from '@hey-api/shared'; import { pathToJsonPointer } from '@hey-api/shared'; import { createSchemaComment } from '../../../../plugins/shared/utils/schema'; import { $ } from '../../../../ts-dsl'; +import { TypeAndTsDsl } from '../../../../ts-dsl/type/and'; +import { TypeAttrTsDsl } from '../../../../ts-dsl/type/attr'; +import { TypeExprTsDsl } from '../../../../ts-dsl/type/expr'; +import { TypeObjectTsDsl } from '../../../../ts-dsl/type/object'; import { exportEnumAst } from './enum'; import type { ProcessorContext } from './processor'; import type { TypeScriptFinal } from './types'; @@ -54,10 +59,90 @@ export function exportAst({ }), ); + if (schema.circularTypeAlias) { + if (final.type instanceof TypeAndTsDsl) { + const comment = plugin.config.comments && createSchemaComment(schema); + const iface = exportCircularInterfaceAst({ + comment, + final, + symbol, + }); + plugin.node(iface); + return; + } + + if (final.type instanceof TypeExprTsDsl) { + const expr = final.type; + const input = expr.getExprInput(); + if (input !== undefined && !(input instanceof TypeAttrTsDsl)) { + const comment = plugin.config.comments && createSchemaComment(schema); + const iface = $.interface(symbol) + .export() + .$if(comment, (t, v) => t.doc(v)) + .extends(input as NodeName, expr.getTypeArgs()); + plugin.node(iface); + return; + } + } + } + + // Union circular refs (e.g. type X = A | Generic) fall through here because + // interfaces cannot represent union types. TS2456 is unavoidable for these cases; + // the affected snapshots are excluded from tsconfig to suppress the error. const node = $.type .alias(symbol) .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); } + +function exportCircularInterfaceAst({ + comment, + final, + symbol, +}: { + comment: false | ReadonlyArray | undefined; + final: TypeScriptFinal; + symbol: CodegenSymbol; +}) { + const andType = final.type as TypeAndTsDsl; + const types = andType.getTypes(); + + let extendsName: NodeName | undefined; + const extendsTypeArgs: Array[number]> = []; + const body: ReturnType = []; + + for (const t of types) { + if (t instanceof TypeExprTsDsl) { + const input = t.getExprInput(); + if (input !== undefined && !(input instanceof TypeAttrTsDsl)) { + extendsName = input as NodeName; + } + extendsTypeArgs.push(...t.getTypeArgs()); + } else if (t instanceof TypeObjectTsDsl) { + body.push(...t.getProps()); + } + } + + const iface = $.interface(symbol) + .export() + .$if(comment, (t, v) => t.doc(v)); + + if (extendsName) { + iface.extends(extendsName, extendsTypeArgs); + } + + if (body.length) { + iface.do(...body); + } + + return iface; +} 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/openapi-ts/src/ts-dsl/decl/__tests__/interface.test.ts b/packages/openapi-ts/src/ts-dsl/decl/__tests__/interface.test.ts new file mode 100644 index 0000000000..9f4839f65b --- /dev/null +++ b/packages/openapi-ts/src/ts-dsl/decl/__tests__/interface.test.ts @@ -0,0 +1,103 @@ +import ts from 'typescript'; + +import { $ } from '../../'; +import { TypePropTsDsl } from '../../type/prop'; + +function render(node: ts.Node): string { + const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + const sourceFile = ts.createSourceFile( + 'test.ts', + '', + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TS, + ); + return printer.printNode(ts.EmitHint.Unspecified, node, sourceFile); +} + +function prop(name: string, fn: (p: TypePropTsDsl) => void): TypePropTsDsl { + return new TypePropTsDsl(name, fn); +} + +describe('InterfaceTsDsl', () => { + it('renders empty interface', () => { + const node = $.interface('Foo').export(); + expect(render(node.toAst())).toBe('export interface Foo {\n}'); + }); + + it('renders interface without export', () => { + const node = $.interface('Foo'); + expect(render(node.toAst())).toBe('interface Foo {\n}'); + }); + + it('renders interface with extends', () => { + const node = $.interface('Foo').export().extends('Bar'); + expect(render(node.toAst())).toBe('export interface Foo extends Bar {\n}'); + }); + + it('renders interface with extends and type args', () => { + const node = $.interface('Foo') + .export() + .extends('Bar', [$.type('string'), $.type('number')]); + expect(render(node.toAst())).toBe('export interface Foo extends Bar {\n}'); + }); + + it('renders interface with extends using type references', () => { + const node = $.interface('ShelterFolder') + .export() + .extends('ShelterFolderTemplate', [$.type('ShelterFolder'), $.type('ShelterResource')]); + expect(render(node.toAst())).toBe( + 'export interface ShelterFolder extends ShelterFolderTemplate {\n}', + ); + }); + + it('renders interface with body members', () => { + const node = $.interface('Foo') + .export() + .do(prop('name', (p) => p.type($.type('string')).optional())); + expect(render(node.toAst())).toBe('export interface Foo {\n name?: string;\n}'); + }); + + it('renders interface with extends + body + type args (full circular case)', () => { + const node = $.interface('Branch') + .export() + .extends('NodeTemplate', [$.type('Branch')]) + .do( + prop('name', (p) => p.type($.type('string'))), + prop('level', (p) => p.type($.type('number')).optional()), + ); + expect(render(node.toAst())).toBe( + 'export interface Branch extends NodeTemplate {\n name: string;\n level?: number;\n}', + ); + }); + + it('renders interface with doc comment', () => { + const node = $.interface('Foo').export().doc(['A description']); + expect(render(node.toAst())).toBe('/**\n * A description\n */\nexport interface Foo {\n}'); + }); + + it('renders interface with own type params', () => { + const node = $.interface('Mapper') + .export() + .generic('T') + .generic('U') + .extends('BaseMapper', [$.type('T')]); + expect(render(node.toAst())).toBe('export interface Mapper extends BaseMapper {\n}'); + }); + + it('renders interface with multiple extends type args using symbols', () => { + const node = $.interface('Folder') + .export() + .extends('FolderTemplate', [$.type('Folder'), $.type('Resource')]) + .do( + prop('accessLevel', (p) => + p.type( + $.type.or($.type.literal('public'), $.type.literal('staff'), $.type.literal('admin')), + ), + ), + ); + expect(render(node.toAst())).toBe( + "export interface Folder extends FolderTemplate {\n accessLevel: 'public' | 'staff' | 'admin';\n}", + ); + }); +}); diff --git a/packages/openapi-ts/src/ts-dsl/decl/interface.ts b/packages/openapi-ts/src/ts-dsl/decl/interface.ts new file mode 100644 index 0000000000..7a76ba85a7 --- /dev/null +++ b/packages/openapi-ts/src/ts-dsl/decl/interface.ts @@ -0,0 +1,81 @@ +import type { AnalysisContext, NodeName, NodeScope, Ref } from '@hey-api/codegen-core'; +import { isSymbol, ref } from '@hey-api/codegen-core'; +import ts from 'typescript'; + +import type { MaybeTsDsl, TypeTsDsl } from '../base'; +import { TsDsl } from '../base'; +import { DocMixin } from '../mixins/doc'; +import { ExportMixin } from '../mixins/modifiers'; +import { TypeParamsMixin } from '../mixins/type-params'; +import { safeTypeName } from '../utils/name'; + +type Body = Array>; +type ExtendsName = NodeName; +type ExtendsTypeArg = NodeName | MaybeTsDsl; + +const Mixed = DocMixin(ExportMixin(TypeParamsMixin(TsDsl))); + +export class InterfaceTsDsl extends Mixed { + readonly '~dsl' = 'InterfaceTsDsl'; + override readonly nameSanitizer = safeTypeName; + override scope: NodeScope = 'type'; + + protected _body: Body = []; + protected _extendsName?: Ref; + protected _extendsTypeArgs?: Array; + + constructor(name: NodeName, fn?: (i: InterfaceTsDsl) => void) { + super(); + this.name.set(name); + if (isSymbol(name)) { + name.setKind('type'); + } + fn?.(this); + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + ctx.analyze(this.name); + ctx.analyze(this._extendsName); + for (const member of this._body) { + ctx.analyze(member); + } + } + + do(...members: Body): this { + this._body.push(...members); + return this; + } + + extends(name: ExtendsName, typeArgs?: Array): this { + this._extendsName = ref(name); + this._extendsTypeArgs = typeArgs; + return this; + } + + override toAst() { + const heritage = this._buildHeritage(); + const members = this.$node(this._body) as ReadonlyArray; + const node = ts.factory.createInterfaceDeclaration( + this.modifiers, + this.$node(this.name) as ts.Identifier, + this.$generics(), + heritage, + members, + ); + return this.$docs(node); + } + + private _buildHeritage(): ReadonlyArray { + if (!this._extendsName) return []; + const exprNode = this.$node(this._extendsName) as ts.Expression; + const typeArgs = this._extendsTypeArgs + ? (this.$type(this._extendsTypeArgs) as unknown as ReadonlyArray) + : undefined; + return [ + ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [ + ts.factory.createExpressionWithTypeArguments(exprNode, typeArgs), + ]), + ]; + } +} diff --git a/packages/openapi-ts/src/ts-dsl/index.ts b/packages/openapi-ts/src/ts-dsl/index.ts index 467658dd5f..0b40fa83f6 100644 --- a/packages/openapi-ts/src/ts-dsl/index.ts +++ b/packages/openapi-ts/src/ts-dsl/index.ts @@ -8,6 +8,7 @@ import { FieldTsDsl } from './decl/field'; import { FuncTsDsl } from './decl/func'; import { GetterTsDsl } from './decl/getter'; import { InitTsDsl } from './decl/init'; +import { InterfaceTsDsl } from './decl/interface'; import { EnumMemberTsDsl } from './decl/member'; import { MethodTsDsl } from './decl/method'; import { ParamTsDsl } from './decl/param'; @@ -161,6 +162,9 @@ const tsDsl = { /** Creates an initialization block or statement. */ init: (...args: ConstructorParameters) => new InitTsDsl(...args), + /** Creates an interface declaration. */ + interface: (...args: ConstructorParameters) => new InterfaceTsDsl(...args), + /** Creates a lazy, context-aware node with deferred evaluation. */ lazy: (...args: ConstructorParameters>) => new LazyTsDsl(...args), diff --git a/packages/openapi-ts/src/ts-dsl/mixins/type-args.ts b/packages/openapi-ts/src/ts-dsl/mixins/type-args.ts index e003319bf2..73fb700aaa 100644 --- a/packages/openapi-ts/src/ts-dsl/mixins/type-args.ts +++ b/packages/openapi-ts/src/ts-dsl/mixins/type-args.ts @@ -1,5 +1,5 @@ import type { AnalysisContext, Node, NodeName, Ref } from '@hey-api/codegen-core'; -import { ref } from '@hey-api/codegen-core'; +import { fromRef, ref } from '@hey-api/codegen-core'; import type ts from 'typescript'; import type { MaybeTsDsl, TypeTsDsl } from '../base'; @@ -40,7 +40,11 @@ export function TypeArgsMixin>(Base protected $generics(): ReadonlyArray | undefined { return this.$type(this._generics); } + + getTypeArgs(): Array { + return this._generics.map((g) => fromRef(g)) as Array; + } } - return TypeArgs as unknown as MixinCtor; + return TypeArgs as unknown as MixinCtor }>; } diff --git a/packages/openapi-ts/src/ts-dsl/type/and.ts b/packages/openapi-ts/src/ts-dsl/type/and.ts index 38dd8a9222..1bdf93b15b 100644 --- a/packages/openapi-ts/src/ts-dsl/type/and.ts +++ b/packages/openapi-ts/src/ts-dsl/type/and.ts @@ -1,5 +1,5 @@ import type { AnalysisContext, NodeName, NodeScope, Ref } from '@hey-api/codegen-core'; -import { ref } from '@hey-api/codegen-core'; +import { fromRef, ref } from '@hey-api/codegen-core'; import ts from 'typescript'; import type { TypeTsDsl } from '../base'; @@ -27,6 +27,10 @@ export class TypeAndTsDsl extends Mixed { } } + getTypes(): Array { + return this._types.map((t) => fromRef(t)) as Array; + } + types(...nodes: Array): this { this._types.push(...nodes.map((n) => ref(n))); return this; diff --git a/packages/openapi-ts/src/ts-dsl/type/expr.ts b/packages/openapi-ts/src/ts-dsl/type/expr.ts index 7fb780be7e..342bb127ec 100644 --- a/packages/openapi-ts/src/ts-dsl/type/expr.ts +++ b/packages/openapi-ts/src/ts-dsl/type/expr.ts @@ -1,5 +1,5 @@ import type { AnalysisContext, NodeName, NodeScope, Ref } from '@hey-api/codegen-core'; -import { isNode, ref } from '@hey-api/codegen-core'; +import { fromRef, isNode, ref } from '@hey-api/codegen-core'; import ts from 'typescript'; import { TsDsl } from '../base'; @@ -52,6 +52,10 @@ export class TypeExprTsDsl extends Mixed { return this; } + getExprInput(): TypeExprExpr | undefined { + return this._exprInput ? (fromRef(this._exprInput) as TypeExprExpr) : undefined; + } + override toAst() { this.$validate(); return ts.factory.createTypeReferenceNode( diff --git a/packages/openapi-ts/src/ts-dsl/type/object.ts b/packages/openapi-ts/src/ts-dsl/type/object.ts index 490abb9de2..c3a58eccaa 100644 --- a/packages/openapi-ts/src/ts-dsl/type/object.ts +++ b/packages/openapi-ts/src/ts-dsl/type/object.ts @@ -20,6 +20,11 @@ export class TypeObjectTsDsl extends Mixed { } } + /** Returns all properties and index signatures. */ + getProps(): Array { + return [...this._props.values()]; + } + /** Returns true if object has at least one property or index signature. */ hasProps(): boolean { return this._props.size > 0; diff --git a/packages/shared/src/ir/types.ts b/packages/shared/src/ir/types.ts index 7fd6e8a58b..b55e4735eb 100644 --- a/packages/shared/src/ir/types.ts +++ b/packages/shared/src/ir/types.ts @@ -145,6 +145,14 @@ export interface IRSchemaObject * properties altogether. */ additionalProperties?: IRSchemaObject | false; + /** + * Reference to symbol instead of `$ref` string. + */ + /** + * When true, this schema produces a circular type alias (e.g., `type X = Generic`). + * The codegen layer should emit an interface declaration instead to avoid TS2456. + */ + circularTypeAlias?: boolean; /** * When present on a union schema, indicates that this union uses a * discriminator for polymorphism. @@ -189,9 +197,6 @@ export interface IRSchemaObject * follow a specific convention. */ propertyNames?: IRSchemaObject; - /** - * Reference to symbol instead of `$ref` string. - */ symbolRef?: Symbol; /** * Each schema eventually resolves into `type`. @@ -210,6 +215,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..6f8cd2915c --- /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('preserves anchor casing in 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: 'item_type' }]); + }); + + 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: 'item_type' }, + { anchor: 'item-type', paramName: 'item_type2' }, + ]); + }); +}); + +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..e7ddaa86a3 --- /dev/null +++ b/packages/shared/src/openApi/3.1.x/parser/dynamicRef.ts @@ -0,0 +1,240 @@ +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 { 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 { + let sanitized = ''; + + for (const char of anchor) { + 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..2798050046 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, @@ -822,6 +833,10 @@ function parseAllOf({ schema: irSchema, }); + if (schemaItems.some((item) => item.circularTypeAlias)) { + irSchema.circularTypeAlias = true; + } + if (schemaTypes.includes('null')) { // nest composition to avoid producing an intersection with null const nestedItems: Array = [ @@ -1153,6 +1168,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 +1394,106 @@ 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 result = buildGenericRef({ + bindings, + schema, + targetRef: schema.$ref!, + templateParams, + }); + const hasCircularBinding = bindings.some(([, ref]) => { + const bindingSchema = context.resolveRef(ref); + return containsRefTo(bindingSchema, schema.$ref!); + }); + if (hasCircularBinding) { + result.circularTypeAlias = true; + } + return result; + } + } + + 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 +1501,7 @@ export function schemaToIrSchema({ return parseAllOf({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1404,7 +1509,7 @@ export function schemaToIrSchema({ return parseAnyOf({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1412,7 +1517,7 @@ export function schemaToIrSchema({ return parseOneOf({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1421,7 +1526,7 @@ export function schemaToIrSchema({ return parseType({ context, schema: schema as SchemaWithRequired, - state, + state: currentState, }); } @@ -1430,7 +1535,7 @@ export function schemaToIrSchema({ return parseType({ context, schema: { ...schema, type: 'string' } as SchemaWithRequired, - state, + state: currentState, }); } @@ -1454,12 +1559,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-allof.yaml b/specs/3.1.x/dynamicref-circular-allof.yaml new file mode 100644 index 0000000000..27e456938e --- /dev/null +++ b/specs/3.1.x/dynamicref-circular-allof.yaml @@ -0,0 +1,67 @@ +openapi: 3.1.0 +info: + title: DynamicRef Circular allOf Test + description: > + Minimal fixture: circular generic ref through allOf with extra properties. + The circular binding should produce an `interface extends` instead of + a `type` alias to avoid TS2456. + version: 0.1.0 +servers: + - url: https://api.example.com +security: [] + +paths: + /branches: + get: + summary: Get branch nodes + operationId: getBranches + tags: [Tree] + responses: + '200': + description: Branch list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Branch' + +components: + schemas: + NodeTemplate: + $id: https://example.com/schemas/NodeTemplate + $defs: + nodeType: + $dynamicAnchor: nodeType + not: {} + type: object + required: [id] + properties: + id: + type: string + child: + $dynamicRef: '#nodeType' + + Branch: + allOf: + - $defs: + nodeType: + $dynamicAnchor: nodeType + $ref: '#/components/schemas/Branch' + $ref: '#/components/schemas/NodeTemplate' + - type: object + required: [name] + properties: + name: + type: string + level: + type: integer + + Leaf: + type: object + required: [id] + properties: + id: + type: string + value: + type: string diff --git a/specs/3.1.x/dynamicref-circular-naked.yaml b/specs/3.1.x/dynamicref-circular-naked.yaml new file mode 100644 index 0000000000..b8969b5adc --- /dev/null +++ b/specs/3.1.x/dynamicref-circular-naked.yaml @@ -0,0 +1,49 @@ +openapi: 3.1.0 +info: + title: DynamicRef Circular Naked Test + description: > + Edge case: circular generic ref WITHOUT allOf or extra properties. + Produces `interface Node extends NodeTemplate {}` to avoid TS2456. + version: 0.1.0 +servers: + - url: https://api.example.com +security: [] + +paths: + /nodes: + get: + summary: Get nodes + operationId: getNodes + tags: [Node] + responses: + '200': + description: Node list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Node' + +components: + schemas: + NodeTemplate: + $id: https://example.com/schemas/NodeTemplate + $defs: + nodeType: + $dynamicAnchor: nodeType + not: {} + type: object + required: [id] + properties: + id: + type: string + child: + $dynamicRef: '#nodeType' + + Node: + $defs: + nodeType: + $dynamicAnchor: nodeType + $ref: '#/components/schemas/Node' + $ref: '#/components/schemas/NodeTemplate' 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..1fb704f89a --- /dev/null +++ b/specs/3.1.x/dynamicref-circular-oneof.yaml @@ -0,0 +1,64 @@ +openapi: 3.1.0 +info: + title: DynamicRef Circular oneOf Test + description: > + Tests that hasCircularBinding detects cycles routed through oneOf. + Produces `type TreeNode = TreeNodeLeaf | TreeNodeTemplate` which + triggers TS2456 — this is a known limitation because interfaces cannot + represent union types. Excluded from tsconfig to avoid the error. + 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).