fork of hey-api/openapi-ts because I need some additional things
1import { ono } from '@jsdevtools/ono';
2
3import { bundle as _bundle } from './bundle';
4import { getJsonSchemaRefParserDefaultOptions } from './options';
5import { newFile, parseFile } from './parse';
6import $Refs from './refs';
7import { resolveExternal } from './resolve-external';
8import { fileResolver } from './resolvers/file';
9import { urlResolver } from './resolvers/url';
10import type { JSONSchema } from './types';
11import { isHandledError, JSONParserErrorGroup } from './util/errors';
12import * as url from './util/url';
13
14interface ResolvedInput {
15 path: string;
16 schema: string | JSONSchema | Buffer | Awaited<JSONSchema> | undefined;
17 type: 'file' | 'json' | 'url';
18}
19
20export function getResolvedInput({
21 pathOrUrlOrSchema,
22}: {
23 pathOrUrlOrSchema: JSONSchema | string | unknown;
24}): ResolvedInput {
25 if (!pathOrUrlOrSchema) {
26 throw ono(`Expected a file path, URL, or object. Got ${pathOrUrlOrSchema}`);
27 }
28
29 const resolvedInput: ResolvedInput = {
30 path: typeof pathOrUrlOrSchema === 'string' ? pathOrUrlOrSchema : '',
31 schema: undefined,
32 type: 'url',
33 };
34
35 // If the path is a filesystem path, then convert it to a URL.
36 // NOTE: According to the JSON Reference spec, these should already be URLs,
37 // but, in practice, many people use local filesystem paths instead.
38 // So we're being generous here and doing the conversion automatically.
39 // This is not intended to be a 100% bulletproof solution.
40 // If it doesn't work for your use-case, then use a URL instead.
41 if (resolvedInput.path && url.isFileSystemPath(resolvedInput.path)) {
42 resolvedInput.path = url.fromFileSystemPath(resolvedInput.path);
43 resolvedInput.type = 'file';
44 } else if (!resolvedInput.path && pathOrUrlOrSchema && typeof pathOrUrlOrSchema === 'object') {
45 if ('$id' in pathOrUrlOrSchema && pathOrUrlOrSchema.$id) {
46 // when schema id has defined an URL should use that hostname to request the references,
47 // instead of using the current page URL
48 const { hostname, protocol } = new URL(pathOrUrlOrSchema.$id as string);
49 resolvedInput.path = `${protocol}//${hostname}:${protocol === 'https:' ? 443 : 80}`;
50 resolvedInput.type = 'url';
51 } else {
52 resolvedInput.schema = pathOrUrlOrSchema;
53 resolvedInput.type = 'json';
54 }
55 }
56
57 if (resolvedInput.type !== 'json') {
58 // resolve the absolute path of the schema
59 resolvedInput.path = url.resolve(url.cwd(), resolvedInput.path);
60 }
61
62 return resolvedInput;
63}
64
65// NOTE: previously used helper removed as unused
66
67/**
68 * This class parses a JSON schema, builds a map of its JSON references and their resolved values,
69 * and provides methods for traversing, manipulating, and dereferencing those references.
70 */
71export class $RefParser {
72 /**
73 * The resolved JSON references
74 *
75 * @type {$Refs}
76 * @readonly
77 */
78 $refs = new $Refs<JSONSchema>();
79 public options = getJsonSchemaRefParserDefaultOptions();
80 /**
81 * The parsed (and possibly dereferenced) JSON schema object
82 *
83 * @type {object}
84 * @readonly
85 */
86 public schema: JSONSchema | null = null;
87 public schemaMany: JSONSchema[] = [];
88 public schemaManySources: string[] = [];
89 public sourcePathToPrefix: Map<string, string> = new Map();
90
91 /**
92 * 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.
93 *
94 * This also eliminates the risk of circular references, so the schema can be safely serialized using `JSON.stringify()`.
95 *
96 * See https://apitools.dev/json-schema-ref-parser/docs/ref-parser.html#bundleschema-options-callback
97 *
98 * @param pathOrUrlOrSchema A JSON Schema object, or the file path or URL of a JSON Schema file.
99 */
100 public async bundle({
101 arrayBuffer,
102 fetch,
103 pathOrUrlOrSchema,
104 resolvedInput,
105 }: {
106 arrayBuffer?: ArrayBuffer;
107 fetch?: RequestInit;
108 pathOrUrlOrSchema: JSONSchema | string | unknown;
109 resolvedInput?: ResolvedInput;
110 }): Promise<JSONSchema> {
111 await this.parse({
112 arrayBuffer,
113 fetch,
114 pathOrUrlOrSchema,
115 resolvedInput,
116 });
117
118 await resolveExternal(this, this.options);
119 const errors = JSONParserErrorGroup.getParserErrors(this);
120 if (errors.length > 0) {
121 throw new JSONParserErrorGroup(this);
122 }
123 _bundle(this, this.options);
124 const errors2 = JSONParserErrorGroup.getParserErrors(this);
125 if (errors2.length > 0) {
126 throw new JSONParserErrorGroup(this);
127 }
128 return this.schema!;
129 }
130
131 /**
132 * Bundles multiple roots (files/URLs/objects) into a single schema by creating a synthetic root
133 * that references each input, resolving all externals, and then hoisting via the existing bundler.
134 */
135 public async bundleMany({
136 arrayBuffer,
137 fetch,
138 pathOrUrlOrSchemas,
139 resolvedInputs,
140 }: {
141 arrayBuffer?: ArrayBuffer[];
142 fetch?: RequestInit;
143 pathOrUrlOrSchemas: Array<JSONSchema | string | unknown>;
144 resolvedInputs?: ResolvedInput[];
145 }): Promise<JSONSchema> {
146 await this.parseMany({ arrayBuffer, fetch, pathOrUrlOrSchemas, resolvedInputs });
147 this.mergeMany();
148
149 await resolveExternal(this, this.options);
150 const errors = JSONParserErrorGroup.getParserErrors(this);
151 if (errors.length > 0) {
152 throw new JSONParserErrorGroup(this);
153 }
154 _bundle(this, this.options);
155 // Merged root is ready for bundling
156
157 const errors2 = JSONParserErrorGroup.getParserErrors(this);
158 if (errors2.length > 0) {
159 throw new JSONParserErrorGroup(this);
160 }
161 return this.schema!;
162 }
163
164 /**
165 * Parses the given JSON schema.
166 * This method does not resolve any JSON references.
167 * It just reads a single file in JSON or YAML format, and parse it as a JavaScript object.
168 *
169 * @param pathOrUrlOrSchema A JSON Schema object, or the file path or URL of a JSON Schema file.
170 * @returns - The returned promise resolves with the parsed JSON schema object.
171 */
172 public async parse({
173 arrayBuffer,
174 fetch,
175 pathOrUrlOrSchema,
176 resolvedInput: _resolvedInput,
177 }: {
178 arrayBuffer?: ArrayBuffer;
179 fetch?: RequestInit;
180 pathOrUrlOrSchema: JSONSchema | string | unknown;
181 resolvedInput?: ResolvedInput;
182 }): Promise<{ schema: JSONSchema }> {
183 const resolvedInput = _resolvedInput || getResolvedInput({ pathOrUrlOrSchema });
184 const { path, type } = resolvedInput;
185 let { schema } = resolvedInput;
186
187 // reset everything
188 this.schema = null;
189 this.$refs = new $Refs();
190
191 if (schema) {
192 // immediately add a new $Ref with the schema object as value
193 const $ref = this.$refs._add(path);
194 $ref.pathType = url.isFileSystemPath(path) ? 'file' : 'http';
195 $ref.value = schema;
196 } else if (type !== 'json') {
197 const file = newFile(path);
198
199 // Add a new $Ref for this file, even though we don't have the value yet.
200 // This ensures that we don't simultaneously read & parse the same file multiple times
201 const $refAdded = this.$refs._add(file.url);
202 $refAdded.pathType = type;
203 try {
204 const resolver = type === 'file' ? fileResolver : urlResolver;
205 await resolver.handler({
206 arrayBuffer,
207 fetch,
208 file,
209 });
210 const parseResult = await parseFile(file, this.options.parse);
211 $refAdded.value = parseResult.result;
212 schema = parseResult.result;
213 } catch (error) {
214 if (isHandledError(error)) {
215 $refAdded.value = error;
216 }
217 throw error;
218 }
219 }
220
221 if (schema === null || typeof schema !== 'object' || Buffer.isBuffer(schema)) {
222 throw ono.syntax(`"${this.$refs._root$Ref.path || schema}" is not a valid JSON Schema`);
223 }
224
225 this.schema = schema;
226
227 return {
228 schema,
229 };
230 }
231
232 private async parseMany({
233 arrayBuffer,
234 fetch,
235 pathOrUrlOrSchemas,
236 resolvedInputs: _resolvedInputs,
237 }: {
238 arrayBuffer?: ArrayBuffer[];
239 fetch?: RequestInit;
240 pathOrUrlOrSchemas: Array<JSONSchema | string | unknown>;
241 resolvedInputs?: ResolvedInput[];
242 }): Promise<{ schemaMany: JSONSchema[] }> {
243 const resolvedInputs = [...(_resolvedInputs || [])];
244 resolvedInputs.push(
245 ...(pathOrUrlOrSchemas.map((schema) => getResolvedInput({ pathOrUrlOrSchema: schema })) ||
246 []),
247 );
248
249 this.schemaMany = [];
250 this.schemaManySources = [];
251 this.sourcePathToPrefix = new Map();
252
253 for (let i = 0; i < resolvedInputs.length; i++) {
254 const resolvedInput = resolvedInputs[i]!;
255 const { path, type } = resolvedInput;
256 let { schema } = resolvedInput;
257
258 if (schema) {
259 // keep schema as-is
260 } else if (type !== 'json') {
261 const file = newFile(path);
262
263 // Add a new $Ref for this file, even though we don't have the value yet.
264 // This ensures that we don't simultaneously read & parse the same file multiple times
265 const $refAdded = this.$refs._add(file.url);
266 $refAdded.pathType = type;
267 try {
268 const resolver = type === 'file' ? fileResolver : urlResolver;
269 await resolver.handler({
270 arrayBuffer: arrayBuffer?.[i],
271 fetch,
272 file,
273 });
274 const parseResult = await parseFile(file, this.options.parse);
275 $refAdded.value = parseResult.result;
276 schema = parseResult.result;
277 } catch (error) {
278 if (isHandledError(error)) {
279 $refAdded.value = error;
280 }
281 throw error;
282 }
283 }
284
285 if (schema === null || typeof schema !== 'object' || Buffer.isBuffer(schema)) {
286 throw ono.syntax(`"${this.$refs._root$Ref.path || schema}" is not a valid JSON Schema`);
287 }
288
289 this.schemaMany.push(schema);
290 this.schemaManySources.push(path && path.length ? path : url.cwd());
291 }
292
293 return {
294 schemaMany: this.schemaMany,
295 };
296 }
297
298 public mergeMany(): JSONSchema {
299 const schemas = this.schemaMany || [];
300 if (schemas.length === 0) {
301 throw ono('mergeMany called with no schemas. Did you run parseMany?');
302 }
303
304 const merged: any = {};
305
306 // Determine spec version: prefer first occurrence of openapi, else swagger
307 let chosenOpenapi: string | undefined;
308 let chosenSwagger: string | undefined;
309 for (const s of schemas) {
310 if (!chosenOpenapi && s && typeof (s as any).openapi === 'string') {
311 chosenOpenapi = (s as any).openapi;
312 }
313 if (!chosenSwagger && s && typeof (s as any).swagger === 'string') {
314 chosenSwagger = (s as any).swagger;
315 }
316 if (chosenOpenapi && chosenSwagger) {
317 break;
318 }
319 }
320 if (typeof chosenOpenapi === 'string') {
321 merged.openapi = chosenOpenapi;
322 } else if (typeof chosenSwagger === 'string') {
323 merged.swagger = chosenSwagger;
324 }
325
326 // Merge info: take first non-empty per-field across inputs
327 const infoAccumulator: any = {};
328 for (const s of schemas) {
329 const info = (s as any)?.info;
330 if (info && typeof info === 'object') {
331 for (const [k, v] of Object.entries(info)) {
332 if (infoAccumulator[k] === undefined && v !== undefined) {
333 infoAccumulator[k] = JSON.parse(JSON.stringify(v));
334 }
335 }
336 }
337 }
338 if (Object.keys(infoAccumulator).length > 0) {
339 merged.info = infoAccumulator;
340 }
341
342 // Merge servers: union by url+description
343 const servers: any[] = [];
344 const seenServers = new Set<string>();
345 for (const s of schemas) {
346 const arr = (s as any)?.servers;
347 if (Array.isArray(arr)) {
348 for (const srv of arr) {
349 if (srv && typeof srv === 'object') {
350 const key = `${srv.url || ''}|${srv.description || ''}`;
351 if (!seenServers.has(key)) {
352 seenServers.add(key);
353 servers.push(JSON.parse(JSON.stringify(srv)));
354 }
355 }
356 }
357 }
358 }
359 if (servers.length > 0) {
360 merged.servers = servers;
361 }
362
363 merged.paths = {};
364 merged.components = {};
365
366 const componentSections = [
367 'schemas',
368 'parameters',
369 'requestBodies',
370 'responses',
371 'headers',
372 'securitySchemes',
373 'examples',
374 'links',
375 'callbacks',
376 ];
377 for (const sec of componentSections) {
378 merged.components[sec] = {};
379 }
380
381 const tagNameSet = new Set<string>();
382 const tags: any[] = [];
383 const usedOpIds = new Set<string>();
384
385 const baseName = (p: string) => {
386 try {
387 const withoutHash = p.split('#')[0]!;
388 const parts = withoutHash.split('/');
389 const filename = parts[parts.length - 1] || 'schema';
390 const dot = filename.lastIndexOf('.');
391 const raw = dot > 0 ? filename.substring(0, dot) : filename;
392 return raw.replace(/[^A-Za-z0-9_-]/g, '_');
393 } catch {
394 return 'schema';
395 }
396 };
397 const unique = (set: Set<string>, proposed: string) => {
398 let name = proposed;
399 let i = 2;
400 while (set.has(name)) {
401 name = `${proposed}_${i++}`;
402 }
403 set.add(name);
404 return name;
405 };
406
407 const rewriteRef = (ref: string, refMap: Map<string, string>): string => {
408 // OAS3: #/components/{section}/{name}...
409 let m = ref.match(/^#\/components\/([^/]+)\/([^/]+)(.*)$/);
410 if (m) {
411 const base = `#/components/${m[1]}/${m[2]}`;
412 const mapped = refMap.get(base);
413 if (mapped) {
414 return mapped + (m[3] || '');
415 }
416 }
417 // OAS2: #/definitions/{name}...
418 m = ref.match(/^#\/definitions\/([^/]+)(.*)$/);
419 if (m) {
420 const base = `#/components/schemas/${m[1]}`;
421 const mapped = refMap.get(base);
422 if (mapped) {
423 // map definitions -> components/schemas
424 return mapped + (m[2] || '');
425 }
426 }
427 return ref;
428 };
429
430 const cloneAndRewrite = (
431 obj: any,
432 refMap: Map<string, string>,
433 tagMap: Map<string, string>,
434 opIdPrefix: string,
435 basePath: string,
436 ): any => {
437 if (obj === null || obj === undefined) {
438 return obj;
439 }
440 if (Array.isArray(obj)) {
441 return obj.map((v) => cloneAndRewrite(v, refMap, tagMap, opIdPrefix, basePath));
442 }
443 if (typeof obj !== 'object') {
444 return obj;
445 }
446
447 const out: any = {};
448 for (const [k, v] of Object.entries(obj)) {
449 if (k === '$ref' && typeof v === 'string') {
450 const s = v as string;
451 if (s.startsWith('#')) {
452 out[k] = rewriteRef(s, refMap);
453 } else {
454 const proto = url.getProtocol(s);
455 if (proto === undefined) {
456 // relative external ref -> absolutize against source base path
457 out[k] = url.resolve(basePath + '#', s);
458 } else {
459 out[k] = s;
460 }
461 }
462 } else if (k === 'tags' && Array.isArray(v) && v.every((x) => typeof x === 'string')) {
463 out[k] = v.map((t) => tagMap.get(t) || t);
464 } else if (k === 'operationId' && typeof v === 'string') {
465 out[k] = unique(usedOpIds, `${opIdPrefix}_${v}`);
466 } else {
467 out[k] = cloneAndRewrite(v as any, refMap, tagMap, opIdPrefix, basePath);
468 }
469 }
470 return out;
471 };
472
473 for (let i = 0; i < schemas.length; i++) {
474 const schema: any = schemas[i] || {};
475 const sourcePath = this.schemaManySources[i] || `multi://input/${i + 1}`;
476 const prefix = baseName(sourcePath);
477
478 // Track prefix for this source path (strip hash). Only map real file/http paths
479 const withoutHash = url.stripHash(sourcePath);
480 const protocol = url.getProtocol(withoutHash);
481 if (
482 protocol === undefined ||
483 protocol === 'file' ||
484 protocol === 'http' ||
485 protocol === 'https'
486 ) {
487 this.sourcePathToPrefix.set(withoutHash, prefix);
488 }
489
490 const refMap = new Map<string, string>();
491 const tagMap = new Map<string, string>();
492
493 const srcComponents = (schema.components || {}) as any;
494 for (const sec of componentSections) {
495 const group = srcComponents[sec] || {};
496 for (const [name] of Object.entries(group)) {
497 const newName = `${prefix}_${name}`;
498 refMap.set(`#/components/${sec}/${name}`, `#/components/${sec}/${newName}`);
499 }
500 }
501
502 const srcTags: any[] = Array.isArray(schema.tags) ? schema.tags : [];
503 for (const t of srcTags) {
504 if (!t || typeof t !== 'object' || typeof t.name !== 'string') {
505 continue;
506 }
507 const desired = t.name;
508 const finalName = tagNameSet.has(desired) ? `${prefix}_${desired}` : desired;
509 tagNameSet.add(finalName);
510 tagMap.set(desired, finalName);
511 if (!tags.find((x) => x && x.name === finalName)) {
512 tags.push({ ...t, name: finalName });
513 }
514 }
515
516 for (const sec of componentSections) {
517 const group = (schema.components && schema.components[sec]) || {};
518 for (const [name, val] of Object.entries(group)) {
519 const newName = `${prefix}_${name}`;
520 merged.components[sec][newName] = cloneAndRewrite(
521 val,
522 refMap,
523 tagMap,
524 prefix,
525 url.stripHash(sourcePath),
526 );
527 }
528 }
529
530 const srcPaths = (schema.paths || {}) as Record<string, any>;
531 for (const [p, item] of Object.entries(srcPaths)) {
532 let targetPath = p;
533 if (merged.paths[p]) {
534 const trimmed = p.startsWith('/') ? p.substring(1) : p;
535 targetPath = `/${prefix}/${trimmed}`;
536 }
537 merged.paths[targetPath] = cloneAndRewrite(
538 item,
539 refMap,
540 tagMap,
541 prefix,
542 url.stripHash(sourcePath),
543 );
544 }
545 }
546
547 if (tags.length > 0) {
548 merged.tags = tags;
549 }
550
551 // Rebuild $refs root using the first input's path to preserve external resolution semantics
552 const rootPath = this.schemaManySources[0] || url.cwd();
553 this.$refs = new $Refs();
554 const rootRef = this.$refs._add(rootPath);
555 rootRef.pathType = url.isFileSystemPath(rootPath) ? 'file' : 'http';
556 rootRef.value = merged;
557 this.schema = merged;
558 return merged as JSONSchema;
559 }
560}
561
562export { sendRequest } from './resolvers/url';
563export type { JSONSchema } from './types';