UNPKG

@hey-api/json-schema-ref-parser

Version:

Parse, Resolve, and Dereference JSON Schema $ref pointers

652 lines (557 loc) 22.9 kB
import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { $RefParser } from '..'; import { getSpecsPath } from './utils'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const getSnapshotsPath = () => path.join(__dirname, '__snapshots__'); const getTempSnapshotsPath = () => path.join(__dirname, '.gen', 'snapshots'); const writeJsonFile = (filePath: string, value: unknown) => { fs.writeFileSync(filePath, JSON.stringify(value, null, 2)); }; /** * Helper function to compare a bundled schema with a snapshot file. * Handles writing the schema to a temp file and comparing with the snapshot. * * @param schema - The bundled schema to compare * @param snapshotName - The name of the snapshot file (e.g., 'circular-ref-with-description.json') */ const expectBundledSchemaToMatchSnapshot = async (schema: unknown, snapshotName: string) => { const outputPath = path.join(getTempSnapshotsPath(), snapshotName); const snapshotPath = path.join(getSnapshotsPath(), snapshotName); // Ensure directory exists fs.mkdirSync(path.dirname(outputPath), { recursive: true }); // Write the bundled result const content = JSON.stringify(schema, null, 2); fs.writeFileSync(outputPath, content); // Compare with snapshot await expect(content).toMatchFileSnapshot(snapshotPath); }; describe('bundle', () => { it('handles circular reference with description', async () => { const refParser = new $RefParser(); const pathOrUrlOrSchema = path.join( getSpecsPath(), 'json-schema-ref-parser', 'circular-ref-with-description.json', ); const schema = await refParser.bundle({ pathOrUrlOrSchema }); await expectBundledSchemaToMatchSnapshot(schema, 'circular-ref-with-description.json'); }); it('emits decoded internal refs for generic component names', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-schema-ref-parser-')); try { const rootPath = path.join(tempDir, 'root.json'); writeJsonFile(rootPath, { components: { schemas: { ClientResponse: { properties: { id: { type: 'string', }, }, type: 'object', }, 'PaginatedListItems<ClientResponse>': { properties: { items: { items: { $ref: '#/components/schemas/ClientResponse', }, type: 'array', }, }, type: 'object', }, }, }, info: { title: 'Test API', version: '1.0.0', }, openapi: '3.0.0', paths: { '/clients': { get: { responses: { '200': { content: { 'application/json': { schema: { $ref: '#/components/schemas/PaginatedListItems<ClientResponse>', }, }, }, description: 'ok', }, }, }, }, }, }); const refParser = new $RefParser(); const schema = (await refParser.bundle({ pathOrUrlOrSchema: rootPath })) as any; expect( schema.paths['/clients'].get.responses['200'].content['application/json'].schema.$ref, ).toBe('#/components/schemas/PaginatedListItems<ClientResponse>'); const bundledJson = JSON.stringify(schema); expect(bundledJson).not.toContain('PaginatedListItems%3CClientResponse%3E'); } finally { fs.rmSync(tempDir, { force: true, recursive: true }); } }); it('emits decoded refs for external schemas with generic and unicode names', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-schema-ref-parser-')); try { const externalPath = path.join(tempDir, 'external.json'); const rootPath = path.join(tempDir, 'root.json'); writeJsonFile(externalPath, { components: { schemas: { 'PaginatedList<ClientItem>': { description: 'generic schema', properties: { next: { $ref: '#/components/schemas/PaginatedList<ClientItem>', }, }, type: 'object', }, Überschrift: { description: 'unicode schema', properties: { next: { $ref: '#/components/schemas/Überschrift', }, }, type: 'object', }, }, }, }); writeJsonFile(rootPath, { info: { title: 'Test API', version: '1.0.0', }, openapi: '3.0.0', paths: { '/generic': { get: { responses: { '200': { content: { 'application/json': { schema: { $ref: 'external.json#/components/schemas/PaginatedList<ClientItem>', }, }, }, description: 'ok', }, }, }, }, '/unicode': { get: { responses: { '200': { content: { 'application/json': { schema: { $ref: 'external.json#/components/schemas/Überschrift', }, }, }, description: 'ok', }, }, }, }, }, }); const refParser = new $RefParser(); const schema = (await refParser.bundle({ pathOrUrlOrSchema: rootPath })) as any; const schemas = schema.components.schemas as Record<string, any>; const findSchemaByDescription = (description: string) => Object.entries(schemas).find(([, value]) => value.description === description); const genericSchema = findSchemaByDescription('generic schema'); const unicodeSchema = findSchemaByDescription('unicode schema'); expect(genericSchema).toBeDefined(); expect(unicodeSchema).toBeDefined(); const [genericName, genericValue] = genericSchema!; const [unicodeName, unicodeValue] = unicodeSchema!; expect(genericValue.properties.next.$ref).toBe(`#/components/schemas/${genericName}`); expect(unicodeValue.properties.next.$ref).toBe(`#/components/schemas/${unicodeName}`); const bundledJson = JSON.stringify(schema); expect(bundledJson).not.toContain('PaginatedList%3CClientItem%3E'); expect(bundledJson).not.toContain('%C3%9Cberschrift'); } finally { fs.rmSync(tempDir, { force: true, recursive: true }); } }); it('names whole-file $refs after the source filename, not "root"', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-schema-ref-parser-')); try { const agentTypePath = path.join(tempDir, 'AgentType.json'); const userTypePath = path.join(tempDir, 'UserType.json'); const rootPath = path.join(tempDir, 'root.json'); // Each external file IS the schema — no #/components/schemas/... fragment. writeJsonFile(agentTypePath, { properties: { kind: { type: 'string' }, }, type: 'object', }); writeJsonFile(userTypePath, { properties: { name: { type: 'string' }, }, type: 'object', }); writeJsonFile(rootPath, { info: { title: 'Test API', version: '1.0.0' }, openapi: '3.0.0', paths: { '/agents': { get: { responses: { '200': { content: { 'application/json': { schema: { $ref: 'AgentType.json' }, }, }, description: 'ok', }, }, }, }, '/users': { get: { responses: { '200': { content: { 'application/json': { schema: { $ref: 'UserType.json' }, }, }, description: 'ok', }, }, }, }, }, }); const refParser = new $RefParser(); const schema = (await refParser.bundle({ pathOrUrlOrSchema: rootPath })) as any; const schemas = (schema.components?.schemas ?? {}) as Record<string, any>; const schemaNames = Object.keys(schemas); // The bug: whole-file refs are named "root", "AgentType_root", etc. // (PascalCased downstream into "Root", "AgentTypeRoot".) expect(schemaNames).not.toContain('root'); expect(schemaNames.every((name) => !/root/i.test(name))).toBe(true); // Expected: schemas named after the source filenames. expect(schemas.AgentType).toBeDefined(); expect(schemas.UserType).toBeDefined(); expect( schema.paths['/agents'].get.responses['200'].content['application/json'].schema.$ref, ).toBe('#/components/schemas/AgentType'); expect( schema.paths['/users'].get.responses['200'].content['application/json'].schema.$ref, ).toBe('#/components/schemas/UserType'); } finally { fs.rmSync(tempDir, { force: true, recursive: true }); } }); it('bundles multiple references to the same file correctly', async () => { const refParser = new $RefParser(); const pathOrUrlOrSchema = path.join( getSpecsPath(), 'json-schema-ref-parser', 'multiple-refs.json', ); const schema = await refParser.bundle({ pathOrUrlOrSchema }); await expectBundledSchemaToMatchSnapshot(schema, 'multiple-refs.json'); }); it('hoists sibling schemas from external files', async () => { const refParser = new $RefParser(); const pathOrUrlOrSchema = path.join( getSpecsPath(), 'json-schema-ref-parser', 'main-with-external-siblings.json', ); const schema = await refParser.bundle({ pathOrUrlOrSchema }); await expectBundledSchemaToMatchSnapshot(schema, 'main-with-external-siblings.json'); }); it('hoists sibling schemas from YAML files with versioned names (Redfish-like)', async () => { const refParser = new $RefParser(); const pathOrUrlOrSchema = path.join( getSpecsPath(), 'json-schema-ref-parser', 'redfish-like.yaml', ); const schema = await refParser.bundle({ pathOrUrlOrSchema }); await expectBundledSchemaToMatchSnapshot(schema, 'redfish-like.json'); }); describe('sibling schema resolution', () => { const specsDir = path.join(getSpecsPath(), 'json-schema-ref-parser'); const findSchemaByValue = ( schemas: Record<string, any>, predicate: (value: any) => boolean, ): [string, any] | undefined => { for (const [name, value] of Object.entries(schemas)) { if (predicate(value)) { return [name, value]; } } return undefined; }; it('hoists sibling schemas through a bare $ref wrapper chain', async () => { const refParser = new $RefParser(); const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-root.json'); const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; expect(schema.components).toBeDefined(); expect(schema.components.schemas).toBeDefined(); const schemas = schema.components.schemas; const mainSchema = findSchemaByValue( schemas, (v) => v.type === 'object' && v.properties?.name, ); expect(mainSchema).toBeDefined(); const [mainName, mainValue] = mainSchema!; expect(mainValue.type).toBe('object'); expect(mainValue.properties.name).toEqual({ type: 'string' }); const enumSchema = findSchemaByValue( schemas, (v) => Array.isArray(v.enum) && v.enum.includes('active'), ); expect(enumSchema).toBeDefined(); const [enumName, enumValue] = enumSchema!; expect(enumValue.type).toBe('string'); expect(enumValue.enum).toEqual(['active', 'inactive', 'pending']); // The main schema's status property should reference the hoisted enum expect(mainValue.properties.status.$ref).toBe(`#/components/schemas/${enumName}`); // The root path's schema ref should point to the hoisted main schema const rootRef = schema.paths['/test'].get.responses['200'].content['application/json'].schema; expect(rootRef.$ref).toBe(`#/components/schemas/${mainName}`); }); it('hoists sibling schemas through an extended $ref wrapper chain', async () => { const refParser = new $RefParser(); const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-extended-root.json'); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; expect(schema.components).toBeDefined(); expect(schema.components.schemas).toBeDefined(); const schemas = schema.components.schemas; // The main schema should be hoisted (with the extra description merged in) const mainSchema = findSchemaByValue( schemas, (v) => v.description === 'Wrapper that extends the versioned schema' || (v.type === 'object' && v.properties?.name), ); expect(mainSchema).toBeDefined(); // The sibling enum must also be hoisted (this was the bug — it was lost before the fix) const enumSchema = findSchemaByValue( schemas, (v) => Array.isArray(v.enum) && v.enum.includes('active'), ); expect(enumSchema).toBeDefined(); const [, enumValue] = enumSchema!; expect(enumValue.type).toBe('string'); expect(enumValue.enum).toEqual(['active', 'inactive', 'pending']); // No "Skipping unresolvable $ref" warnings should have been emitted const unresolvableWarnings = warnSpy.mock.calls.filter( (args) => typeof args[0] === 'string' && args[0].includes('Skipping unresolvable $ref'), ); expect(unresolvableWarnings).toHaveLength(0); } finally { warnSpy.mockRestore(); } }); it('hoists sibling schemas from a direct reference (no wrapper)', async () => { const refParser = new $RefParser(); const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-direct-root.json'); const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; expect(schema.components).toBeDefined(); expect(schema.components.schemas).toBeDefined(); const schemas = schema.components.schemas; const mainSchema = findSchemaByValue( schemas, (v) => v.type === 'object' && v.properties?.name, ); expect(mainSchema).toBeDefined(); const enumSchema = findSchemaByValue( schemas, (v) => Array.isArray(v.enum) && v.enum.includes('active'), ); expect(enumSchema).toBeDefined(); const [enumName, enumValue] = enumSchema!; expect(enumValue.enum).toEqual(['active', 'inactive', 'pending']); const [, mainValue] = mainSchema!; expect(mainValue.properties.status.$ref).toBe(`#/components/schemas/${enumName}`); }); it('hoists multiple sibling schemas through an extended wrapper', async () => { const refParser = new $RefParser(); const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-multi-root.json'); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; expect(schema.components).toBeDefined(); expect(schema.components.schemas).toBeDefined(); const schemas = schema.components.schemas; const mainSchema = findSchemaByValue( schemas, (v) => v.type === 'object' && v.properties?.health, ); expect(mainSchema).toBeDefined(); const statusEnum = findSchemaByValue( schemas, (v) => Array.isArray(v.enum) && v.enum.includes('enabled'), ); expect(statusEnum).toBeDefined(); expect(statusEnum![1].enum).toEqual(['enabled', 'disabled', 'standby']); const healthEnum = findSchemaByValue( schemas, (v) => Array.isArray(v.enum) && v.enum.includes('ok'), ); expect(healthEnum).toBeDefined(); expect(healthEnum![1].enum).toEqual(['ok', 'warning', 'critical']); const [, mainValue] = mainSchema!; expect(mainValue.properties.status.$ref).toBe(`#/components/schemas/${statusEnum![0]}`); expect(mainValue.properties.health.$ref).toBe(`#/components/schemas/${healthEnum![0]}`); const unresolvableWarnings = warnSpy.mock.calls.filter( (args) => typeof args[0] === 'string' && args[0].includes('Skipping unresolvable $ref'), ); expect(unresolvableWarnings).toHaveLength(0); } finally { warnSpy.mockRestore(); } }); it('handles multiple external files with same-named sibling schemas', async () => { const refParser = new $RefParser(); const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-collision-root.json'); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; expect(schema.components).toBeDefined(); expect(schema.components.schemas).toBeDefined(); const schemas = schema.components.schemas; const schemaNames = Object.keys(schemas); const mainSchemaKey = schemaNames.find((name) => name.includes('MainSchema')); const otherSchemaKey = schemaNames.find((name) => name.includes('OtherSchema')); expect(mainSchemaKey).toBeDefined(); expect(otherSchemaKey).toBeDefined(); const statusSchemas = schemaNames.filter((name) => name.includes('Status')); expect(statusSchemas.length).toBeGreaterThanOrEqual(2); const statusValues = statusSchemas.map((name) => schemas[name]); const stringStatus = statusValues.find((v: any) => v.type === 'string'); const integerStatus = statusValues.find((v: any) => v.type === 'integer'); expect(stringStatus).toBeDefined(); expect(integerStatus).toBeDefined(); expect(stringStatus!.enum).toEqual(['active', 'inactive']); expect(integerStatus!.enum).toEqual([0, 1, 2]); const mainSchemaValue = schemas[mainSchemaKey!]; const mainStatusRef = mainSchemaValue.properties.status.$ref; expect(mainStatusRef).toMatch(/^#\/components\/schemas\/.*Status/); const referencedStatus = schemas[mainStatusRef.replace('#/components/schemas/', '')]; expect(referencedStatus).toBeDefined(); expect(referencedStatus.type).toBe('string'); expect(referencedStatus.enum).toEqual(['active', 'inactive']); const otherSchemaValue = schemas[otherSchemaKey!]; const otherStatusRef = otherSchemaValue.properties.code.$ref; expect(otherStatusRef).toMatch(/^#\/components\/schemas\/.*Status/); const referencedOtherStatus = schemas[otherStatusRef.replace('#/components/schemas/', '')]; expect(referencedOtherStatus).toBeDefined(); expect(referencedOtherStatus.type).toBe('integer'); expect(referencedOtherStatus.enum).toEqual([0, 1, 2]); const unresolvableWarnings = warnSpy.mock.calls.filter( (args) => typeof args[0] === 'string' && args[0].includes('Skipping unresolvable $ref'), ); expect(unresolvableWarnings).toHaveLength(0); } finally { warnSpy.mockRestore(); } }); }); describe('mergeMany', () => { it('merges paths with non-conflicting methods under the same path', async () => { const refParser = new $RefParser(); const spec1 = { info: { title: 'Spec 1', version: '1.0.0' }, paths: { '/pet/{petId}': { post: { operationId: 'updatePetWithForm', responses: { '405': { description: 'Invalid input' } }, }, }, }, swagger: '2.0', }; const spec2 = { info: { title: 'Spec 2', version: '1.0.0' }, paths: { '/pet/{petId}': { delete: { operationId: 'deletePet', responses: { '400': { description: 'Invalid ID supplied' }, '404': { description: 'Pet not found' }, }, }, }, }, swagger: '2.0', }; const merged = (await refParser.bundleMany({ pathOrUrlOrSchemas: [spec1, spec2] })) as any; // Both methods should be under the same path (no prefix added) expect(merged.paths['/pet/{petId}']).toBeDefined(); expect(merged.paths['/pet/{petId}'].post).toBeDefined(); expect(merged.paths['/pet/{petId}'].delete).toBeDefined(); // No prefixed path should be created const pathKeys = Object.keys(merged.paths); expect(pathKeys).toHaveLength(1); }); it('adds prefix to path when HTTP methods conflict', async () => { const refParser = new $RefParser(); const spec1 = { info: { title: 'Spec 1', version: '1.0.0' }, paths: { '/pet/{petId}': { get: { operationId: 'getPetById', responses: { '200': { description: 'OK' } }, }, }, }, swagger: '2.0', }; const spec2 = { info: { title: 'Spec 2', version: '1.0.0' }, paths: { '/pet/{petId}': { get: { operationId: 'getPet', responses: { '200': { description: 'Success' } }, }, }, }, swagger: '2.0', }; const merged = (await refParser.bundleMany({ pathOrUrlOrSchemas: [spec1, spec2] })) as any; // The conflicting path should be prefixed const pathKeys = Object.keys(merged.paths); expect(pathKeys).toHaveLength(2); expect(merged.paths['/pet/{petId}']).toBeDefined(); const prefixedKey = pathKeys.find((k) => k !== '/pet/{petId}'); expect(prefixedKey).toBeDefined(); expect(merged.paths[prefixedKey!].get).toBeDefined(); }); }); });