Skip to content
Open
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
71 changes: 71 additions & 0 deletions packages/json-schema-ref-parser/src/__tests__/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,75 @@ describe('bundle', () => {
},
});
});

it('updates discriminator.mapping values when bundling multi-file schemas', async () => {
const refParser = new $RefParser();
const pathOrUrlOrSchema = path.join(
getSpecsPath(),
'json-schema-ref-parser',
'discriminator-multi-file.json',
);
const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any;

// The external schemas should be hoisted into components/schemas with file prefix
const schemas = schema.components.schemas;
expect(schemas['discriminator-providers_JetBrainsProviderConfigResponse']).toBeDefined();
expect(schemas['discriminator-providers_OpenAIProviderConfigResponse']).toBeDefined();

// Find the bundled AIProviderConfigResponse (hoisted from external file)
const aiProvider = schemas['discriminator-providers_AIProviderConfigResponse'];
expect(aiProvider).toBeDefined();
expect(aiProvider.discriminator).toBeDefined();
expect(aiProvider.discriminator.mapping).toBeDefined();

// The discriminator.mapping values should be updated to match the rewritten $ref paths
const mapping = aiProvider.discriminator.mapping;
const oneOfRefs = aiProvider.oneOf.map((item: any) => item.$ref);

// mapping values should point to the same schemas as oneOf $refs
expect(oneOfRefs).toContain(mapping.jetbrains);
expect(oneOfRefs).toContain(mapping.openai);

// mapping values should use the prefixed schema names, not the original ones
expect(mapping.jetbrains).toContain('discriminator-providers_JetBrainsProviderConfigResponse');
expect(mapping.openai).toContain('discriminator-providers_OpenAIProviderConfigResponse');
});

it('does not modify discriminator.mapping for single-file schemas', async () => {
const refParser = new $RefParser();
const schema = (await refParser.bundle({
pathOrUrlOrSchema: {
components: {
schemas: {
Bar: {
properties: { type: { enum: ['bar'], type: 'string' } },
type: 'object',
},
Baz: {
properties: { type: { enum: ['baz'], type: 'string' } },
type: 'object',
},
Foo: {
discriminator: {
mapping: {
bar: '#/components/schemas/Bar',
baz: '#/components/schemas/Baz',
},
propertyName: 'type',
},
oneOf: [{ $ref: '#/components/schemas/Bar' }, { $ref: '#/components/schemas/Baz' }],
},
},
},
openapi: '3.0.3',
},
})) as any;

const mapping = schema.components.schemas.Foo.discriminator.mapping;
// Mapping values should remain unchanged for single-file schemas
expect(mapping.bar).toContain('Bar');
expect(mapping.baz).toContain('Baz');
// The mapping keys (discriminator values) should be preserved
expect(Object.keys(mapping)).toEqual(['bar', 'baz']);
});
});
139 changes: 139 additions & 0 deletions packages/json-schema-ref-parser/src/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,144 @@ function removeFromInventory(inventory: Array<InventoryEntry>, entry: any) {
inventory.splice(index, 1);
}

/**
* Extracts the last path segment from a JSON Reference string.
* e.g. "#/components/schemas/Foo" → "Foo", "#/Foo" → "Foo"
*/
function refLastSegment(ref: string): string {
let clean = ref;
if (clean.startsWith('#')) {
clean = clean.slice(1);
}
if (clean.startsWith('/')) {
clean = clean.slice(1);
}
if (!clean) {
return '';
}
const parts = clean.split('/');
const last = parts[parts.length - 1] || '';
try {
return decodeURIComponent(last);
} catch {
return last;
}
}

/**
* Safely decodes a URI string. Returns the original string if decoding fails.
*/
function safeDecodeURI(value: string): string {
try {
return decodeURI(value);
} catch {
return value;
}
}

