diff --git a/packages/openapi-ts/src/openApi/2.0.x/parser/schema.ts b/packages/openapi-ts/src/openApi/2.0.x/parser/schema.ts index f8301b6a4..e39081462 100644 --- a/packages/openapi-ts/src/openApi/2.0.x/parser/schema.ts +++ b/packages/openapi-ts/src/openApi/2.0.x/parser/schema.ts @@ -7,7 +7,7 @@ import type { SchemaWithRequired, } from '~/openApi/shared/types/schema'; import { discriminatorValues } from '~/openApi/shared/utils/discriminator'; -import { refToName } from '~/utils/ref'; +import { isTopLevelComponentRef, refToName } from '~/utils/ref'; import type { SchemaObject } from '../types/spec'; @@ -554,8 +554,9 @@ const parseRef = ({ state: SchemaState; }): IR.SchemaObject => { const irSchema: IR.SchemaObject = {}; - // Inline non-component refs (e.g. #/paths/...) to avoid generating orphaned named types - const isComponentsRef = schema.$ref.startsWith('#/definitions/'); + // Inline non-component refs (e.g. #/paths/...) and deep path refs (e.g. #/definitions/Foo/properties/bar) + // to avoid generating orphaned named types or referencing unregistered symbols + const isComponentsRef = isTopLevelComponentRef(schema.$ref); if (!isComponentsRef) { if (!state.circularReferenceTracker.has(schema.$ref)) { const refSchema = context.resolveRef(schema.$ref); diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts index b6dfd33a1..8391f3c0f 100644 --- a/packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts @@ -7,7 +7,7 @@ import type { SchemaWithRequired, } from '~/openApi/shared/types/schema'; import { discriminatorValues } from '~/openApi/shared/utils/discriminator'; -import { refToName } from '~/utils/ref'; +import { isTopLevelComponentRef, refToName } from '~/utils/ref'; import type { ReferenceObject, SchemaObject } from '../types/spec'; @@ -976,8 +976,9 @@ const parseRef = ({ schema: ReferenceObject; state: SchemaState; }): IR.SchemaObject => { - // Inline non-component refs (e.g. #/paths/...) to avoid generating orphaned named types - const isComponentsRef = schema.$ref.startsWith('#/components/'); + // Inline non-component refs (e.g. #/paths/...) and deep path refs (e.g. #/components/schemas/Foo/properties/bar) + // to avoid generating orphaned named types or referencing unregistered symbols + const isComponentsRef = isTopLevelComponentRef(schema.$ref); if (!isComponentsRef) { if (!state.circularReferenceTracker.has(schema.$ref)) { const refSchema = context.resolveRef(schema.$ref); diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts index 0ba9706da..43a06fc2e 100644 --- a/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts @@ -7,7 +7,7 @@ import type { SchemaWithRequired, } from '~/openApi/shared/types/schema'; import { discriminatorValues } from '~/openApi/shared/utils/discriminator'; -import { refToName } from '~/utils/ref'; +import { isTopLevelComponentRef, refToName } from '~/utils/ref'; import type { SchemaObject } from '../types/spec'; @@ -1037,8 +1037,9 @@ const parseRef = ({ schema: SchemaWithRequired; state: SchemaState; }): IR.SchemaObject => { - // Inline non-component refs (e.g. #/paths/...) to avoid generating orphaned named types - const isComponentsRef = schema.$ref.startsWith('#/components/'); + // Inline non-component refs (e.g. #/paths/...) and deep path refs (e.g. #/components/schemas/Foo/properties/bar) + // to avoid generating orphaned named types or referencing unregistered symbols + const isComponentsRef = isTopLevelComponentRef(schema.$ref); if (!isComponentsRef) { if (!state.circularReferenceTracker.has(schema.$ref)) { const refSchema = context.resolveRef(schema.$ref); diff --git a/packages/openapi-ts/src/utils/__tests__/ref.test.ts b/packages/openapi-ts/src/utils/__tests__/ref.test.ts new file mode 100644 index 000000000..251af2929 --- /dev/null +++ b/packages/openapi-ts/src/utils/__tests__/ref.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; + +import { + isTopLevelComponentRef, + jsonPointerToPath, + pathToJsonPointer, +} from '../ref'; + +describe('jsonPointerToPath', () => { + it('parses root pointer', () => { + expect(jsonPointerToPath('#')).toEqual([]); + expect(jsonPointerToPath('')).toEqual([]); + }); + + it('parses component ref', () => { + expect(jsonPointerToPath('#/components/schemas/Foo')).toEqual([ + 'components', + 'schemas', + 'Foo', + ]); + }); + + it('parses deep path ref', () => { + expect( + jsonPointerToPath('#/components/schemas/Foo/properties/bar/items'), + ).toEqual(['components', 'schemas', 'Foo', 'properties', 'bar', 'items']); + }); +}); + +describe('pathToJsonPointer', () => { + it('converts empty path to root pointer', () => { + expect(pathToJsonPointer([])).toBe('#'); + }); + + it('converts path to pointer', () => { + expect(pathToJsonPointer(['components', 'schemas', 'Foo'])).toBe( + '#/components/schemas/Foo', + ); + }); +}); + +describe('isTopLevelComponentRef', () => { + describe('OpenAPI 3.x refs', () => { + it('returns true for top-level component refs', () => { + expect(isTopLevelComponentRef('#/components/schemas/Foo')).toBe(true); + expect(isTopLevelComponentRef('#/components/parameters/Bar')).toBe(true); + expect(isTopLevelComponentRef('#/components/responses/Error')).toBe(true); + expect(isTopLevelComponentRef('#/components/requestBodies/Body')).toBe( + true, + ); + }); + + it('returns false for deep path refs', () => { + expect( + isTopLevelComponentRef('#/components/schemas/Foo/properties/bar'), + ).toBe(false); + expect( + isTopLevelComponentRef('#/components/schemas/Foo/properties/bar/items'), + ).toBe(false); + expect(isTopLevelComponentRef('#/components/schemas/Foo/allOf/0')).toBe( + false, + ); + }); + }); + + describe('OpenAPI 2.0 refs', () => { + it('returns true for top-level definitions refs', () => { + expect(isTopLevelComponentRef('#/definitions/Foo')).toBe(true); + expect(isTopLevelComponentRef('#/definitions/Bar')).toBe(true); + }); + + it('returns false for deep path refs', () => { + expect(isTopLevelComponentRef('#/definitions/Foo/properties/bar')).toBe( + false, + ); + expect( + isTopLevelComponentRef('#/definitions/Foo/properties/bar/items'), + ).toBe(false); + }); + }); + + describe('non-component refs', () => { + it('returns false for path refs', () => { + expect(isTopLevelComponentRef('#/paths/~1users/get')).toBe(false); + }); + + it('returns false for other refs', () => { + expect(isTopLevelComponentRef('#/info/title')).toBe(false); + }); + }); +}); diff --git a/packages/openapi-ts/src/utils/ref.ts b/packages/openapi-ts/src/utils/ref.ts index 236770bce..289b404ab 100644 --- a/packages/openapi-ts/src/utils/ref.ts +++ b/packages/openapi-ts/src/utils/ref.ts @@ -94,6 +94,35 @@ export const pathToJsonPointer = ( return '#' + (segments ? `/${segments}` : ''); }; +/** + * Checks if a $ref points to a top-level component (not a deep path reference). + * + * Top-level component references: + * - OpenAPI 3.x: #/components/{type}/{name} (3 segments) + * - OpenAPI 2.0: #/definitions/{name} (2 segments) + * + * Deep path references (4+ segments for 3.x, 3+ for 2.0) should be inlined + * because they don't have corresponding registered symbols. + * + * @param $ref - The $ref string to check + * @returns true if the ref points to a top-level component, false otherwise + */ +export const isTopLevelComponentRef = ($ref: string): boolean => { + const path = jsonPointerToPath($ref); + + // OpenAPI 3.x: #/components/{type}/{name} = 3 segments + if (path[0] === 'components') { + return path.length === 3; + } + + // OpenAPI 2.0: #/definitions/{name} = 2 segments + if (path[0] === 'definitions') { + return path.length === 2; + } + + return false; +}; + export const resolveRef = ({ $ref, spec, -- 2.51.2 From 5ae0baaf6b7b9c4db0bb0b600d4204ba78846d3b Mon Sep 17 00:00:00 2001 From: chrg1001 <40189653+chrg1001@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:46:07 +0900 Subject: [PATCH 2/4] test: add E2E tests for deep path $ref handling Cover the isTopLevelComponentRef calls in parser/schema.ts for all OpenAPI versions (2.0, 3.0.x, 3.1.x) to improve patch coverage. --- .../openapi-ts/src/__tests__/index.test.ts | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/packages/openapi-ts/src/__tests__/index.test.ts b/packages/openapi-ts/src/__tests__/index.test.ts index c3374ee5c..cbde1145a 100644 --- a/packages/openapi-ts/src/__tests__/index.test.ts +++ b/packages/openapi-ts/src/__tests__/index.test.ts @@ -5,6 +5,143 @@ import { createClient } from '~/index'; type Config = Parameters[0]; describe('createClient', () => { + it('handles deep path $ref without errors', async () => { + // This test verifies that deep path refs like + // #/components/schemas/Foo/properties/bar/items are inlined + // instead of being treated as symbol references (which would fail) + const config: Config = { + dryRun: true, + input: { + components: { + schemas: { + Bar: { + properties: { + nested: { + // Deep path ref - should be inlined, not treated as symbol + $ref: '#/components/schemas/Foo/properties/items/items', + }, + }, + type: 'object', + }, + Foo: { + properties: { + items: { + items: { + properties: { + name: { type: 'string' }, + }, + type: 'object', + }, + type: 'array', + }, + }, + type: 'object', + }, + }, + }, + info: { title: 'deep-ref-test', version: '1.0.0' }, + openapi: '3.1.0', + }, + logs: { + level: 'silent', + }, + output: 'output', + plugins: ['@hey-api/typescript'], + }; + + // Should not throw "Symbol finalName has not been resolved yet" error + const results = await createClient(config); + expect(results).toHaveLength(1); + }); + + it('handles deep path $ref in OpenAPI 3.0.x without errors', async () => { + const config: Config = { + dryRun: true, + input: { + components: { + schemas: { + Bar: { + properties: { + nested: { + $ref: '#/components/schemas/Foo/properties/items/items', + }, + }, + type: 'object', + }, + Foo: { + properties: { + items: { + items: { + properties: { + name: { type: 'string' }, + }, + type: 'object', + }, + type: 'array', + }, + }, + type: 'object', + }, + }, + }, + info: { title: 'deep-ref-test', version: '1.0.0' }, + openapi: '3.0.0', + paths: {}, + }, + logs: { + level: 'silent', + }, + output: 'output', + plugins: ['@hey-api/typescript'], + }; + + const results = await createClient(config); + expect(results).toHaveLength(1); + }); + + it('handles deep path $ref in OpenAPI 2.0 (Swagger) without errors', async () => { + const config: Config = { + dryRun: true, + input: { + definitions: { + Bar: { + properties: { + nested: { + $ref: '#/definitions/Foo/properties/items/items', + }, + }, + type: 'object', + }, + Foo: { + properties: { + items: { + items: { + properties: { + name: { type: 'string' }, + }, + type: 'object', + }, + type: 'array', + }, + }, + type: 'object', + }, + }, + info: { title: 'deep-ref-test', version: '1.0.0' }, + paths: {}, + swagger: '2.0', + }, + logs: { + level: 'silent', + }, + output: 'output', + plugins: ['@hey-api/typescript'], + }; + + const results = await createClient(config); + expect(results).toHaveLength(1); + }); + it('1 config, 1 input, 1 output', async () => { const config: Config = { dryRun: true, -- 2.51.2 From d94368bcd9205ceb7e5641644032e26c17b2a1af Mon Sep 17 00:00:00 2001 From: chrg1001 <40189653+chrg1001@users.noreply.github.com> Date: Fri, 23 Jan 2026 09:28:09 +0900 Subject: [PATCH 3/4] test: add test case for deep #/paths/... references --- specs/3.0.x/deep-path-ref.yaml | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 specs/3.0.x/deep-path-ref.yaml diff --git a/specs/3.0.x/deep-path-ref.yaml b/specs/3.0.x/deep-path-ref.yaml new file mode 100644 index 000000000..7c05dc11a --- /dev/null +++ b/specs/3.0.x/deep-path-ref.yaml @@ -0,0 +1,39 @@ +openapi: 3.0.1 +info: + title: Deep Path Ref Test + description: Test case for deep path references in #/paths/... + version: '1' +paths: + /users: + get: + operationId: getUsers + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + /posts: + get: + operationId: getPosts + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + author: + # Deep path reference to /users response schema item + $ref: '#/paths/~1users/get/responses/200/content/application~1json/schema/items' + title: + type: string -- 2.51.2 From 00cfaed32a3b1d2a7bbd5c4c2a946a9f7be262b2 Mon Sep 17 00:00:00 2001 From: Lubos Date: Sat, 24 Jan 2026 08:06:26 +0800 Subject: [PATCH 4/4] Update fix message to use parser prefix --- .changeset/cuddly-jokes-walk.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cuddly-jokes-walk.md diff --git a/.changeset/cuddly-jokes-walk.md b/.changeset/cuddly-jokes-walk.md new file mode 100644 index 000000000..def2de8a0 --- /dev/null +++ b/.changeset/cuddly-jokes-walk.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +**parser**: inline deep path `$ref` references