Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/helpers/document-loader/__fixtures__/models.yaml
Original file line number Diff line number Diff line change
@@ -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'
63 changes: 63 additions & 0 deletions src/helpers/document-loader/__fixtures__/multi-file-api.yaml
Original file line number Diff line number Diff line change
@@ -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'
59 changes: 59 additions & 0 deletions src/helpers/document-loader/load.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
]);
});
});
});
14 changes: 11 additions & 3 deletions src/helpers/document-loader/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export const load = async (source: string, logger?: Logger): Promise<Json> => {
JsonMap;

const walked: string[] = [];
const inlinedRefs = new Set<string>();

const walkFn = async (node: JsonMap, ptr: string) => {
if (!isReference(node) || walked.includes(ptr)) {
Expand All @@ -198,11 +199,18 @@ export const load = async (source: string, logger?: Logger): Promise<Json> => {
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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deduplication skips inlining when target path differs

Low Severity

node.$ref = newRef.$ is set unconditionally (line 202), but jsonPointer.set(doc, newRef.pointer, cloned) only runs for the first encounter of a given canonicalKey. Since newRef depends on componentKeyForPointer(ptr), if the same external schema is referenced from contexts producing different component keys (e.g., one resolving to schemas, another to parameters), the second occurrence's $ref will point to a path that was never populated in doc, creating a dangling reference.

Additional Locations (1)
Fix in Cursor Fix in Web

}
};

Expand Down
Loading