fork of hey-api/openapi-ts because I need some additional things
1import fs from 'node:fs';
2import path from 'node:path';
3import { fileURLToPath } from 'node:url';
4
5import { $RefParser } from '..';
6import { getSpecsPath } from './utils';
7
8const __filename = fileURLToPath(import.meta.url);
9const __dirname = path.dirname(__filename);
10
11const getSnapshotsPath = () => path.join(__dirname, '__snapshots__');
12const getTempSnapshotsPath = () => path.join(__dirname, '.gen', 'snapshots');
13
14/**
15 * Helper function to compare a bundled schema with a snapshot file.
16 * Handles writing the schema to a temp file and comparing with the snapshot.
17 *
18 * @param schema - The bundled schema to compare
19 * @param snapshotName - The name of the snapshot file (e.g., 'circular-ref-with-description.json')
20 */
21const expectBundledSchemaToMatchSnapshot = async (schema: unknown, snapshotName: string) => {
22 const outputPath = path.join(getTempSnapshotsPath(), snapshotName);
23 const snapshotPath = path.join(getSnapshotsPath(), snapshotName);
24
25 // Ensure directory exists
26 fs.mkdirSync(path.dirname(outputPath), { recursive: true });
27
28 // Write the bundled result
29 const content = JSON.stringify(schema, null, 2);
30 fs.writeFileSync(outputPath, content);
31
32 // Compare with snapshot
33 await expect(content).toMatchFileSnapshot(snapshotPath);
34};
35
36describe('bundle', () => {
37 it('handles circular reference with description', async () => {
38 const refParser = new $RefParser();
39 const pathOrUrlOrSchema = path.join(
40 getSpecsPath(),
41 'json-schema-ref-parser',
42 'circular-ref-with-description.json',
43 );
44 const schema = await refParser.bundle({ pathOrUrlOrSchema });
45
46 await expectBundledSchemaToMatchSnapshot(schema, 'circular-ref-with-description.json');
47 });
48
49 it('bundles multiple references to the same file correctly', async () => {
50 const refParser = new $RefParser();
51 const pathOrUrlOrSchema = path.join(
52 getSpecsPath(),
53 'json-schema-ref-parser',
54 'multiple-refs.json',
55 );
56 const schema = await refParser.bundle({ pathOrUrlOrSchema });
57
58 await expectBundledSchemaToMatchSnapshot(schema, 'multiple-refs.json');
59 });
60
61 it('hoists sibling schemas from external files', async () => {
62 const refParser = new $RefParser();
63 const pathOrUrlOrSchema = path.join(
64 getSpecsPath(),
65 'json-schema-ref-parser',
66 'main-with-external-siblings.json',
67 );
68 const schema = await refParser.bundle({ pathOrUrlOrSchema });
69
70 await expectBundledSchemaToMatchSnapshot(schema, 'main-with-external-siblings.json');
71 });
72
73 it('hoists sibling schemas from YAML files with versioned names (Redfish-like)', async () => {
74 const refParser = new $RefParser();
75 const pathOrUrlOrSchema = path.join(
76 getSpecsPath(),
77 'json-schema-ref-parser',
78 'redfish-like.yaml',
79 );
80 const schema = await refParser.bundle({ pathOrUrlOrSchema });
81
82 await expectBundledSchemaToMatchSnapshot(schema, 'redfish-like.json');
83 });
84
85 describe('sibling schema resolution', () => {
86 const specsDir = path.join(getSpecsPath(), 'json-schema-ref-parser');
87
88 const findSchemaByValue = (
89 schemas: Record<string, any>,
90 predicate: (value: any) => boolean,
91 ): [string, any] | undefined => {
92 for (const [name, value] of Object.entries(schemas)) {
93 if (predicate(value)) {
94 return [name, value];
95 }
96 }
97 return undefined;
98 };
99
100 it('hoists sibling schemas through a bare $ref wrapper chain', async () => {
101 const refParser = new $RefParser();
102 const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-root.json');
103 const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any;
104
105 expect(schema.components).toBeDefined();
106 expect(schema.components.schemas).toBeDefined();
107
108 const schemas = schema.components.schemas;
109
110 const mainSchema = findSchemaByValue(
111 schemas,
112 (v) => v.type === 'object' && v.properties?.name,
113 );
114 expect(mainSchema).toBeDefined();
115 const [mainName, mainValue] = mainSchema!;
116 expect(mainValue.type).toBe('object');
117 expect(mainValue.properties.name).toEqual({ type: 'string' });
118
119 const enumSchema = findSchemaByValue(
120 schemas,
121 (v) => Array.isArray(v.enum) && v.enum.includes('active'),
122 );
123 expect(enumSchema).toBeDefined();
124 const [enumName, enumValue] = enumSchema!;
125 expect(enumValue.type).toBe('string');
126 expect(enumValue.enum).toEqual(['active', 'inactive', 'pending']);
127
128 // The main schema's status property should reference the hoisted enum
129 expect(mainValue.properties.status.$ref).toBe(`#/components/schemas/${enumName}`);
130
131 // The root path's schema ref should point to the hoisted main schema
132 const rootRef = schema.paths['/test'].get.responses['200'].content['application/json'].schema;
133 expect(rootRef.$ref).toBe(`#/components/schemas/${mainName}`);
134 });
135
136 it('hoists sibling schemas through an extended $ref wrapper chain', async () => {
137 const refParser = new $RefParser();
138 const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-extended-root.json');
139 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
140
141 try {
142 const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any;
143
144 expect(schema.components).toBeDefined();
145 expect(schema.components.schemas).toBeDefined();
146
147 const schemas = schema.components.schemas;
148
149 // The main schema should be hoisted (with the extra description merged in)
150 const mainSchema = findSchemaByValue(
151 schemas,
152 (v) =>
153 v.description === 'Wrapper that extends the versioned schema' ||
154 (v.type === 'object' && v.properties?.name),
155 );
156 expect(mainSchema).toBeDefined();
157
158 // The sibling enum must also be hoisted (this was the bug — it was lost before the fix)
159 const enumSchema = findSchemaByValue(
160 schemas,
161 (v) => Array.isArray(v.enum) && v.enum.includes('active'),
162 );
163 expect(enumSchema).toBeDefined();
164 const [, enumValue] = enumSchema!;
165 expect(enumValue.type).toBe('string');
166 expect(enumValue.enum).toEqual(['active', 'inactive', 'pending']);
167
168 // No "Skipping unresolvable $ref" warnings should have been emitted
169 const unresolvableWarnings = warnSpy.mock.calls.filter(
170 (args) => typeof args[0] === 'string' && args[0].includes('Skipping unresolvable $ref'),
171 );
172 expect(unresolvableWarnings).toHaveLength(0);
173 } finally {
174 warnSpy.mockRestore();
175 }
176 });
177
178 it('hoists sibling schemas from a direct reference (no wrapper)', async () => {
179 const refParser = new $RefParser();
180 const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-direct-root.json');
181 const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any;
182
183 expect(schema.components).toBeDefined();
184 expect(schema.components.schemas).toBeDefined();
185
186 const schemas = schema.components.schemas;
187
188 const mainSchema = findSchemaByValue(
189 schemas,
190 (v) => v.type === 'object' && v.properties?.name,
191 );
192 expect(mainSchema).toBeDefined();
193
194 const enumSchema = findSchemaByValue(
195 schemas,
196 (v) => Array.isArray(v.enum) && v.enum.includes('active'),
197 );
198 expect(enumSchema).toBeDefined();
199 const [enumName, enumValue] = enumSchema!;
200 expect(enumValue.enum).toEqual(['active', 'inactive', 'pending']);
201
202 const [, mainValue] = mainSchema!;
203 expect(mainValue.properties.status.$ref).toBe(`#/components/schemas/${enumName}`);
204 });
205
206 it('hoists multiple sibling schemas through an extended wrapper', async () => {
207 const refParser = new $RefParser();
208 const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-multi-root.json');
209 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
210
211 try {
212 const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any;
213
214 expect(schema.components).toBeDefined();
215 expect(schema.components.schemas).toBeDefined();
216
217 const schemas = schema.components.schemas;
218
219 const mainSchema = findSchemaByValue(
220 schemas,
221 (v) => v.type === 'object' && v.properties?.health,
222 );
223 expect(mainSchema).toBeDefined();
224
225 const statusEnum = findSchemaByValue(
226 schemas,
227 (v) => Array.isArray(v.enum) && v.enum.includes('enabled'),
228 );
229 expect(statusEnum).toBeDefined();
230 expect(statusEnum![1].enum).toEqual(['enabled', 'disabled', 'standby']);
231
232 const healthEnum = findSchemaByValue(
233 schemas,
234 (v) => Array.isArray(v.enum) && v.enum.includes('ok'),
235 );
236 expect(healthEnum).toBeDefined();
237 expect(healthEnum![1].enum).toEqual(['ok', 'warning', 'critical']);
238
239 const [, mainValue] = mainSchema!;
240 expect(mainValue.properties.status.$ref).toBe(`#/components/schemas/${statusEnum![0]}`);
241 expect(mainValue.properties.health.$ref).toBe(`#/components/schemas/${healthEnum![0]}`);
242
243 const unresolvableWarnings = warnSpy.mock.calls.filter(
244 (args) => typeof args[0] === 'string' && args[0].includes('Skipping unresolvable $ref'),
245 );
246 expect(unresolvableWarnings).toHaveLength(0);
247 } finally {
248 warnSpy.mockRestore();
249 }
250 });
251
252 it('handles multiple external files with same-named sibling schemas', async () => {
253 const refParser = new $RefParser();
254 const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-collision-root.json');
255 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
256
257 try {
258 const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any;
259
260 expect(schema.components).toBeDefined();
261 expect(schema.components.schemas).toBeDefined();
262
263 const schemas = schema.components.schemas;
264 const schemaNames = Object.keys(schemas);
265
266 const mainSchemaKey = schemaNames.find((name) => name.includes('MainSchema'));
267 const otherSchemaKey = schemaNames.find((name) => name.includes('OtherSchema'));
268
269 expect(mainSchemaKey).toBeDefined();
270 expect(otherSchemaKey).toBeDefined();
271
272 const statusSchemas = schemaNames.filter((name) => name.includes('Status'));
273 expect(statusSchemas.length).toBeGreaterThanOrEqual(2);
274
275 const statusValues = statusSchemas.map((name) => schemas[name]);
276 const stringStatus = statusValues.find((v: any) => v.type === 'string');
277 const integerStatus = statusValues.find((v: any) => v.type === 'integer');
278
279 expect(stringStatus).toBeDefined();
280 expect(integerStatus).toBeDefined();
281 expect(stringStatus!.enum).toEqual(['active', 'inactive']);
282 expect(integerStatus!.enum).toEqual([0, 1, 2]);
283
284 const mainSchemaValue = schemas[mainSchemaKey!];
285 const mainStatusRef = mainSchemaValue.properties.status.$ref;
286 expect(mainStatusRef).toMatch(/^#\/components\/schemas\/.*Status/);
287
288 const referencedStatus = schemas[mainStatusRef.replace('#/components/schemas/', '')];
289 expect(referencedStatus).toBeDefined();
290 expect(referencedStatus.type).toBe('string');
291 expect(referencedStatus.enum).toEqual(['active', 'inactive']);
292
293 const otherSchemaValue = schemas[otherSchemaKey!];
294 const otherStatusRef = otherSchemaValue.properties.code.$ref;
295 expect(otherStatusRef).toMatch(/^#\/components\/schemas\/.*Status/);
296
297 const referencedOtherStatus = schemas[otherStatusRef.replace('#/components/schemas/', '')];
298 expect(referencedOtherStatus).toBeDefined();
299 expect(referencedOtherStatus.type).toBe('integer');
300 expect(referencedOtherStatus.enum).toEqual([0, 1, 2]);
301
302 const unresolvableWarnings = warnSpy.mock.calls.filter(
303 (args) => typeof args[0] === 'string' && args[0].includes('Skipping unresolvable $ref'),
304 );
305 expect(unresolvableWarnings).toHaveLength(0);
306 } finally {
307 warnSpy.mockRestore();
308 }
309 });
310 });
311});