Skip to content

Commit 0ea5571

Browse files
authored
fix(core): inline non-component $refs to avoid broken named imports (#398) (#3355)
* fix(core): inline non-component $refs to avoid broken named imports (#398) A `$ref` like `#/paths/~1{id}/get/parameters/0/schema` (URL-encoded JSON Pointer to an inline schema, as emitted by JSON-Schema-Ref-Parser `bundle()`) used to crash orval ("Ref not found"). The crash was fixed incidentally, but the bug just changed shape: orval emitted `import type { Schema } from './model'` for an undeclared `Schema` type, producing TypeScript that no longer compiles. Root cause: `resolveValue` treated every resolved `$ref` as a named, exportable type — but only refs under `components.{schemas,responses,parameters,requestBodies}` actually have a corresponding `export type` emitted by `schema-definition.ts`. Refs to any other location (typically bundler artefacts) had no target import. Fix: introduce `isComponentRef` in `getters/ref.ts`, derived from a single `NAMED_COMPONENT_SECTIONS` source of truth that also feeds `RefComponentSuffix`. In `resolveValue`, when the user-facing `$ref` isn't a named component ref, fall through to `getScalar` so the resolved schema is inlined exactly like a non-`$ref` schema would be. Tests: - `packages/core/src/getters/ref.test.ts` — `isComponentRef` matrix - `packages/core/src/resolvers/value.test.ts` — inline vs named-import branching - `tests/specifications/issue-398-encoded-path-ref.yaml` — full regression spec (the exact YAML from the issue) * fix(core): drop unnecessary cast and add cycle guard for inline path-refs Address PR review feedback on #3355: - `OpenApiReferenceObject` already declares `$ref?: string`, so the `(schema as { $ref?: string })` cast was redundant. The `refValue && ...` truthiness check stays because `$ref` is optional. - A self-referential path-ref (e.g. a schema whose `$ref` points to a location that contains a `$ref` back to itself) would recurse through `getScalar` -> `resolveValue` forever, because the existing parent guard tracks `resolvedImport.name` rather than the ref string. Track the ref string in `context.parents` for the inline branch and bail to `unknown` on a detected cycle — anonymous recursive types can't be expressed in TypeScript anyway.
1 parent b30537d commit 0ea5571

8 files changed

Lines changed: 300 additions & 7 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { isComponentRef } from './ref';
4+
5+
describe('isComponentRef', () => {
6+
it.each([
7+
'#/components/schemas/Pet',
8+
'#/components/responses/ErrorResponse',
9+
'#/components/parameters/PetId',
10+
'#/components/requestBodies/CreatePetBody',
11+
])('returns true for named component ref %s', (ref) => {
12+
expect(isComponentRef(ref)).toBe(true);
13+
});
14+
15+
it.each([
16+
// issue #398: bundler-emitted JSON Pointer into an inline schema
17+
'#/paths/~1{id}/get/parameters/0/schema',
18+
'#/paths/~1%7Bid%7D/get/parameters/0/schema',
19+
// component sections that orval does not emit as named imports
20+
'#/components/headers/X-Rate-Limit',
21+
'#/components/examples/PetExample',
22+
'#/components/securitySchemes/ApiKey',
23+
// OAS 3.1 inline definitions
24+
'#/$defs/Pet',
25+
// External-file refs (after dereferenceExternalRef these never reach value resolution,
26+
// but the predicate must reject them defensively)
27+
'other.yaml#/components/schemas/Pet',
28+
'http://example.com/schemas#/components/schemas/Pet',
29+
// Malformed / nested-name variants
30+
'#/components/schemas/Foo/Bar',
31+
'#/components/schemas/',
32+
'',
33+
])('returns false for non-named ref %s', (ref) => {
34+
expect(isComponentRef(ref)).toBe(false);
35+
});
36+
37+
it('handles JSON-Pointer-encoded names containing slashes', () => {
38+
// RFC 6901: literal "/" inside a name is encoded as "~1".
39+
// The decoded name is "My/Type" but the ref string segment has no literal slash.
40+
expect(isComponentRef('#/components/schemas/My~1Type')).toBe(true);
41+
});
42+
});

packages/core/src/getters/ref.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import type { ContextSpec, NormalizedOverrideOutput } from '../types';
22
import { pascal, sanitize, upath } from '../utils';
33

4-
type RefComponent = 'schemas' | 'responses' | 'parameters' | 'requestBodies';
4+
/**
5+
* `$ref`s targeting these sections under `#/components/...` are emitted as
6+
* named TypeScript imports (e.g. `import type { Pet } from './model'`).
7+
* Refs to any other location — for example `#/paths/.../schema` produced by
8+
* JSON-Schema-Ref-Parser `bundle()` — have no corresponding `export type`
9+
* and must be inlined by the resolver. See issue #398.
10+
*/
11+
export const NAMED_COMPONENT_SECTIONS = [
12+
'schemas',
13+
'responses',
14+
'parameters',
15+
'requestBodies',
16+
] as const;
517

6-
const RefComponent = {
7-
schemas: 'schemas' as RefComponent,
8-
responses: 'responses' as RefComponent,
9-
parameters: 'parameters' as RefComponent,
10-
requestBodies: 'requestBodies' as RefComponent,
11-
};
18+
type RefComponent = (typeof NAMED_COMPONENT_SECTIONS)[number];
1219

1320
export const RefComponentSuffix: Record<RefComponent, string> = {
1421
schemas: '',
@@ -17,6 +24,19 @@ export const RefComponentSuffix: Record<RefComponent, string> = {
1724
requestBodies: 'Body',
1825
};
1926

27+
const COMPONENT_REF_PATTERN = new RegExp(
28+
String.raw`^#\/components\/(${NAMED_COMPONENT_SECTIONS.join('|')})\/[^/]+$`,
29+
);
30+
31+
/**
32+
* True iff `ref` targets a named slot eligible for emission as a TypeScript
33+
* import. Used by `resolveValue` to decide between named import vs inlining
34+
* the resolved schema.
35+
*/
36+
export function isComponentRef(ref: string): boolean {
37+
return COMPONENT_REF_PATTERN.test(ref);
38+
}
39+
2040
const regex = new RegExp('~1', 'g');
2141

2242
export interface RefInfo {
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type {
4+
ContextSpec,
5+
OpenApiDocument,
6+
OpenApiReferenceObject,
7+
} from '../types';
8+
import { resolveValue } from './value';
9+
10+
function createContext(spec: OpenApiDocument): ContextSpec {
11+
return {
12+
target: 'core-test',
13+
workspace: '/tmp',
14+
spec,
15+
output: {
16+
override: {
17+
components: {
18+
schemas: { suffix: '' },
19+
},
20+
},
21+
},
22+
} as ContextSpec;
23+
}
24+
25+
describe('resolveValue', () => {
26+
it('emits a named import for a component schema ref', () => {
27+
const context = createContext({
28+
openapi: '3.1.0',
29+
components: {
30+
schemas: {
31+
Pet: {
32+
type: 'object',
33+
properties: { id: { type: 'string' } },
34+
},
35+
},
36+
},
37+
});
38+
39+
const result = resolveValue({
40+
schema: { $ref: '#/components/schemas/Pet' } as OpenApiReferenceObject,
41+
context,
42+
});
43+
44+
expect(result.value).toBe('Pet');
45+
expect(result.imports[0]).toMatchObject({
46+
name: 'Pet',
47+
schemaName: 'Pet',
48+
});
49+
expect(result.isRef).toBe(true);
50+
});
51+
52+
// Regression for issue #398: a $ref like `#/paths/.../schema` (emitted by
53+
// JSON-Schema-Ref-Parser bundle()) resolves to an inline schema with no
54+
// corresponding `export type`. orval previously generated a broken
55+
// `import { Schema } from './model'` referencing an undeclared type.
56+
it('inlines a path-based ref instead of emitting a broken import', () => {
57+
const context = createContext({
58+
openapi: '3.0.3',
59+
paths: {
60+
'/{id}': {
61+
get: {
62+
parameters: [
63+
{
64+
in: 'path',
65+
name: 'id',
66+
required: true,
67+
schema: { type: 'string' },
68+
},
69+
],
70+
responses: {
71+
'200': {
72+
description: 'OK',
73+
content: {
74+
'application/json': {
75+
schema: {
76+
$ref: '#/paths/~1%7Bid%7D/get/parameters/0/schema',
77+
},
78+
},
79+
},
80+
},
81+
},
82+
},
83+
},
84+
},
85+
} as unknown as OpenApiDocument);
86+
87+
const result = resolveValue({
88+
schema: {
89+
$ref: '#/paths/~1%7Bid%7D/get/parameters/0/schema',
90+
} as OpenApiReferenceObject,
91+
context,
92+
});
93+
94+
expect(result.value).toBe('string');
95+
expect(result.imports).toHaveLength(0);
96+
expect(result.isRef).toBe(false);
97+
});
98+
99+
// Defensive guard: a self-referential path-ref would otherwise recurse via
100+
// getScalar -> resolveValue forever, since the named-ref cycle tracker keys
101+
// off `resolvedImport.name` and not the ref string.
102+
it('breaks cycles on self-referential path-based refs', () => {
103+
const selfRef =
104+
'#/paths/~1self/get/responses/200/content/application~1json/schema';
105+
const context = createContext({
106+
openapi: '3.0.3',
107+
paths: {
108+
'/self': {
109+
get: {
110+
responses: {
111+
'200': {
112+
description: 'OK',
113+
content: {
114+
'application/json': {
115+
schema: {
116+
type: 'object',
117+
properties: {
118+
child: { $ref: selfRef },
119+
},
120+
},
121+
},
122+
},
123+
},
124+
},
125+
},
126+
},
127+
},
128+
} as unknown as OpenApiDocument);
129+
130+
expect(() =>
131+
resolveValue({
132+
schema: { $ref: selfRef } as OpenApiReferenceObject,
133+
context,
134+
}),
135+
).not.toThrow();
136+
});
137+
});

packages/core/src/resolvers/value.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getScalar } from '../getters';
22
import type { FormDataContext } from '../getters/object';
3+
import { isComponentRef } from '../getters/ref';
34
import type {
45
ContextSpec,
56
GeneratorImport,
@@ -25,6 +26,7 @@ export function resolveValue({
2526
formDataContext,
2627
}: ResolveValueOptions): ResolverValue {
2728
if (isReference(schema)) {
29+
const refValue = schema.$ref;
2830
const {
2931
schema: schemaObject,
3032
imports,
@@ -33,6 +35,41 @@ export function resolveValue({
3335
imports: GeneratorImport[];
3436
} = resolveRef(schema, context);
3537

38+
// Refs that don't target a named component slot (e.g. bundler-emitted
39+
// `#/paths/.../schema`) have no corresponding `export type`, so emitting
40+
// a named import would dangle. Inline the resolved schema instead.
41+
// See issue #398.
42+
if (refValue && !isComponentRef(refValue)) {
43+
// Inlining walks nested $refs via getScalar -> resolveValue. A
44+
// self-referential path-ref would recurse forever because the named-ref
45+
// cycle guard below tracks `resolvedImport.name`, not the ref string.
46+
// Fall back to `unknown` to break the chain — anonymous recursive types
47+
// can't be expressed in TypeScript anyway.
48+
if (context.parents?.includes(refValue)) {
49+
return {
50+
value: 'unknown',
51+
imports: [],
52+
schemas: [],
53+
type: 'unknown',
54+
isEnum: false,
55+
originalSchema: schemaObject,
56+
hasReadonlyProps: false,
57+
isRef: false,
58+
dependencies: [],
59+
};
60+
}
61+
const scalar = getScalar({
62+
item: schemaObject,
63+
name,
64+
context: {
65+
...context,
66+
parents: [...(context.parents ?? []), refValue],
67+
},
68+
formDataContext,
69+
});
70+
return { ...scalar, originalSchema: schemaObject, isRef: false };
71+
}
72+
3673
const resolvedImport = imports[0];
3774

3875
let hasReadonlyProps = false;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Generated by orval v8.10.0 🍺
3+
* Do not edit manually.
4+
* Issue 398 — encoded path ref
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
import axios from 'axios';
8+
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
9+
10+
export const getById = (
11+
id: string,
12+
options?: AxiosRequestConfig,
13+
): Promise<AxiosResponse<string>> => {
14+
return axios.get(`/${id}`, {
15+
...options,
16+
});
17+
};
18+
19+
export type GetByIdResult = AxiosResponse<string>;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Generated by orval v8.10.0 🍺
3+
* Do not edit manually.
4+
* Issue 398 — encoded path ref
5+
* OpenAPI spec version: 1.0.0
6+
*/

tests/configs/default.config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,17 @@ export default defineConfig({
670670
target: '../specifications/issue-2998.yaml',
671671
},
672672
},
673+
'issue-398-encoded-path-ref': {
674+
output: {
675+
target: '../generated/default/issue-398-encoded-path-ref/endpoints.ts',
676+
schemas: '../generated/default/issue-398-encoded-path-ref/model',
677+
clean: true,
678+
formatter: 'prettier',
679+
},
680+
input: {
681+
target: '../specifications/issue-398-encoded-path-ref.yaml',
682+
},
683+
},
673684
'boolean-discriminator': {
674685
output: {
675686
target: '../generated/default/boolean-discriminator/endpoints.ts',
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
openapi: 3.0.3
2+
info:
3+
title: Issue 398 — encoded path ref
4+
version: 1.0.0
5+
paths:
6+
/{id}:
7+
get:
8+
operationId: getById
9+
parameters:
10+
- in: path
11+
name: id
12+
required: true
13+
schema:
14+
type: string
15+
responses:
16+
'200':
17+
description: OK
18+
content:
19+
application/json:
20+
schema:
21+
$ref: '#/paths/~1%7Bid%7D/get/parameters/0/schema'

0 commit comments

Comments
 (0)