Skip to content

Commit a0e60a9

Browse files
the-ultArjen
andauthored
fix(zod,fetch): resolve $ref schemas with suffix in runtime validation (#3059)
* fix: resolve zod $ref schemas when component suffix is enabled (refs #3027) * test: add issue-3027 fixtures and snapshots for zod suffix ref regression * fix: update ESLint configuration and improve test assertions for package-json * chore: remove temp pr-description file * chore(pr): address review feedback and remove unrelated changes - remove broad ts-nocheck usage in package-json test and use typed-safe assertion - drop temporary ESLint override added only for that suppression - revert unrelated solid-query infinite defaults change from query generator * chore(test): restore lint-safe baseline for package-json test - re-add ts-nocheck in package-json.test.ts to avoid widespread mock typing noise - re-add file-scoped ban-ts-comment override for this legacy test file - keep PR focused on issue #3027 while maintaining passing lint/checks * refactor(zod,core): improve review findings — JSDoc, logging, edge-case tests - Replace silent catch {} in dereference with tryResolveRefSchema helper that logs via logVerbose on failure for easier debugging - Extract double-IIFE into named tryResolveRefSchema function - Deduplicate error messages into REF_NOT_FOUND_PREFIX constant - Add JSDoc to resolveRef, getSchema, dereference, resolveExampleRefs - Remove unnecessary double-cast in resolveExampleRefs - Add 6 zod.test.ts edge cases: circular ref, additionalProperties, nullable ref, deep nested allOf, empty suffix regression - Add 3 ref.test.ts edge cases: suffix import name, passthrough, fallback - Expand issue-3027 fixture with PositionMap, TreeNode, NullablePositionWrapper schemas - Document @ts-nocheck rationale in package-json.test.ts - Regenerate fetch and zod snapshots * test(ref): document current behavior for nonexistent local refs (KNOWN ISSUE) * fix(ref): enhance example resolution to properly handle resolved examples --------- Co-authored-by: Arjen <ult_dev@pm.me>
1 parent 5a425ba commit a0e60a9

File tree

124 files changed

+3944
-61
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

124 files changed

+3944
-61
lines changed

eslint.config.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default defineConfig(
1212
globalIgnores([
1313
'docs',
1414
'tests/generated',
15+
'tests/**/__snapshots__/**',
1516
'samples',
1617
'.husky',
1718
'packages/hono/src/zValidator.ts',
@@ -91,5 +92,11 @@ export default defineConfig(
9192
'@typescript-eslint/no-unsafe-return': 'off',
9293
},
9394
},
95+
{
96+
files: ['packages/orval/src/utils/package-json.test.ts'],
97+
rules: {
98+
'@typescript-eslint/ban-ts-comment': 'off',
99+
},
100+
},
94101
eslintPluginPrettierRecommended, // also sets up eslint-config-prettier
95102
);
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type {
4+
ContextSpec,
5+
OpenApiDocument,
6+
OpenApiReferenceObject,
7+
OpenApiSchemaObject,
8+
} from '../types';
9+
import { resolveExampleRefs, resolveRef } from './ref';
10+
11+
function createContext(spec: OpenApiDocument): ContextSpec {
12+
return {
13+
target: 'core-test',
14+
workspace: '/tmp',
15+
spec,
16+
output: {
17+
override: {
18+
components: {
19+
schemas: { suffix: '' },
20+
},
21+
},
22+
},
23+
} as ContextSpec;
24+
}
25+
26+
describe('resolveRef', () => {
27+
it('supports schema generic and resolves local schema refs', () => {
28+
const context = createContext({
29+
openapi: '3.1.0',
30+
components: {
31+
schemas: {
32+
Position: {
33+
type: 'object',
34+
properties: {
35+
id: { type: 'string' },
36+
},
37+
},
38+
},
39+
},
40+
});
41+
42+
const resolveSchemaRef: (
43+
schema: OpenApiReferenceObject,
44+
context: ContextSpec,
45+
) => { schema: OpenApiSchemaObject; imports: unknown[] } = resolveRef;
46+
47+
const result = resolveSchemaRef(
48+
{ $ref: '#/components/schemas/Position' },
49+
context,
50+
);
51+
const typedSchema = result.schema;
52+
53+
expect(typedSchema).toMatchObject({
54+
type: 'object',
55+
properties: {
56+
id: { type: 'string' },
57+
},
58+
});
59+
expect(result.imports[0]).toEqual({
60+
name: 'Position',
61+
schemaName: 'Position',
62+
});
63+
});
64+
65+
it('preserves nullable and OpenAPI 3.1 type array hints from a direct ref', () => {
66+
const context = createContext({
67+
openapi: '3.1.0',
68+
components: {
69+
schemas: {
70+
MaybePosition: {
71+
type: 'object',
72+
properties: {
73+
id: { type: 'string' },
74+
},
75+
},
76+
},
77+
},
78+
});
79+
80+
const refWithHints = {
81+
$ref: '#/components/schemas/MaybePosition',
82+
nullable: true,
83+
type: ['object', 'null'],
84+
} as unknown as OpenApiReferenceObject;
85+
86+
const { schema } = resolveRef(refWithHints, context);
87+
88+
const withNullable = schema as OpenApiSchemaObject & {
89+
nullable?: boolean;
90+
type?: string[];
91+
};
92+
93+
expect(withNullable.nullable).toBe(true);
94+
expect(withNullable.type).toEqual(['object', 'null']);
95+
});
96+
97+
it('resolves nested schema refs and example refs in schema-like containers', () => {
98+
const context = createContext({
99+
openapi: '3.1.0',
100+
components: {
101+
schemas: {
102+
Position: {
103+
type: 'object',
104+
properties: {
105+
id: { type: 'string' },
106+
},
107+
},
108+
},
109+
examples: {
110+
PositionExample: {
111+
value: {
112+
id: 'p_1',
113+
},
114+
},
115+
},
116+
},
117+
});
118+
119+
type NestedResolved = {
120+
schema: OpenApiSchemaObject;
121+
examples?: unknown[];
122+
};
123+
124+
const carrier = {
125+
schema: {
126+
$ref: '#/components/schemas/Position',
127+
},
128+
examples: [{ $ref: '#/components/examples/PositionExample' }],
129+
};
130+
131+
const resolved = resolveRef(
132+
carrier as unknown as OpenApiReferenceObject,
133+
context,
134+
) as { schema: NestedResolved; imports: unknown[] };
135+
136+
expect(resolved.schema.schema).toMatchObject({
137+
type: 'object',
138+
properties: {
139+
id: { type: 'string' },
140+
},
141+
});
142+
expect(resolved.schema.examples).toEqual([{ id: 'p_1' }]);
143+
});
144+
145+
it('applies schema suffix to import name when suffix is configured', () => {
146+
const context = createContext({
147+
openapi: '3.1.0',
148+
components: {
149+
schemas: {
150+
Position: {
151+
type: 'object',
152+
properties: {
153+
id: { type: 'string' },
154+
},
155+
},
156+
},
157+
},
158+
});
159+
// Override suffix
160+
(
161+
context.output.override.components as { schemas: { suffix: string } }
162+
).schemas.suffix = 'Schema';
163+
164+
const result = resolveRef(
165+
{ $ref: '#/components/schemas/Position' },
166+
context,
167+
);
168+
169+
expect(result.imports[0]).toEqual({
170+
name: 'PositionSchema',
171+
schemaName: 'Position',
172+
});
173+
});
174+
175+
it('returns a non-ref schema as-is when it is already dereferenced', () => {
176+
const context = createContext({
177+
openapi: '3.1.0',
178+
components: { schemas: {} },
179+
});
180+
181+
// A plain schema object (no $ref) is treated as already dereferenced
182+
const result = resolveRef(
183+
{ type: 'object' } as unknown as OpenApiReferenceObject,
184+
context,
185+
);
186+
187+
expect(result.schema).toMatchObject({ type: 'object' });
188+
expect(result.imports).toEqual([]);
189+
});
190+
191+
it('documents current behavior for nonexistent local refs (KNOWN ISSUE)', () => {
192+
const context = createContext({
193+
openapi: '3.1.0',
194+
components: { schemas: {} },
195+
});
196+
197+
// KNOWN ISSUE: When a ref path does not resolve to a nested schema,
198+
// getSchema currently falls back to context.spec and returns the full
199+
// spec document instead of throwing (see schemaByRefPaths ??= context.spec
200+
// in ref.ts). This test documents current behavior and should be updated
201+
// once unresolved refs are handled strictly.
202+
const result = resolveRef(
203+
{ $ref: '#/components/schemas/NonExistent' },
204+
context,
205+
);
206+
207+
// Current behavior: resolves (doesn't throw) and returns root spec.
208+
expect(result.schema).toHaveProperty('openapi', '3.1.0');
209+
});
210+
});
211+
212+
describe('resolveExampleRefs', () => {
213+
const context = createContext({
214+
openapi: '3.1.0',
215+
components: {
216+
examples: {
217+
Primitive: {
218+
value: 'hello',
219+
},
220+
ObjectValue: {
221+
value: {
222+
id: 'p_1',
223+
},
224+
},
225+
},
226+
},
227+
});
228+
229+
it('resolves example refs in arrays and records', () => {
230+
const list = resolveExampleRefs(
231+
[{ $ref: '#/components/examples/Primitive' }],
232+
context,
233+
);
234+
235+
const map = resolveExampleRefs(
236+
{
237+
sample: { $ref: '#/components/examples/ObjectValue' },
238+
},
239+
context,
240+
);
241+
242+
expect(list).toEqual(['hello']);
243+
expect(map).toEqual({ sample: { id: 'p_1' } });
244+
});
245+
});

0 commit comments

Comments
 (0)