/**
* After bundling rewrites all $ref objects to internal paths, discriminator.mapping
* values (which are plain strings, not $ref objects) may still contain stale references
* from external files. This function walks the schema and updates those mapping values
* by matching them against the already-corrected $ref values in sibling oneOf/anyOf arrays.
*/
function updateDiscriminatorMappings(schema: any): void {
const seen = new WeakSet<object>();

function walk(obj: any): void {
if (!obj || typeof obj !== 'object' || seen.has(obj)) {
return;
}

if (Array.isArray(obj)) {
for (const item of obj) {
walk(item);
}
return;
}

seen.add(obj);

if (
obj.discriminator &&
typeof obj.discriminator === 'object' &&
obj.discriminator.mapping &&
typeof obj.discriminator.mapping === 'object' &&
(Array.isArray(obj.oneOf) || Array.isArray(obj.anyOf))
) {
const composition = [...(obj.oneOf || []), ...(obj.anyOf || [])];

// Build a map from schema name (last segment) to full $ref path
const nameToFullRef = new Map<string, string | null>();
for (const item of composition) {
if (item && typeof item === 'object' && item.$ref && typeof item.$ref === 'string') {
const name = refLastSegment(item.$ref);
if (name) {
// null signals ambiguity (multiple $refs with the same last segment)
nameToFullRef.set(name, nameToFullRef.has(name) ? null : item.$ref);
}
}
}

const mapping = obj.discriminator.mapping;
for (const key of Object.keys(mapping)) {
const mappingValue = mapping[key];
if (typeof mappingValue !== 'string') {
continue;
}

// Skip if the mapping value already correctly matches a $ref in the composition.
// Compare both exact strings and URI-decoded versions to handle cases where
// the bundler URL-encodes non-ASCII characters in $ref values but the mapping
// retains the original unencoded form (e.g. "Spæcial" vs "Sp%C3%A6cial").
const decodedMappingValue = safeDecodeURI(mappingValue);
const isAlreadyCorrect = composition.some((item: any) => {
if (!item || typeof item.$ref !== 'string') {
return false;
}
return item.$ref === mappingValue || safeDecodeURI(item.$ref) === decodedMappingValue;
});
if (isAlreadyCorrect) {
continue;
}

const mappingName = refLastSegment(mappingValue);
if (!mappingName) {
continue;
}

// Try exact name match (e.g. both have the same last segment)
const exactMatch = nameToFullRef.get(mappingName);
if (exactMatch) {
mapping[key] = exactMatch;
continue;
}

// Try suffix match to handle file-prefix case:
// bundled $ref name is "{filePrefix}_{originalName}", mapping still has "{originalName}"
let matched: string | undefined;
let matchCount = 0;
for (const [refName, fullRef] of nameToFullRef) {
if (fullRef && refName.endsWith(`_${mappingName}`)) {
matched = fullRef;
matchCount++;
}
}

if (matchCount === 1 && matched) {
mapping[key] = matched;
}
}
}

for (const value of Object.values(obj)) {
walk(value);
}
}

walk(schema);
}

/**
* Bundles all external JSON references into the main JSON schema, thus resulting in a schema that
* only has *internal* references, not any *external* references.
Expand Down Expand Up @@ -638,4 +776,5 @@ export function bundle(parser: $RefParser, options: ParserOptions): void {
});

remap(parser, inventory);
updateDiscriminatorMappings(parser.schema);
}
27 changes: 27 additions & 0 deletions packages/json-schema-ref-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,33 @@ export class $RefParser {
out[k] = cloneAndRewrite(v as any, refMap, tagMap, opIdPrefix, basePath);
}
}

// Rewrite discriminator.mapping values using the same ref rewriting logic as $ref keys.
// discriminator.mapping values are $ref-like strings but stored as plain values,
// so the generic $ref handling above doesn't catch them.
if (
out.discriminator &&
typeof out.discriminator === 'object' &&
out.discriminator.mapping &&
typeof out.discriminator.mapping === 'object'
) {
for (const [mk, mv] of Object.entries(out.discriminator.mapping)) {
if (typeof mv === 'string') {
if ((mv as string).startsWith('#')) {
const rewritten = rewriteRef(mv as string, refMap);
if (rewritten !== mv) {
out.discriminator.mapping[mk] = rewritten;
}
} else {
const proto = url.getProtocol(mv as string);
if (proto === undefined) {
out.discriminator.mapping[mk] = url.resolve(basePath + '#', mv as string);
}
}
}
}
}

return out;
};

Expand Down
14 changes: 14 additions & 0 deletions specs/json-schema-ref-parser/discriminator-multi-file.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"openapi": "3.0.3",
"info": {
"title": "Discriminator multi-file test",
"version": "1"
},
"components": {
"schemas": {
"AIProviderConfigResponse": {
"$ref": "discriminator-providers.json#/AIProviderConfigResponse"
}
}
}
}
35 changes: 35 additions & 0 deletions specs/json-schema-ref-parser/discriminator-providers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"JetBrainsProviderConfigResponse": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["jetbrains"]
}
},
"required": ["type"]
},
"OpenAIProviderConfigResponse": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["openai"]
}
},
"required": ["type"]
},
"AIProviderConfigResponse": {
"oneOf": [
{ "$ref": "#/JetBrainsProviderConfigResponse" },
{ "$ref": "#/OpenAIProviderConfigResponse" }
],
"discriminator": {
"propertyName": "type",
"mapping": {
"jetbrains": "#/JetBrainsProviderConfigResponse",
"openai": "#/OpenAIProviderConfigResponse"
}
}
}
}
Loading