fork of hey-api/openapi-ts because I need some additional things
at feat/skip-token 352 lines 10 kB view raw
1import type { ParserOptions } from './options'; 2import $Ref from './ref'; 3import type { JSONSchema } from './types'; 4import { 5 InvalidPointerError, 6 isHandledError, 7 JSONParserError, 8 MissingPointerError, 9} from './util/errors'; 10import * as url from './util/url'; 11 12const slashes = /\//g; 13const tildes = /~/g; 14const escapedSlash = /~1/g; 15const escapedTilde = /~0/g; 16 17const safeDecodeURIComponent = (encodedURIComponent: string): string => { 18 try { 19 return decodeURIComponent(encodedURIComponent); 20 } catch { 21 return encodedURIComponent; 22 } 23}; 24 25/** 26 * This class represents a single JSON pointer and its resolved value. 27 * 28 * @param $ref 29 * @param path 30 * @param [friendlyPath] - The original user-specified path (used for error messages) 31 * @class 32 */ 33class Pointer<S extends object = JSONSchema> { 34 /** 35 * The {@link $Ref} object that contains this {@link Pointer} object. 36 */ 37 $ref: $Ref<S>; 38 39 /** 40 * The file path or URL, containing the JSON pointer in the hash. 41 * This path is relative to the path of the main JSON schema file. 42 */ 43 path: string; 44 45 /** 46 * The original path or URL, used for error messages. 47 */ 48 originalPath: string; 49 50 /** 51 * The value of the JSON pointer. 52 * Can be any JSON type, not just objects. Unknown file types are represented as Buffers (byte arrays). 53 */ 54 55 value: any; 56 /** 57 * Indicates whether the pointer references itself. 58 */ 59 circular: boolean; 60 /** 61 * The number of indirect references that were traversed to resolve the value. 62 * Resolving a single pointer may require resolving multiple $Refs. 63 */ 64 indirections: number; 65 66 constructor($ref: $Ref<S>, path: string, friendlyPath?: string) { 67 this.$ref = $ref; 68 69 this.path = path; 70 71 this.originalPath = friendlyPath || path; 72 73 this.value = undefined; 74 75 this.circular = false; 76 77 this.indirections = 0; 78 } 79 80 /** 81 * Resolves the value of a nested property within the given object. 82 * 83 * @param obj - The object that will be crawled 84 * @param options 85 * @param pathFromRoot - the path of place that initiated resolving 86 * 87 * @returns 88 * Returns a JSON pointer whose {@link Pointer#value} is the resolved value. 89 * If resolving this value required resolving other JSON references, then 90 * the {@link Pointer#$ref} and {@link Pointer#path} will reflect the resolution path 91 * of the resolved value. 92 */ 93 resolve(obj: S, options?: ParserOptions, pathFromRoot?: string) { 94 const tokens = Pointer.parse(this.path, this.originalPath); 95 96 // Crawl the object, one token at a time 97 this.value = unwrapOrThrow(obj); 98 99 const errors: MissingPointerError[] = []; 100 101 for (let i = 0; i < tokens.length; i++) { 102 if (resolveIf$Ref(this, options, pathFromRoot)) { 103 // The $ref path has changed, so append the remaining tokens to the path 104 this.path = Pointer.join(this.path, tokens.slice(i)); 105 } 106 107 if ( 108 typeof this.value === 'object' && 109 this.value !== null && 110 !isRootPath(pathFromRoot) && 111 '$ref' in this.value 112 ) { 113 return this; 114 } 115 116 const token = tokens[i]!; 117 if ( 118 this.value[token] === undefined || 119 (this.value[token] === null && i === tokens.length - 1) 120 ) { 121 // one final case is if the entry itself includes slashes, and was parsed out as a token - we can join the remaining tokens and try again 122 let didFindSubstringSlashMatch = false; 123 for (let j = tokens.length - 1; j > i; j--) { 124 const joinedToken = tokens.slice(i, j + 1).join('/'); 125 if (this.value[joinedToken] !== undefined) { 126 this.value = this.value[joinedToken]; 127 i = j; 128 didFindSubstringSlashMatch = true; 129 break; 130 } 131 } 132 if (didFindSubstringSlashMatch) { 133 continue; 134 } 135 136 this.value = null; 137 errors.push(new MissingPointerError(token, decodeURI(this.originalPath))); 138 } else { 139 this.value = this.value[token]; 140 } 141 } 142 143 if (errors.length > 0) { 144 throw errors.length === 1 145 ? errors[0] 146 : new AggregateError(errors, 'Multiple missing pointer errors'); 147 } 148 149 // Resolve the final value 150 if ( 151 !this.value || 152 (this.value.$ref && url.resolve(this.path, this.value.$ref) !== pathFromRoot) 153 ) { 154 resolveIf$Ref(this, options, pathFromRoot); 155 } 156 157 return this; 158 } 159 160 /** 161 * Sets the value of a nested property within the given object. 162 * 163 * @param obj - The object that will be crawled 164 * @param value - the value to assign 165 * @param options 166 * 167 * @returns 168 * Returns the modified object, or an entirely new object if the entire object is overwritten. 169 */ 170 set(obj: S, value: any, options?: ParserOptions) { 171 const tokens = Pointer.parse(this.path); 172 let token; 173 174 if (tokens.length === 0) { 175 // There are no tokens, replace the entire object with the new value 176 this.value = value; 177 return value; 178 } 179 180 // Crawl the object, one token at a time 181 this.value = unwrapOrThrow(obj); 182 183 for (let i = 0; i < tokens.length - 1; i++) { 184 resolveIf$Ref(this, options); 185 186 token = tokens[i]!; 187 if (this.value && this.value[token] !== undefined) { 188 // The token exists 189 this.value = this.value[token]; 190 } else { 191 // The token doesn't exist, so create it 192 this.value = setValue(this, token, {}); 193 } 194 } 195 196 // Set the value of the final token 197 resolveIf$Ref(this, options); 198 token = tokens[tokens.length - 1]; 199 setValue(this, token, value); 200 201 // Return the updated object 202 return obj; 203 } 204 205 /** 206 * Parses a JSON pointer (or a path containing a JSON pointer in the hash) 207 * and returns an array of the pointer's tokens. 208 * (e.g. "schema.json#/definitions/person/name" => ["definitions", "person", "name"]) 209 * 210 * The pointer is parsed according to RFC 6901 211 * {@link https://tools.ietf.org/html/rfc6901#section-3} 212 * 213 * @param path 214 * @param [originalPath] 215 * @returns 216 */ 217 static parse(path: string, originalPath?: string): string[] { 218 // Get the JSON pointer from the path's hash 219 const pointer = url.getHash(path).substring(1); 220 221 // If there's no pointer, then there are no tokens, 222 // so return an empty array 223 if (!pointer) { 224 return []; 225 } 226 227 // Split into an array 228 const split = pointer.split('/'); 229 230 // Decode each part, according to RFC 6901 231 for (let i = 0; i < split.length; i++) { 232 split[i] = safeDecodeURIComponent( 233 split[i]!.replace(escapedSlash, '/').replace(escapedTilde, '~'), 234 ); 235 } 236 237 if (split[0] !== '') { 238 throw new InvalidPointerError(pointer, originalPath === undefined ? path : originalPath); 239 } 240 241 return split.slice(1); 242 } 243 244 /** 245 * Creates a JSON pointer path, by joining one or more tokens to a base path. 246 * 247 * @param base - The base path (e.g. "schema.json#/definitions/person") 248 * @param tokens - The token(s) to append (e.g. ["name", "first"]) 249 * @returns 250 */ 251 static join(base: string, tokens: string | string[]) { 252 // Ensure that the base path contains a hash 253 if (base.indexOf('#') === -1) { 254 base += '#'; 255 } 256 257 // Append each token to the base path 258 tokens = Array.isArray(tokens) ? tokens : [tokens]; 259 for (let i = 0; i < tokens.length; i++) { 260 const token = tokens[i]!; 261 // Encode the token, according to RFC 6901 262 base += '/' + encodeURIComponent(token.replace(tildes, '~0').replace(slashes, '~1')); 263 } 264 265 return base; 266 } 267} 268 269/** 270 * If the given pointer's {@link Pointer#value} is a JSON reference, 271 * then the reference is resolved and {@link Pointer#value} is replaced with the resolved value. 272 * In addition, {@link Pointer#path} and {@link Pointer#$ref} are updated to reflect the 273 * resolution path of the new value. 274 * 275 * @param pointer 276 * @param options 277 * @param [pathFromRoot] - the path of place that initiated resolving 278 * @returns - Returns `true` if the resolution path changed 279 */ 280function resolveIf$Ref(pointer: any, options: any, pathFromRoot?: any) { 281 // Is the value a JSON reference? (and allowed?) 282 283 if ($Ref.isAllowed$Ref(pointer.value)) { 284 const $refPath = url.resolve(pointer.path, pointer.value.$ref); 285 286 if ($refPath === pointer.path && !isRootPath(pathFromRoot)) { 287 // The value is a reference to itself, so there's nothing to do. 288 pointer.circular = true; 289 } else { 290 const resolved = pointer.$ref.$refs._resolve($refPath, pointer.path, options); 291 if (resolved === null) { 292 return false; 293 } 294 295 pointer.indirections += resolved.indirections + 1; 296 297 if ($Ref.isExtended$Ref(pointer.value)) { 298 // This JSON reference "extends" the resolved value, rather than simply pointing to it. 299 // So the resolved path does NOT change. Just the value does. 300 pointer.value = $Ref.dereference(pointer.value, resolved.value); 301 return false; 302 } else { 303 // Resolve the reference 304 pointer.$ref = resolved.$ref; 305 pointer.path = resolved.path; 306 pointer.value = resolved.value; 307 } 308 309 return true; 310 } 311 } 312 return undefined; 313} 314export default Pointer; 315 316/** 317 * Sets the specified token value of the {@link Pointer#value}. 318 * 319 * The token is evaluated according to RFC 6901. 320 * {@link https://tools.ietf.org/html/rfc6901#section-4} 321 * 322 * @param pointer - The JSON Pointer whose value will be modified 323 * @param token - A JSON Pointer token that indicates how to modify `obj` 324 * @param value - The value to assign 325 * @returns - Returns the assigned value 326 */ 327function setValue(pointer: any, token: any, value: any) { 328 if (pointer.value && typeof pointer.value === 'object') { 329 if (token === '-' && Array.isArray(pointer.value)) { 330 pointer.value.push(value); 331 } else { 332 pointer.value[token] = value; 333 } 334 } else { 335 throw new JSONParserError( 336 `Error assigning $ref pointer "${pointer.path}". \nCannot set "${token}" of a non-object.`, 337 ); 338 } 339 return value; 340} 341 342function unwrapOrThrow(value: any) { 343 if (isHandledError(value)) { 344 throw value; 345 } 346 347 return value; 348} 349 350function isRootPath(pathFromRoot: any): boolean { 351 return typeof pathFromRoot == 'string' && Pointer.parse(pathFromRoot).length == 0; 352}