1 | import jsYaml from 'js-yaml';
|
---|
2 | import { url } from '@swagger-api/apidom-reference/configuration/empty';
|
---|
3 | import '../../helpers/fetch-polyfill.node.js';
|
---|
4 | import lib from './index.js';
|
---|
5 | import createError from './create-error.js';
|
---|
6 | import { isFreelyNamed, absolutifyPointer } from '../helpers.js';
|
---|
7 | import { ACCEPT_HEADER_VALUE_FOR_DOCUMENTS } from '../../constants.js';
|
---|
8 | const ABSOLUTE_URL_REGEXP = /^([a-z]+:\/\/|\/\/)/i;
|
---|
9 | const JSONRefError = createError('JSONRefError', function cb(message, extra, oriError) {
|
---|
10 | this.originalError = oriError;
|
---|
11 | Object.assign(this, extra || {});
|
---|
12 | });
|
---|
13 | const docCache = {};
|
---|
14 | const specmapRefs = new WeakMap();
|
---|
15 | const skipResolutionTestFns = [
|
---|
16 | // OpenAPI 2.0 response examples
|
---|
17 | path =>
|
---|
18 | // ["paths", *, *, "responses", *, "examples"]
|
---|
19 | path[0] === 'paths' && path[3] === 'responses' && path[5] === 'examples',
|
---|
20 | // OpenAPI 3.0 Response Media Type Examples
|
---|
21 | path =>
|
---|
22 | // ["paths", *, *, "responses", *, "content", *, "example"]
|
---|
23 | path[0] === 'paths' && path[3] === 'responses' && path[5] === 'content' && path[7] === 'example', path =>
|
---|
24 | // ["paths", *, *, "responses", *, "content", *, "examples", *, "value"]
|
---|
25 | path[0] === 'paths' && path[3] === 'responses' && path[5] === 'content' && path[7] === 'examples' && path[9] === 'value',
|
---|
26 | // OpenAPI 3.0 Request Body Media Type Examples
|
---|
27 | path =>
|
---|
28 | // ["paths", *, *, "requestBody", "content", *, "example"]
|
---|
29 | path[0] === 'paths' && path[3] === 'requestBody' && path[4] === 'content' && path[6] === 'example', path =>
|
---|
30 | // ["paths", *, *, "requestBody", "content", *, "examples", *, "value"]
|
---|
31 | path[0] === 'paths' && path[3] === 'requestBody' && path[4] === 'content' && path[6] === 'examples' && path[8] === 'value',
|
---|
32 | // OAS 3.0 Parameter Examples
|
---|
33 | path =>
|
---|
34 | // ["paths", *, "parameters", *, "example"]
|
---|
35 | path[0] === 'paths' && path[2] === 'parameters' && path[4] === 'example', path =>
|
---|
36 | // ["paths", *, *, "parameters", *, "example"]
|
---|
37 | path[0] === 'paths' && path[3] === 'parameters' && path[5] === 'example', path =>
|
---|
38 | // ["paths", *, "parameters", *, "examples", *, "value"]
|
---|
39 | path[0] === 'paths' && path[2] === 'parameters' && path[4] === 'examples' && path[6] === 'value', path =>
|
---|
40 | // ["paths", *, *, "parameters", *, "examples", *, "value"]
|
---|
41 | path[0] === 'paths' && path[3] === 'parameters' && path[5] === 'examples' && path[7] === 'value', path =>
|
---|
42 | // ["paths", *, "parameters", *, "content", *, "example"]
|
---|
43 | path[0] === 'paths' && path[2] === 'parameters' && path[4] === 'content' && path[6] === 'example', path =>
|
---|
44 | // ["paths", *, "parameters", *, "content", *, "examples", *, "value"]
|
---|
45 | path[0] === 'paths' && path[2] === 'parameters' && path[4] === 'content' && path[6] === 'examples' && path[8] === 'value', path =>
|
---|
46 | // ["paths", *, *, "parameters", *, "content", *, "example"]
|
---|
47 | path[0] === 'paths' && path[3] === 'parameters' && path[4] === 'content' && path[7] === 'example', path =>
|
---|
48 | // ["paths", *, *, "parameters", *, "content", *, "examples", *, "value"]
|
---|
49 | path[0] === 'paths' && path[3] === 'parameters' && path[5] === 'content' && path[7] === 'examples' && path[9] === 'value'];
|
---|
50 | const shouldSkipResolution = path => skipResolutionTestFns.some(fn => fn(path));
|
---|
51 |
|
---|
52 | // =========================
|
---|
53 | // Core
|
---|
54 | // =========================
|
---|
55 |
|
---|
56 | /**
|
---|
57 | * This plugin resolves the JSON pointers.
|
---|
58 | * A major part of this plugin deals with cyclic references via 2 mechanisms.
|
---|
59 | * 1. If a pointer was already resolved before in this path, halt.
|
---|
60 | * 2. If the patch value points to one of the ancestors in this path, halt.
|
---|
61 | *
|
---|
62 | * Note that either one of these mechanism is sufficient, both must be in place.
|
---|
63 | * For examples:
|
---|
64 | *
|
---|
65 | * Given the following spec, #1 alone is insufficient because after the 2nd
|
---|
66 | * application, there will be a cyclic object reference.
|
---|
67 | * a.b.c: $ref-d
|
---|
68 | * d.e.f: $ref-a (per #1, safe to return patch as no immediate cycle)
|
---|
69 | *
|
---|
70 | * Given the following spec, #2 alone is insufficient because although there will
|
---|
71 | * never be any cyclic object reference, the plugin will keep producing patches.
|
---|
72 | * a: $ref-b
|
---|
73 | * b: $ref-a
|
---|
74 | */
|
---|
75 | const plugin = {
|
---|
76 | key: '$ref',
|
---|
77 | plugin: (ref, key, fullPath, specmap) => {
|
---|
78 | const specmapInstance = specmap.getInstance();
|
---|
79 | const parent = fullPath.slice(0, -1);
|
---|
80 | if (isFreelyNamed(parent) || shouldSkipResolution(parent)) {
|
---|
81 | return undefined;
|
---|
82 | }
|
---|
83 | const {
|
---|
84 | baseDoc
|
---|
85 | } = specmap.getContext(fullPath);
|
---|
86 | if (typeof ref !== 'string') {
|
---|
87 | return new JSONRefError('$ref: must be a string (JSON-Ref)', {
|
---|
88 | $ref: ref,
|
---|
89 | baseDoc,
|
---|
90 | fullPath
|
---|
91 | });
|
---|
92 | }
|
---|
93 | const splitString = split(ref);
|
---|
94 | const refPath = splitString[0];
|
---|
95 | const pointer = splitString[1] || '';
|
---|
96 | let basePath;
|
---|
97 | try {
|
---|
98 | basePath = baseDoc || refPath ? absoluteify(refPath, baseDoc) : null;
|
---|
99 | } catch (e) {
|
---|
100 | return wrapError(e, {
|
---|
101 | pointer,
|
---|
102 | $ref: ref,
|
---|
103 | basePath,
|
---|
104 | fullPath
|
---|
105 | });
|
---|
106 | }
|
---|
107 | let promOrVal;
|
---|
108 | let tokens;
|
---|
109 | if (pointerAlreadyInPath(pointer, basePath, parent, specmap)) {
|
---|
110 | // Cyclic reference!
|
---|
111 | // if `useCircularStructures` is not set, just leave the reference
|
---|
112 | // unresolved, but absolutify it so that we don't leave an invalid $ref
|
---|
113 | // path in the content
|
---|
114 | if (!specmapInstance.useCircularStructures) {
|
---|
115 | const absolutifiedRef = absolutifyPointer(ref, basePath);
|
---|
116 | if (ref === absolutifiedRef) {
|
---|
117 | // avoids endless looping
|
---|
118 | // without this, the ref plugin never stops seeing this $ref
|
---|
119 | return null;
|
---|
120 | }
|
---|
121 | return lib.replace(fullPath, absolutifiedRef);
|
---|
122 | }
|
---|
123 | }
|
---|
124 | if (basePath == null) {
|
---|
125 | tokens = jsonPointerToArray(pointer);
|
---|
126 | promOrVal = specmap.get(tokens);
|
---|
127 | if (typeof promOrVal === 'undefined') {
|
---|
128 | promOrVal = new JSONRefError(`Could not resolve reference: ${ref}`, {
|
---|
129 | pointer,
|
---|
130 | $ref: ref,
|
---|
131 | baseDoc,
|
---|
132 | fullPath
|
---|
133 | });
|
---|
134 | }
|
---|
135 | } else {
|
---|
136 | promOrVal = extractFromDoc(basePath, pointer);
|
---|
137 | // eslint-disable-next-line no-underscore-dangle
|
---|
138 | if (promOrVal.__value != null) {
|
---|
139 | promOrVal = promOrVal.__value; // eslint-disable-line no-underscore-dangle
|
---|
140 | } else {
|
---|
141 | promOrVal = promOrVal.catch(e => {
|
---|
142 | throw wrapError(e, {
|
---|
143 | pointer,
|
---|
144 | $ref: ref,
|
---|
145 | baseDoc,
|
---|
146 | fullPath
|
---|
147 | });
|
---|
148 | });
|
---|
149 | }
|
---|
150 | }
|
---|
151 | if (promOrVal instanceof Error) {
|
---|
152 | return [lib.remove(fullPath), promOrVal];
|
---|
153 | }
|
---|
154 | const absolutifiedRef = absolutifyPointer(ref, basePath);
|
---|
155 | const patch = lib.replace(parent, promOrVal, {
|
---|
156 | $$ref: absolutifiedRef
|
---|
157 | });
|
---|
158 | if (basePath && basePath !== baseDoc) {
|
---|
159 | return [patch, lib.context(parent, {
|
---|
160 | baseDoc: basePath
|
---|
161 | })];
|
---|
162 | }
|
---|
163 | try {
|
---|
164 | // prevents circular values from being constructed, unless we specifically
|
---|
165 | // want that to happen
|
---|
166 | if (!patchValueAlreadyInPath(specmap.state, patch) || specmapInstance.useCircularStructures) {
|
---|
167 | return patch;
|
---|
168 | }
|
---|
169 | } catch (e) {
|
---|
170 | // if we're catching here, path traversal failed, so we should
|
---|
171 | // ditch without sending any patches back up.
|
---|
172 | //
|
---|
173 | // this is a narrow fix for the larger problem of patches being queued
|
---|
174 | // and then having the state they were generated against be modified
|
---|
175 | // before they are applied.
|
---|
176 | //
|
---|
177 | // TODO: re-engineer specmap patch/state management to avoid this
|
---|
178 | return null;
|
---|
179 | }
|
---|
180 | return undefined;
|
---|
181 | }
|
---|
182 | };
|
---|
183 | const mod = Object.assign(plugin, {
|
---|
184 | docCache,
|
---|
185 | absoluteify,
|
---|
186 | clearCache,
|
---|
187 | JSONRefError,
|
---|
188 | wrapError,
|
---|
189 | getDoc,
|
---|
190 | split,
|
---|
191 | extractFromDoc,
|
---|
192 | fetchJSON,
|
---|
193 | extract,
|
---|
194 | jsonPointerToArray,
|
---|
195 | unescapeJsonPointerToken
|
---|
196 | });
|
---|
197 | export default mod;
|
---|
198 |
|
---|
199 | // =========================
|
---|
200 | // Utilities
|
---|
201 | // =========================
|
---|
202 |
|
---|
203 | /**
|
---|
204 | * Resolves a path and its base to an abolute URL.
|
---|
205 | * @api public
|
---|
206 | */
|
---|
207 | function absoluteify(path, basePath) {
|
---|
208 | if (!ABSOLUTE_URL_REGEXP.test(path)) {
|
---|
209 | if (!basePath) {
|
---|
210 | throw new JSONRefError(`Tried to resolve a relative URL, without having a basePath. path: '${path}' basePath: '${basePath}'`);
|
---|
211 | }
|
---|
212 | return url.resolve(basePath, path);
|
---|
213 | }
|
---|
214 | return path;
|
---|
215 | }
|
---|
216 |
|
---|
217 | /**
|
---|
218 | * Wraps an error as JSONRefError.
|
---|
219 | * @param {Error} e the error.
|
---|
220 | * @param {Object} extra (optional) optional data.
|
---|
221 | * @return {Error} an instance of JSONRefError.
|
---|
222 | * @api public
|
---|
223 | */
|
---|
224 | function wrapError(e, extra) {
|
---|
225 | let message;
|
---|
226 | if (e && e.response && e.response.body) {
|
---|
227 | message = `${e.response.body.code} ${e.response.body.message}`;
|
---|
228 | } else {
|
---|
229 | message = e.message;
|
---|
230 | }
|
---|
231 | return new JSONRefError(`Could not resolve reference: ${message}`, extra, e);
|
---|
232 | }
|
---|
233 |
|
---|
234 | /**
|
---|
235 | * Splits a pointer by the hash delimiter.
|
---|
236 | * @api public
|
---|
237 | */
|
---|
238 | function split(ref) {
|
---|
239 | return (ref + '').split('#'); // eslint-disable-line prefer-template
|
---|
240 | }
|
---|
241 |
|
---|
242 | /**
|
---|
243 | * Extracts a pointer from its document.
|
---|
244 | * @param {String} docPath the absolute document URL.
|
---|
245 | * @param {String} pointer the pointer whose value is to be extracted.
|
---|
246 | * @return {Promise} a promise of the pointer value.
|
---|
247 | * @api public
|
---|
248 | */
|
---|
249 | function extractFromDoc(docPath, pointer) {
|
---|
250 | const doc = docCache[docPath];
|
---|
251 | if (doc && !lib.isPromise(doc)) {
|
---|
252 | // If doc is already available, return __value together with the promise.
|
---|
253 | // __value is for special handling in cycle check:
|
---|
254 | // pointerAlreadyInPath() won't work if patch.value is a promise,
|
---|
255 | // thus when that promise is finally resolved, cycle might happen (because
|
---|
256 | // `spec` and `docCache[basePath]` refer to the exact same object).
|
---|
257 | // See test "should resolve a cyclic spec when baseDoc is specified".
|
---|
258 | try {
|
---|
259 | const v = extract(pointer, doc);
|
---|
260 | return Object.assign(Promise.resolve(v), {
|
---|
261 | __value: v
|
---|
262 | });
|
---|
263 | } catch (e) {
|
---|
264 | return Promise.reject(e);
|
---|
265 | }
|
---|
266 | }
|
---|
267 | return getDoc(docPath).then(_doc => extract(pointer, _doc));
|
---|
268 | }
|
---|
269 |
|
---|
270 | /**
|
---|
271 | * Clears all document caches.
|
---|
272 | * @param {String} item (optional) the name of the cache item to be cleared.
|
---|
273 | * @api public
|
---|
274 | */
|
---|
275 | function clearCache(item) {
|
---|
276 | if (typeof item !== 'undefined') {
|
---|
277 | delete docCache[item];
|
---|
278 | } else {
|
---|
279 | Object.keys(docCache).forEach(key => {
|
---|
280 | delete docCache[key];
|
---|
281 | });
|
---|
282 | }
|
---|
283 | }
|
---|
284 |
|
---|
285 | /**
|
---|
286 | * Fetches and caches a document.
|
---|
287 | * @param {String} docPath the absolute URL of the document.
|
---|
288 | * @return {Promise} a promise of the document content.
|
---|
289 | * @api public
|
---|
290 | */
|
---|
291 | function getDoc(docPath) {
|
---|
292 | const val = docCache[docPath];
|
---|
293 | if (val) {
|
---|
294 | return lib.isPromise(val) ? val : Promise.resolve(val);
|
---|
295 | }
|
---|
296 |
|
---|
297 | // NOTE: we need to use `mod.fetchJSON` in order to be able to overwrite it.
|
---|
298 | // Any tips on how to make this cleaner, please ping!
|
---|
299 | docCache[docPath] = mod.fetchJSON(docPath).then(doc => {
|
---|
300 | docCache[docPath] = doc;
|
---|
301 | return doc;
|
---|
302 | });
|
---|
303 | return docCache[docPath];
|
---|
304 | }
|
---|
305 |
|
---|
306 | /**
|
---|
307 | * Fetches a document.
|
---|
308 | * @param {String} docPath the absolute URL of the document.
|
---|
309 | * @return {Promise} a promise of the document content.
|
---|
310 | * @api public
|
---|
311 | */
|
---|
312 | function fetchJSON(docPath) {
|
---|
313 | return fetch(docPath, {
|
---|
314 | headers: {
|
---|
315 | Accept: ACCEPT_HEADER_VALUE_FOR_DOCUMENTS
|
---|
316 | },
|
---|
317 | loadSpec: true
|
---|
318 | }).then(res => res.text()).then(text => jsYaml.load(text));
|
---|
319 | }
|
---|
320 |
|
---|
321 | /**
|
---|
322 | * Extracts a pointer from an object.
|
---|
323 | * @param {String[]} pointer the JSON pointer.
|
---|
324 | * @param {Object} obj an object whose value is to be extracted.
|
---|
325 | * @return {Object} the value to be extracted.
|
---|
326 | * @api public
|
---|
327 | */
|
---|
328 | function extract(pointer, obj) {
|
---|
329 | const tokens = jsonPointerToArray(pointer);
|
---|
330 | if (tokens.length < 1) {
|
---|
331 | return obj;
|
---|
332 | }
|
---|
333 | const val = lib.getIn(obj, tokens);
|
---|
334 | if (typeof val === 'undefined') {
|
---|
335 | throw new JSONRefError(`Could not resolve pointer: ${pointer} does not exist in document`, {
|
---|
336 | pointer
|
---|
337 | });
|
---|
338 | }
|
---|
339 | return val;
|
---|
340 | }
|
---|
341 |
|
---|
342 | /**
|
---|
343 | * Converts a JSON pointer to array.
|
---|
344 | * @api public
|
---|
345 | */
|
---|
346 | function jsonPointerToArray(pointer) {
|
---|
347 | if (typeof pointer !== 'string') {
|
---|
348 | throw new TypeError(`Expected a string, got a ${typeof pointer}`);
|
---|
349 | }
|
---|
350 | if (pointer[0] === '/') {
|
---|
351 | pointer = pointer.substr(1);
|
---|
352 | }
|
---|
353 | if (pointer === '') {
|
---|
354 | return [];
|
---|
355 | }
|
---|
356 | return pointer.split('/').map(unescapeJsonPointerToken);
|
---|
357 | }
|
---|
358 |
|
---|
359 | /**
|
---|
360 | * Unescapes a JSON pointer.
|
---|
361 | * @api public
|
---|
362 | */
|
---|
363 | function unescapeJsonPointerToken(token) {
|
---|
364 | if (typeof token !== 'string') {
|
---|
365 | return token;
|
---|
366 | }
|
---|
367 | const params = new URLSearchParams(`=${token.replace(/~1/g, '/').replace(/~0/g, '~')}`);
|
---|
368 | return params.get('');
|
---|
369 | }
|
---|
370 |
|
---|
371 | /**
|
---|
372 | * Escapes a JSON pointer.
|
---|
373 | * @api public
|
---|
374 | */
|
---|
375 | function escapeJsonPointerToken(token) {
|
---|
376 | const params = new URLSearchParams([['', token.replace(/~/g, '~0').replace(/\//g, '~1')]]);
|
---|
377 | return params.toString().slice(1);
|
---|
378 | }
|
---|
379 | function arrayToJsonPointer(arr) {
|
---|
380 | if (arr.length === 0) {
|
---|
381 | return '';
|
---|
382 | }
|
---|
383 | return `/${arr.map(escapeJsonPointerToken).join('/')}`;
|
---|
384 | }
|
---|
385 | const pointerBoundaryChar = c => !c || c === '/' || c === '#';
|
---|
386 | function pointerIsAParent(pointer, parentPointer) {
|
---|
387 | if (pointerBoundaryChar(parentPointer)) {
|
---|
388 | // This is the root of the document, so its naturally a parent
|
---|
389 | return true;
|
---|
390 | }
|
---|
391 | const nextChar = pointer.charAt(parentPointer.length);
|
---|
392 | const lastParentChar = parentPointer.slice(-1);
|
---|
393 | return pointer.indexOf(parentPointer) === 0 && (!nextChar || nextChar === '/' || nextChar === '#') && lastParentChar !== '#';
|
---|
394 | }
|
---|
395 |
|
---|
396 | // =========================
|
---|
397 | // Private
|
---|
398 | // =========================
|
---|
399 |
|
---|
400 | /**
|
---|
401 | * Checks if this pointer points back to one or more pointers along the path.
|
---|
402 | */
|
---|
403 | function pointerAlreadyInPath(pointer, basePath, parent, specmap) {
|
---|
404 | let refs = specmapRefs.get(specmap);
|
---|
405 | if (!refs) {
|
---|
406 | // Stores all resolved references of a specmap instance.
|
---|
407 | // Schema: path -> pointer (path's $ref value).
|
---|
408 | refs = {};
|
---|
409 | specmapRefs.set(specmap, refs);
|
---|
410 | }
|
---|
411 | const parentPointer = arrayToJsonPointer(parent);
|
---|
412 | const fullyQualifiedPointer = `${basePath || '<specmap-base>'}#${pointer}`;
|
---|
413 |
|
---|
414 | // dirty hack to strip `allof/[index]` from the path, in order to avoid cases
|
---|
415 | // where we get false negatives because:
|
---|
416 | // - we resolve a path, then
|
---|
417 | // - allOf plugin collapsed `allOf/[index]` out of the path, then
|
---|
418 | // - we try to work on a child $ref within that collapsed path.
|
---|
419 | //
|
---|
420 | // because of the path collapse, we lose track of it in our specmapRefs hash
|
---|
421 | // solution: always throw the allOf constructs out of paths we store
|
---|
422 | // TODO: solve this with a global register, or by writing more metadata in
|
---|
423 | // either allOf or refs plugin
|
---|
424 | const safeParentPointer = parentPointer.replace(/allOf\/\d+\/?/g, '');
|
---|
425 |
|
---|
426 | // Case 1: direct cycle, e.g. a.b.c.$ref: '/a.b'
|
---|
427 | // Detect by checking that the parent path doesn't start with pointer.
|
---|
428 | // This only applies if the pointer is internal, i.e. basePath === rootPath (could be null)
|
---|
429 | const rootDoc = specmap.contextTree.get([]).baseDoc;
|
---|
430 | if (basePath === rootDoc && pointerIsAParent(safeParentPointer, pointer)) {
|
---|
431 | // eslint-disable-line
|
---|
432 | return true;
|
---|
433 | }
|
---|
434 |
|
---|
435 | // Case 2: indirect cycle
|
---|
436 | // ex1: a.$ref: '/b' & b.c.$ref: '/b/c'
|
---|
437 | // ex2: a.$ref: '/b/c' & b.c.$ref: '/b'
|
---|
438 | // Detect by retrieving all the $refs along the path of parent
|
---|
439 | // and checking if any starts with pointer or vice versa.
|
---|
440 | let currPath = '';
|
---|
441 | const hasIndirectCycle = parent.some(token => {
|
---|
442 | currPath = `${currPath}/${escapeJsonPointerToken(token)}`;
|
---|
443 | return refs[currPath] && refs[currPath].some(ref => pointerIsAParent(ref, fullyQualifiedPointer) || pointerIsAParent(fullyQualifiedPointer, ref));
|
---|
444 | });
|
---|
445 | if (hasIndirectCycle) {
|
---|
446 | return true;
|
---|
447 | }
|
---|
448 |
|
---|
449 | // No cycle, this ref will be resolved, so stores it now for future detection.
|
---|
450 | // No need to store if has cycle, as parent path is a dead-end and won't be checked again.
|
---|
451 |
|
---|
452 | refs[safeParentPointer] = (refs[safeParentPointer] || []).concat(fullyQualifiedPointer);
|
---|
453 | return undefined;
|
---|
454 | }
|
---|
455 |
|
---|
456 | /**
|
---|
457 | * Checks if the value of this patch ends up pointing to an ancestor along the path.
|
---|
458 | */
|
---|
459 | function patchValueAlreadyInPath(root, patch) {
|
---|
460 | const ancestors = [root];
|
---|
461 | patch.path.reduce((parent, p) => {
|
---|
462 | ancestors.push(parent[p]);
|
---|
463 | return parent[p];
|
---|
464 | }, root);
|
---|
465 | return pointToAncestor(patch.value);
|
---|
466 | function pointToAncestor(obj) {
|
---|
467 | return lib.isObject(obj) && (ancestors.indexOf(obj) >= 0 || Object.keys(obj).some(k => pointToAncestor(obj[k])));
|
---|
468 | }
|
---|
469 | } |
---|