diff --git a/src/helpers/document-loader/__fixtures__/models.yaml b/src/helpers/document-loader/__fixtures__/models.yaml new file mode 100644 index 0000000..a9e8936 --- /dev/null +++ b/src/helpers/document-loader/__fixtures__/models.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Shared Models + version: 0.1.0 +paths: {} +components: + schemas: + Address: + title: Address + type: object + properties: + line1: { type: string } + city: { type: string } + + Company: + title: Company + type: object + properties: + name: { type: string } + address: + $ref: '#/components/schemas/Address' + status: + $ref: '#/components/schemas/CompanyStatus' + + CompanyStatus: + title: CompanyStatus + type: string + enum: [PENDING, APPROVED, REJECTED] + + CompanyList: + title: CompanyList + type: array + items: + $ref: '#/components/schemas/Company' diff --git a/src/helpers/document-loader/__fixtures__/multi-file-api.yaml b/src/helpers/document-loader/__fixtures__/multi-file-api.yaml new file mode 100644 index 0000000..4596e85 --- /dev/null +++ b/src/helpers/document-loader/__fixtures__/multi-file-api.yaml @@ -0,0 +1,63 @@ +openapi: 3.1.0 +info: + title: Multi File API + version: 0.1.0 +paths: + /companies: + get: + operationId: listCompanies + responses: + '200': + description: List of companies + content: + application/json: + schema: + $ref: 'models.yaml#/components/schemas/CompanyList' + post: + operationId: createCompany + requestBody: + content: + application/json: + schema: + $ref: 'models.yaml#/components/schemas/Company' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: 'models.yaml#/components/schemas/Company' + /companies/{id}: + get: + operationId: getCompany + parameters: + - in: path + name: id + required: true + schema: { type: string } + responses: + '200': + description: Company detail + content: + application/json: + schema: + $ref: 'models.yaml#/components/schemas/Company' + put: + operationId: updateCompany + parameters: + - in: path + name: id + required: true + schema: { type: string } + requestBody: + content: + application/json: + schema: + $ref: 'models.yaml#/components/schemas/Company' + responses: + '200': + description: Updated + content: + application/json: + schema: + $ref: 'models.yaml#/components/schemas/Company' diff --git a/src/helpers/document-loader/load.spec.ts b/src/helpers/document-loader/load.spec.ts new file mode 100644 index 0000000..ab783e1 --- /dev/null +++ b/src/helpers/document-loader/load.spec.ts @@ -0,0 +1,59 @@ +import 'ts-array-extensions'; +import type { JsonMap } from '@laurence79/ts-json'; +import path from 'path'; +import { load } from './load'; + +const fixture = (name: string) => path.join(__dirname, '__fixtures__', name); + +describe('load', () => { + describe('multi-file $ref resolution', () => { + it('inlines cross-file schemas into the root document', async () => { + const doc = (await load(fixture('multi-file-api.yaml'))) as JsonMap; + const components = doc.components as JsonMap; + const schemas = components.schemas as JsonMap; + + expect(schemas.Company).toBeDefined(); + expect(schemas.CompanyList).toBeDefined(); + expect(schemas.Address).toBeDefined(); + expect(schemas.CompanyStatus).toBeDefined(); + }); + + it('rewrites $ref to point to local schemas', async () => { + const doc = (await load(fixture('multi-file-api.yaml'))) as JsonMap; + const paths = doc.paths as JsonMap; + const companies = paths['/companies'] as JsonMap; + const get = companies.get as JsonMap; + const responses = get.responses as JsonMap; + const ok = responses['200'] as JsonMap; + const content = ok.content as JsonMap; + const json = content['application/json'] as JsonMap; + const schema = json.schema as JsonMap; + + expect(schema.$ref).toMatch(/#\/components\/schemas\/CompanyList$/); + }); + + it('does not create nested components trees under inlined schemas', async () => { + const doc = (await load(fixture('multi-file-api.yaml'))) as JsonMap; + const components = doc.components as JsonMap; + const schemas = components.schemas as JsonMap; + + for (const [, schema] of Object.entries(schemas)) { + expect(schema).not.toHaveProperty('components'); + } + }); + + it('inlines each external schema only once regardless of reference count', async () => { + const doc = (await load(fixture('multi-file-api.yaml'))) as JsonMap; + const components = doc.components as JsonMap; + const schemas = components.schemas as JsonMap; + + const schemaNames = Object.keys(schemas).sort(); + expect(schemaNames).toEqual([ + 'Address', + 'Company', + 'CompanyList', + 'CompanyStatus' + ]); + }); + }); +}); diff --git a/src/helpers/document-loader/load.ts b/src/helpers/document-loader/load.ts index e5a2f4a..278c36e 100644 --- a/src/helpers/document-loader/load.ts +++ b/src/helpers/document-loader/load.ts @@ -172,6 +172,7 @@ export const load = async (source: string, logger?: Logger): Promise => { JsonMap; const walked: string[] = []; + const inlinedRefs = new Set(); const walkFn = async (node: JsonMap, ptr: string) => { if (!isReference(node) || walked.includes(ptr)) { @@ -198,11 +199,18 @@ export const load = async (source: string, logger?: Logger): Promise => { return new Reference(`#/components/${key}/${title}`, filename); })(); - jsonPointer.set(doc, newRef.pointer, refNode); - node.$ref = newRef.$; - await AsyncJsonWalker.walk(doc, walkFn, newRef.pointer); + const canonicalKey = ref.$; + + if (!inlinedRefs.has(canonicalKey)) { + inlinedRefs.add(canonicalKey); + + const cloned = JSON.parse(JSON.stringify(refNode)) as JsonMap; + jsonPointer.set(doc, newRef.pointer, cloned); + + await AsyncJsonWalker.walk(cloned, walkFn, newRef.pointer); + } } };