Skip to content

Commit 4674566

Browse files
committed
feat: implement bundling of multiple schemas with enhanced prefix handling
1 parent 0f7f409 commit 4674566

File tree

3 files changed

+339
-11
lines changed

3 files changed

+339
-11
lines changed

lib/bundle.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,21 @@ function remap(parser: $RefParser, inventory: InventoryEntry[]) {
607607

608608
let defName = namesForPrefix.get(targetKey);
609609
if (!defName) {
610-
const proposed = `${baseName(entry.file)}_${lastToken(entry.hash)}`;
610+
// If the external file is one of the original input sources, prefer its assigned prefix
611+
let proposedBase = baseName(entry.file);
612+
try {
613+
const parserAny: any = parser as any;
614+
if (parserAny && parserAny.sourcePathToPrefix && typeof parserAny.sourcePathToPrefix.get === "function") {
615+
const withoutHash = (entry.file || "").split("#")[0];
616+
const mapped = parserAny.sourcePathToPrefix.get(withoutHash);
617+
if (mapped && typeof mapped === "string") {
618+
proposedBase = mapped;
619+
}
620+
}
621+
} catch {
622+
// Ignore errors
623+
}
624+
const proposed = `${proposedBase}_${lastToken(entry.hash)}`;
611625
defName = uniqueName(container, proposed);
612626
namesForPrefix.set(targetKey, defName);
613627
// Store the resolved value under the container

lib/index.ts

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ export const getResolvedInput = ({
6262
return resolvedInput;
6363
};
6464

65+
const _ensureResolvedInputPath = (input: ResolvedInput, fallbackPath: string): ResolvedInput => {
66+
if (input.type === "json" && (!input.path || input.path.length === 0)) {
67+
return { ...input, path: fallbackPath };
68+
}
69+
return input;
70+
};
71+
72+
// NOTE: previously used helper removed as unused
73+
6574
/**
6675
* This class parses a JSON schema, builds a map of its JSON references and their resolved values,
6776
* and provides methods for traversing, manipulating, and dereferencing those references.
@@ -82,6 +91,9 @@ export class $RefParser {
8291
* @readonly
8392
*/
8493
public schema: JSONSchema | null = null;
94+
public schemaMany: JSONSchema[] = [];
95+
public schemaManySources: string[] = [];
96+
public sourcePathToPrefix: Map<string, string> = new Map();
8597

8698
/**
8799
* Bundles all referenced files/URLs into a single schema that only has internal `$ref` pointers. This lets you split-up your schema however you want while you're building it, but easily combine all those files together when it's time to package or distribute the schema to other people. The resulting schema size will be small, since it will still contain internal JSON references rather than being fully-dereferenced.
@@ -109,6 +121,7 @@ export class $RefParser {
109121
pathOrUrlOrSchema,
110122
resolvedInput,
111123
});
124+
112125
await resolveExternal(this, this.options);
113126
const errors = JSONParserErrorGroup.getParserErrors(this);
114127
if (errors.length > 0) {
@@ -122,6 +135,39 @@ export class $RefParser {
122135
return this.schema!;
123136
}
124137

138+
/**
139+
* Bundles multiple roots (files/URLs/objects) into a single schema by creating a synthetic root
140+
* that references each input, resolving all externals, and then hoisting via the existing bundler.
141+
*/
142+
public async bundleMany({
143+
arrayBuffer,
144+
fetch,
145+
pathOrUrlOrSchemas,
146+
resolvedInputs,
147+
}: {
148+
arrayBuffer?: ArrayBuffer[];
149+
fetch?: RequestInit;
150+
pathOrUrlOrSchemas: Array<JSONSchema | string | unknown>;
151+
resolvedInputs?: ResolvedInput[];
152+
}): Promise<JSONSchema> {
153+
await this.parseMany({ arrayBuffer, fetch, pathOrUrlOrSchemas, resolvedInputs });
154+
this.mergeMany();
155+
156+
await resolveExternal(this, this.options);
157+
const errors = JSONParserErrorGroup.getParserErrors(this);
158+
if (errors.length > 0) {
159+
throw new JSONParserErrorGroup(this);
160+
}
161+
_bundle(this, this.options);
162+
// Merged root is ready for bundling
163+
164+
const errors2 = JSONParserErrorGroup.getParserErrors(this);
165+
if (errors2.length > 0) {
166+
throw new JSONParserErrorGroup(this);
167+
}
168+
return this.schema!;
169+
}
170+
125171
/**
126172
* Dereferences all `$ref` pointers in the JSON Schema, replacing each reference with its resolved value. This results in a schema object that does not contain any `$ref` pointers. Instead, it's a normal JavaScript object tree that can easily be crawled and used just like any other JavaScript object. This is great for programmatic usage, especially when using tools that don't understand JSON references.
127173
*
@@ -223,6 +269,273 @@ export class $RefParser {
223269
schema,
224270
};
225271
}
272+
273+
private async parseMany({
274+
arrayBuffer,
275+
fetch,
276+
pathOrUrlOrSchemas,
277+
resolvedInputs: _resolvedInputs,
278+
}: {
279+
arrayBuffer?: ArrayBuffer[];
280+
fetch?: RequestInit;
281+
pathOrUrlOrSchemas: Array<JSONSchema | string | unknown>;
282+
resolvedInputs?: ResolvedInput[];
283+
}): Promise<{ schemaMany: JSONSchema[] }> {
284+
const resolvedInputs = [...(_resolvedInputs || [])];
285+
resolvedInputs.push(...(pathOrUrlOrSchemas.map((schema) => getResolvedInput({ pathOrUrlOrSchema: schema })) || []));
286+
287+
this.schemaMany = [];
288+
this.schemaManySources = [];
289+
this.sourcePathToPrefix = new Map();
290+
291+
for (let i = 0; i < resolvedInputs.length; i++) {
292+
const resolvedInput = resolvedInputs[i];
293+
const { path, type } = resolvedInput;
294+
let { schema } = resolvedInput;
295+
296+
if (schema) {
297+
// keep schema as-is
298+
} else if (type !== "json") {
299+
const file = newFile(path);
300+
301+
// Add a new $Ref for this file, even though we don't have the value yet.
302+
// This ensures that we don't simultaneously read & parse the same file multiple times
303+
const $refAdded = this.$refs._add(file.url);
304+
$refAdded.pathType = type;
305+
try {
306+
const resolver = type === "file" ? fileResolver : urlResolver;
307+
await resolver.handler({
308+
arrayBuffer: arrayBuffer?.[i],
309+
fetch,
310+
file,
311+
});
312+
const parseResult = await parseFile(file, this.options);
313+
$refAdded.value = parseResult.result;
314+
schema = parseResult.result;
315+
} catch (err) {
316+
if (isHandledError(err)) {
317+
$refAdded.value = err;
318+
}
319+
320+
throw err;
321+
}
322+
}
323+
324+
if (schema === null || typeof schema !== "object" || Buffer.isBuffer(schema)) {
325+
throw ono.syntax(`"${this.$refs._root$Ref.path || schema}" is not a valid JSON Schema`);
326+
}
327+
328+
this.schemaMany.push(schema);
329+
this.schemaManySources.push(path && path.length ? path : url.cwd());
330+
}
331+
332+
return {
333+
schemaMany: this.schemaMany,
334+
};
335+
}
336+
337+
public mergeMany(): JSONSchema {
338+
const schemas = this.schemaMany || [];
339+
if (schemas.length === 0) {
340+
throw ono("mergeMany called with no schemas. Did you run parseMany?");
341+
}
342+
343+
const first: any = schemas[0] || {};
344+
const merged: any = {};
345+
346+
if (typeof first.openapi === "string") {
347+
merged.openapi = first.openapi;
348+
}
349+
if (typeof first.swagger === "string") {
350+
merged.swagger = first.swagger;
351+
}
352+
if (first.info) {
353+
merged.info = JSON.parse(JSON.stringify(first.info));
354+
}
355+
if (first.servers) {
356+
merged.servers = JSON.parse(JSON.stringify(first.servers));
357+
}
358+
359+
merged.paths = {};
360+
merged.components = {};
361+
362+
const componentSections = [
363+
"schemas",
364+
"parameters",
365+
"requestBodies",
366+
"responses",
367+
"headers",
368+
"securitySchemes",
369+
"examples",
370+
"links",
371+
"callbacks",
372+
];
373+
for (const sec of componentSections) {
374+
merged.components[sec] = {};
375+
}
376+
377+
const tagNameSet = new Set<string>();
378+
const tags: any[] = [];
379+
const usedOpIds = new Set<string>();
380+
381+
const baseName = (p: string) => {
382+
try {
383+
const withoutHash = p.split("#")[0];
384+
const parts = withoutHash.split("/");
385+
const filename = parts[parts.length - 1] || "schema";
386+
const dot = filename.lastIndexOf(".");
387+
const raw = dot > 0 ? filename.substring(0, dot) : filename;
388+
return raw.replace(/[^A-Za-z0-9_-]/g, "_");
389+
} catch {
390+
return "schema";
391+
}
392+
};
393+
const unique = (set: Set<string>, proposed: string) => {
394+
let name = proposed;
395+
let i = 2;
396+
while (set.has(name)) {
397+
name = `${proposed}_${i++}`;
398+
}
399+
set.add(name);
400+
return name;
401+
};
402+
403+
const rewriteRef = (ref: string, refMap: Map<string, string>): string => {
404+
// OAS3: #/components/{section}/{name}...
405+
let m = ref.match(/^#\/components\/([^/]+)\/([^/]+)(.*)$/);
406+
if (m) {
407+
const base = `#/components/${m[1]}/${m[2]}`;
408+
const mapped = refMap.get(base);
409+
if (mapped) {
410+
return mapped + (m[3] || "");
411+
}
412+
}
413+
// OAS2: #/definitions/{name}...
414+
m = ref.match(/^#\/definitions\/([^/]+)(.*)$/);
415+
if (m) {
416+
const base = `#/components/schemas/${m[1]}`;
417+
const mapped = refMap.get(base);
418+
if (mapped) {
419+
// map definitions -> components/schemas
420+
return mapped + (m[2] || "");
421+
}
422+
}
423+
return ref;
424+
};
425+
426+
const cloneAndRewrite = (
427+
obj: any,
428+
refMap: Map<string, string>,
429+
tagMap: Map<string, string>,
430+
opIdPrefix: string,
431+
basePath: string,
432+
): any => {
433+
if (obj === null || obj === undefined) {
434+
return obj;
435+
}
436+
if (Array.isArray(obj)) {
437+
return obj.map((v) => cloneAndRewrite(v, refMap, tagMap, opIdPrefix, basePath));
438+
}
439+
if (typeof obj !== "object") {
440+
return obj;
441+
}
442+
443+
const out: any = {};
444+
for (const [k, v] of Object.entries(obj)) {
445+
if (k === "$ref" && typeof v === "string") {
446+
const s = v as string;
447+
if (s.startsWith("#")) {
448+
out[k] = rewriteRef(s, refMap);
449+
} else {
450+
const proto = url.getProtocol(s);
451+
if (proto === undefined) {
452+
// relative external ref -> absolutize against source base path
453+
out[k] = url.resolve(basePath + "#", s);
454+
} else {
455+
out[k] = s;
456+
}
457+
}
458+
} else if (k === "tags" && Array.isArray(v) && v.every((x) => typeof x === "string")) {
459+
out[k] = v.map((t) => tagMap.get(t) || t);
460+
} else if (k === "operationId" && typeof v === "string") {
461+
out[k] = unique(usedOpIds, `${opIdPrefix}_${v}`);
462+
} else {
463+
out[k] = cloneAndRewrite(v as any, refMap, tagMap, opIdPrefix, basePath);
464+
}
465+
}
466+
return out;
467+
};
468+
469+
for (let i = 0; i < schemas.length; i++) {
470+
const schema: any = schemas[i] || {};
471+
const sourcePath = this.schemaManySources[i] || `multi://input/${i + 1}`;
472+
const prefix = baseName(sourcePath);
473+
474+
// Track prefix for this source path (strip hash). Only map real file/http paths
475+
const withoutHash = url.stripHash(sourcePath);
476+
const protocol = url.getProtocol(withoutHash);
477+
if (protocol === undefined || protocol === "file" || protocol === "http" || protocol === "https") {
478+
this.sourcePathToPrefix.set(withoutHash, prefix);
479+
}
480+
481+
const refMap = new Map<string, string>();
482+
const tagMap = new Map<string, string>();
483+
484+
const srcComponents = (schema.components || {}) as any;
485+
for (const sec of componentSections) {
486+
const group = srcComponents[sec] || {};
487+
for (const [name] of Object.entries(group)) {
488+
const newName = `${prefix}_${name}`;
489+
refMap.set(`#/components/${sec}/${name}`, `#/components/${sec}/${newName}`);
490+
}
491+
}
492+
493+
const srcTags: any[] = Array.isArray(schema.tags) ? schema.tags : [];
494+
for (const t of srcTags) {
495+
if (!t || typeof t !== "object" || typeof t.name !== "string") {
496+
continue;
497+
}
498+
const desired = t.name;
499+
const finalName = tagNameSet.has(desired) ? `${prefix}_${desired}` : desired;
500+
tagNameSet.add(finalName);
501+
tagMap.set(desired, finalName);
502+
if (!tags.find((x) => x && x.name === finalName)) {
503+
tags.push({ ...t, name: finalName });
504+
}
505+
}
506+
507+
for (const sec of componentSections) {
508+
const group = (schema.components && schema.components[sec]) || {};
509+
for (const [name, val] of Object.entries(group)) {
510+
const newName = `${prefix}_${name}`;
511+
merged.components[sec][newName] = cloneAndRewrite(val, refMap, tagMap, prefix, url.stripHash(sourcePath));
512+
}
513+
}
514+
515+
const srcPaths = (schema.paths || {}) as Record<string, any>;
516+
for (const [p, item] of Object.entries(srcPaths)) {
517+
let targetPath = p;
518+
if (merged.paths[p]) {
519+
const trimmed = p.startsWith("/") ? p.substring(1) : p;
520+
targetPath = `/${prefix}/${trimmed}`;
521+
}
522+
merged.paths[targetPath] = cloneAndRewrite(item, refMap, tagMap, prefix, url.stripHash(sourcePath));
523+
}
524+
}
525+
526+
if (tags.length > 0) {
527+
merged.tags = tags;
528+
}
529+
530+
// Rebuild $refs root using the first input's path to preserve external resolution semantics
531+
const rootPath = this.schemaManySources[0] || url.cwd();
532+
this.$refs = new $Refs();
533+
const rootRef = this.$refs._add(rootPath);
534+
rootRef.pathType = url.isFileSystemPath(rootPath) ? "file" : "http";
535+
rootRef.value = merged;
536+
this.schema = merged;
537+
return merged as JSONSchema;
538+
}
226539
}
227540

228541
export { sendRequest } from "./resolvers/url.js";

0 commit comments

Comments
 (0)