diff --git a/packages/json-schema-ref-parser/.gitignore b/packages/json-schema-ref-parser/.gitignore new file mode 100644 index 000000000..514978282 --- /dev/null +++ b/packages/json-schema-ref-parser/.gitignore @@ -0,0 +1 @@ +.gen diff --git a/packages/json-schema-ref-parser/src/__tests__/__snapshots__/circular-ref-with-description.json b/packages/json-schema-ref-parser/src/__tests__/__snapshots__/circular-ref-with-description.json new file mode 100644 index 000000000..dd9a76274 --- /dev/null +++ b/packages/json-schema-ref-parser/src/__tests__/__snapshots__/circular-ref-with-description.json @@ -0,0 +1,11 @@ +{ + "schemas": { + "Foo": { + "$ref": "#/schemas/Bar" + }, + "Bar": { + "description": "ok", + "$ref": "#/schemas/Foo" + } + } +} \ No newline at end of file diff --git a/packages/json-schema-ref-parser/src/__tests__/__snapshots__/cross-file-ref-main.json b/packages/json-schema-ref-parser/src/__tests__/__snapshots__/cross-file-ref-main.json new file mode 100644 index 000000000..2d356701f --- /dev/null +++ b/packages/json-schema-ref-parser/src/__tests__/__snapshots__/cross-file-ref-main.json @@ -0,0 +1,64 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Cross-file Reference Test", + "version": "1.0.0" + }, + "paths": { + "/resource-a": { + "get": { + "responses": { + "200": { + "description": "Returns SchemaA", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cross-file-ref-file1_SchemaA" + } + } + } + } + } + } + }, + "/resource-b": { + "get": { + "responses": { + "200": { + "description": "Returns SchemaB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cross-file-ref-file2_SchemaB" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "cross-file-ref-file1_SchemaA": { + "type": "object", + "properties": { + "typeField": { + "$ref": "#/components/schemas/cross-file-ref-file2_SchemaB" + }, + "name": { + "type": "string" + } + } + }, + "cross-file-ref-file2_SchemaB": { + "type": "string", + "enum": [ + "TypeA", + "TypeB", + "TypeC" + ] + } + } + } +} \ No newline at end of file diff --git a/packages/json-schema-ref-parser/src/__tests__/__snapshots__/main-with-external-siblings.json b/packages/json-schema-ref-parser/src/__tests__/__snapshots__/main-with-external-siblings.json new file mode 100644 index 000000000..4153c96e9 --- /dev/null +++ b/packages/json-schema-ref-parser/src/__tests__/__snapshots__/main-with-external-siblings.json @@ -0,0 +1,78 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": { + "/resolution": { + "get": { + "summary": "Get resolution step", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/external-with-siblings_ResolutionStep" + } + } + } + } + } + } + }, + "/action": { + "get": { + "summary": "Get action info", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/external-with-siblings_ActionInfo" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "external-with-siblings_ActionInfo": { + "type": "object", + "properties": { + "ActionId": { + "type": "string" + } + } + }, + "external-with-siblings_ResolutionStep": { + "type": "object", + "properties": { + "ResolutionType": { + "oneOf": [ + { + "$ref": "#/components/schemas/external-with-siblings_ResolutionType" + } + ] + }, + "ActionName": { + "type": "string" + } + } + }, + "external-with-siblings_ResolutionType": { + "type": "string", + "enum": [ + "ContactVendor", + "ResetToDefaults", + "RetryOperation" + ] + } + } + } +} \ No newline at end of file diff --git a/packages/json-schema-ref-parser/src/__tests__/__snapshots__/multiple-refs.json b/packages/json-schema-ref-parser/src/__tests__/__snapshots__/multiple-refs.json new file mode 100644 index 000000000..f53a19c6e --- /dev/null +++ b/packages/json-schema-ref-parser/src/__tests__/__snapshots__/multiple-refs.json @@ -0,0 +1,48 @@ +{ + "paths": { + "/test1/{pathId}": { + "get": { + "summary": "First endpoint using the same pathId schema", + "parameters": [ + { + "$ref": "#/components/parameters/path-parameter_pathId" + } + ], + "responses": { + "200": { + "description": "Test 1 response" + } + } + } + }, + "/test2/{pathId}": { + "get": { + "summary": "Second endpoint using the same pathId schema", + "parameters": [ + { + "$ref": "#/components/parameters/path-parameter_pathId" + } + ], + "responses": { + "200": { + "description": "Test 2 response" + } + } + } + } + }, + "components": { + "parameters": { + "path-parameter_pathId": { + "name": "pathId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the path" + } + } + } + } +} \ No newline at end of file diff --git a/packages/json-schema-ref-parser/src/__tests__/__snapshots__/redfish-like.json b/packages/json-schema-ref-parser/src/__tests__/__snapshots__/redfish-like.json new file mode 100644 index 000000000..7528fef8a --- /dev/null +++ b/packages/json-schema-ref-parser/src/__tests__/__snapshots__/redfish-like.json @@ -0,0 +1,87 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Redfish-like API", + "version": "1.0.0", + "description": "Test API simulating Redfish structure with versioned schemas" + }, + "paths": { + "/redfish/v1/Systems": { + "get": { + "summary": "Get Systems", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolutionStep_v1_0_1_ResolutionStep_v1_0_1_ResolutionStep" + } + } + } + }, + "default": { + "description": "Error" + } + } + } + }, + "/redfish/v1/Actions": { + "post": { + "summary": "Submit Action", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolutionStep_v1_0_1_ResolutionStep_v1_0_1_ActionParameters" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ResolutionStep_v1_0_1_ResolutionStep_v1_0_1_ActionParameters": { + "type": "object", + "properties": { + "ActionId": { + "type": "string" + }, + "ActionType": { + "$ref": "#/components/schemas/ResolutionStep_v1_0_1_ResolutionStep_v1_0_1_ResolutionType" + } + } + }, + "ResolutionStep_v1_0_1_ResolutionStep_v1_0_1_ResolutionStep": { + "type": "object", + "properties": { + "ResolutionType": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResolutionStep_v1_0_1_ResolutionStep_v1_0_1_ResolutionType" + } + ] + }, + "ActionName": { + "type": "string", + "description": "Name of the action" + } + } + }, + "ResolutionStep_v1_0_1_ResolutionStep_v1_0_1_ResolutionType": { + "type": "string", + "enum": [ + "ContactVendor", + "ResetToDefaults", + "RetryOperation" + ], + "description": "Types of resolution actions" + } + } + } +} \ No newline at end of file diff --git a/packages/json-schema-ref-parser/src/__tests__/bundle.test.ts b/packages/json-schema-ref-parser/src/__tests__/bundle.test.ts index 513b076ee..7f5691ef4 100644 --- a/packages/json-schema-ref-parser/src/__tests__/bundle.test.ts +++ b/packages/json-schema-ref-parser/src/__tests__/bundle.test.ts @@ -1,8 +1,16 @@ +import fs from 'node:fs'; 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'); + describe('bundle', () => { it('handles circular reference with description', async () => { const refParser = new $RefParser(); @@ -12,17 +20,19 @@ describe('bundle', () => { 'circular-ref-with-description.json', ); const schema = await refParser.bundle({ pathOrUrlOrSchema }); - expect(schema).toEqual({ - schemas: { - Bar: { - $ref: '#/schemas/Foo', - description: 'ok', - }, - Foo: { - $ref: '#/schemas/Bar', - }, - }, - }); + + const outputPath = path.join(getTempSnapshotsPath(), 'circular-ref-with-description.json'); + const snapshotPath = path.join(getSnapshotsPath(), 'circular-ref-with-description.json'); + + // 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); }); it('bundles multiple references to the same file correctly', async () => { @@ -32,29 +42,20 @@ describe('bundle', () => { 'json-schema-ref-parser', 'multiple-refs.json', ); - const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; - - // Both parameters should now be $ref to the same internal definition - const firstParam = schema.paths['/test1/{pathId}'].get.parameters[0]; - const secondParam = schema.paths['/test2/{pathId}'].get.parameters[0]; - - // The $ref should match the output structure in file_context_0 - expect(firstParam.$ref).toBe('#/components/parameters/path-parameter_pathId'); - expect(secondParam.$ref).toBe('#/components/parameters/path-parameter_pathId'); - - // The referenced parameter should exist and match the expected structure - expect(schema.components).toBeDefined(); - expect(schema.components.parameters).toBeDefined(); - expect(schema.components.parameters['path-parameter_pathId']).toEqual({ - in: 'path', - name: 'pathId', - required: true, - schema: { - description: 'Unique identifier for the path', - format: 'uuid', - type: 'string', - }, - }); + const schema = await refParser.bundle({ pathOrUrlOrSchema }); + + const outputPath = path.join(getTempSnapshotsPath(), 'multiple-refs.json'); + const snapshotPath = path.join(getSnapshotsPath(), 'multiple-refs.json'); + + // 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); }); it('hoists sibling schemas from external files', async () => { @@ -64,47 +65,20 @@ describe('bundle', () => { 'json-schema-ref-parser', 'main-with-external-siblings.json', ); - const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; + const schema = await refParser.bundle({ pathOrUrlOrSchema }); - // Main schema should reference the hoisted schemas - const resolutionStepSchema = - schema.paths['/resolution'].get.responses['200'].content['application/json'].schema; - expect(resolutionStepSchema.$ref).toBe( - '#/components/schemas/external-with-siblings_ResolutionStep', - ); + const outputPath = path.join(getTempSnapshotsPath(), 'main-with-external-siblings.json'); + const snapshotPath = path.join(getSnapshotsPath(), 'main-with-external-siblings.json'); - const actionInfoSchema = - schema.paths['/action'].get.responses['200'].content['application/json'].schema; - expect(actionInfoSchema.$ref).toBe('#/components/schemas/external-with-siblings_ActionInfo'); - - // All schemas from the external file should be hoisted - expect(schema.components).toBeDefined(); - expect(schema.components.schemas).toBeDefined(); - - // ResolutionStep should be hoisted - expect(schema.components.schemas['external-with-siblings_ResolutionStep']).toBeDefined(); - expect( - schema.components.schemas['external-with-siblings_ResolutionStep'].properties.ResolutionType - .oneOf[0].$ref, - ).toBe('#/components/schemas/external-with-siblings_ResolutionType'); - - // ResolutionType (sibling schema) should also be hoisted - expect(schema.components.schemas['external-with-siblings_ResolutionType']).toBeDefined(); - expect(schema.components.schemas['external-with-siblings_ResolutionType']).toEqual({ - enum: ['ContactVendor', 'ResetToDefaults', 'RetryOperation'], - type: 'string', - }); - - // ActionInfo (another sibling schema) should also be hoisted - expect(schema.components.schemas['external-with-siblings_ActionInfo']).toBeDefined(); - expect(schema.components.schemas['external-with-siblings_ActionInfo']).toEqual({ - properties: { - ActionId: { - type: 'string', - }, - }, - type: 'object', - }); + // 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); }); it('hoists sibling schemas from YAML files with versioned names (Redfish-like)', async () => { @@ -114,52 +88,20 @@ describe('bundle', () => { 'json-schema-ref-parser', 'redfish-like.yaml', ); - const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; - - // Verify the main schema references are hoisted - const systemsSchema = - schema.paths['/redfish/v1/Systems'].get.responses['200'].content['application/json'].schema; - expect(systemsSchema.$ref).toContain('ResolutionStep'); - - const actionsSchema = - schema.paths['/redfish/v1/Actions'].post.responses['200'].content['application/json'].schema; - expect(actionsSchema.$ref).toContain('ActionParameters'); + const schema = await refParser.bundle({ pathOrUrlOrSchema }); - // All three schemas from the external YAML should be hoisted - expect(schema.components).toBeDefined(); - expect(schema.components.schemas).toBeDefined(); + const outputPath = path.join(getTempSnapshotsPath(), 'redfish-like.json'); + const snapshotPath = path.join(getSnapshotsPath(), 'redfish-like.json'); - const schemaKeys = Object.keys(schema.components.schemas); + // Ensure directory exists + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - // ResolutionStep (directly referenced) - const resolutionStepKey = schemaKeys.find( - (k) => k.includes('ResolutionStep') && !k.includes('Type') && !k.includes('ActionParameters'), - ); - expect(resolutionStepKey).toBeDefined(); - - // ResolutionType (sibling, referenced by ResolutionStep and ActionParameters) - const resolutionTypeKey = schemaKeys.find((k) => k.includes('ResolutionType')); - expect(resolutionTypeKey).toBeDefined(); - expect(schema.components.schemas[resolutionTypeKey!]).toEqual({ - description: 'Types of resolution actions', - enum: ['ContactVendor', 'ResetToDefaults', 'RetryOperation'], - type: 'string', - }); - - // ActionParameters (directly referenced) - const actionParamsKey = schemaKeys.find((k) => k.includes('ActionParameters')); - expect(actionParamsKey).toBeDefined(); - - // Verify that internal $refs in hoisted schemas point to hoisted locations - const resolutionStep = schema.components.schemas[resolutionStepKey!]; - expect(resolutionStep.properties.ResolutionType.oneOf[0].$ref).toContain('ResolutionType'); - expect(resolutionStep.properties.ResolutionType.oneOf[0].$ref).toMatch( - /^#\/components\/schemas\//, - ); + // Write the bundled result + const content = JSON.stringify(schema, null, 2); + fs.writeFileSync(outputPath, content); - const actionParams = schema.components.schemas[actionParamsKey!]; - expect(actionParams.properties.ActionType.$ref).toContain('ResolutionType'); - expect(actionParams.properties.ActionType.$ref).toMatch(/^#\/components\/schemas\//); + // Compare with snapshot + await expect(content).toMatchFileSnapshot(snapshotPath); }); it('fixes cross-file references (schemas in different external files)', async () => { @@ -169,53 +111,19 @@ describe('bundle', () => { 'json-schema-ref-parser', 'cross-file-ref-main.json', ); - const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; - - // Both schemas should be hoisted - expect(schema.components).toBeDefined(); - expect(schema.components.schemas).toBeDefined(); - - const schemaKeys = Object.keys(schema.components.schemas); - expect(schemaKeys.length).toBe(2); - - // Find the hoisted schemas - const schemaAKey = schemaKeys.find((k) => k.includes('SchemaA')); - const schemaBKey = schemaKeys.find((k) => k.includes('SchemaB')); - - expect(schemaAKey).toBeDefined(); - expect(schemaBKey).toBeDefined(); - - // SchemaA should have a reference to SchemaB - const schemaA = schema.components.schemas[schemaAKey!]; - expect(schemaA.properties.typeField.$ref).toBe(`#/components/schemas/${schemaBKey}`); - - // SchemaB should be the enum type - const schemaB = schema.components.schemas[schemaBKey!]; - expect(schemaB).toEqual({ - enum: ['TypeA', 'TypeB', 'TypeC'], - type: 'string', - }); - - // Verify no dangling refs exist - const findDanglingRefs = (obj: any, schemas: any): string[] => { - const dangling: string[] = []; - const check = (o: any) => { - if (!o || typeof o !== 'object') return; - if (o.$ref && typeof o.$ref === 'string' && o.$ref.startsWith('#/components/schemas/')) { - const schemaName = o.$ref.replace('#/components/schemas/', ''); - if (!schemas[schemaName]) { - dangling.push(o.$ref); - } - } - for (const value of Object.values(o)) { - check(value); - } - }; - check(obj); - return dangling; - }; - - const danglingRefs = findDanglingRefs(schema, schema.components.schemas); - expect(danglingRefs).toEqual([]); + const schema = await refParser.bundle({ pathOrUrlOrSchema }); + + const outputPath = path.join(getTempSnapshotsPath(), 'cross-file-ref-main.json'); + const snapshotPath = path.join(getSnapshotsPath(), 'cross-file-ref-main.json'); + + // 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); }); });