[d24f17c] | 1 | import qs from 'qs';
|
---|
| 2 | import jsYaml from 'js-yaml';
|
---|
| 3 | import '../helpers/fetch-polyfill.node.js';
|
---|
| 4 | import { encodeDisallowedCharacters } from '../execute/oas3/style-serializer.js';
|
---|
| 5 |
|
---|
| 6 | // For testing
|
---|
| 7 | export const self = {
|
---|
| 8 | serializeRes,
|
---|
| 9 | mergeInQueryOrForm
|
---|
| 10 | };
|
---|
| 11 |
|
---|
| 12 | // Handles fetch-like syntax and the case where there is only one object passed-in
|
---|
| 13 | // (which will have the URL as a property). Also serializes the response.
|
---|
| 14 | export default async function http(url, request = {}) {
|
---|
| 15 | if (typeof url === 'object') {
|
---|
| 16 | request = url;
|
---|
| 17 | url = request.url;
|
---|
| 18 | }
|
---|
| 19 | request.headers = request.headers || {};
|
---|
| 20 |
|
---|
| 21 | // Serializes query, for convenience
|
---|
| 22 | // Should be the last thing we do, as its hard to mutate the URL with
|
---|
| 23 | // the search string, but much easier to manipulate the req.query object
|
---|
| 24 | self.mergeInQueryOrForm(request);
|
---|
| 25 |
|
---|
| 26 | // Newlines in header values cause weird error messages from `window.fetch`,
|
---|
| 27 | // so let's message them out.
|
---|
| 28 | // Context: https://stackoverflow.com/a/50709178
|
---|
| 29 | if (request.headers) {
|
---|
| 30 | Object.keys(request.headers).forEach(headerName => {
|
---|
| 31 | const value = request.headers[headerName];
|
---|
| 32 | if (typeof value === 'string') {
|
---|
| 33 | request.headers[headerName] = value.replace(/\n+/g, ' ');
|
---|
| 34 | }
|
---|
| 35 | });
|
---|
| 36 | }
|
---|
| 37 |
|
---|
| 38 | // Wait for the request interceptor, if it was provided
|
---|
| 39 | // WARNING: don't put anything between this and the request firing unless
|
---|
| 40 | // you have a good reason!
|
---|
| 41 | if (request.requestInterceptor) {
|
---|
| 42 | request = (await request.requestInterceptor(request)) || request;
|
---|
| 43 | }
|
---|
| 44 |
|
---|
| 45 | /**
|
---|
| 46 | * For content-type=multipart/form-data remove content-type from request before fetch,
|
---|
| 47 | * so that correct one with `boundary` is set when request body is different from boundary encoded string.
|
---|
| 48 | */
|
---|
| 49 | const contentType = request.headers['content-type'] || request.headers['Content-Type'];
|
---|
| 50 | if (/multipart\/form-data/i.test(contentType)) {
|
---|
| 51 | delete request.headers['content-type'];
|
---|
| 52 | delete request.headers['Content-Type'];
|
---|
| 53 | }
|
---|
| 54 |
|
---|
| 55 | // eslint-disable-next-line no-undef
|
---|
| 56 | let res;
|
---|
| 57 | try {
|
---|
| 58 | res = await (request.userFetch || fetch)(request.url, request);
|
---|
| 59 | res = await self.serializeRes(res, url, request);
|
---|
| 60 | if (request.responseInterceptor) {
|
---|
| 61 | res = (await request.responseInterceptor(res)) || res;
|
---|
| 62 | }
|
---|
| 63 | } catch (resError) {
|
---|
| 64 | if (!res) {
|
---|
| 65 | // res is completely absent, so we can't construct our own error
|
---|
| 66 | // so we'll just throw the error we got
|
---|
| 67 | throw resError;
|
---|
| 68 | }
|
---|
| 69 | const error = new Error(res.statusText || `response status is ${res.status}`);
|
---|
| 70 | error.status = res.status;
|
---|
| 71 | error.statusCode = res.status;
|
---|
| 72 | error.responseError = resError;
|
---|
| 73 | throw error;
|
---|
| 74 | }
|
---|
| 75 | if (!res.ok) {
|
---|
| 76 | const error = new Error(res.statusText || `response status is ${res.status}`);
|
---|
| 77 | error.status = res.status;
|
---|
| 78 | error.statusCode = res.status;
|
---|
| 79 | error.response = res;
|
---|
| 80 | throw error;
|
---|
| 81 | }
|
---|
| 82 | return res;
|
---|
| 83 | }
|
---|
| 84 |
|
---|
| 85 | // exported for testing
|
---|
| 86 | export const shouldDownloadAsText = (contentType = '') => /(json|xml|yaml|text)\b/.test(contentType);
|
---|
| 87 | function parseBody(body, contentType) {
|
---|
| 88 | if (contentType && (contentType.indexOf('application/json') === 0 || contentType.indexOf('+json') > 0)) {
|
---|
| 89 | return JSON.parse(body);
|
---|
| 90 | }
|
---|
| 91 | return jsYaml.load(body);
|
---|
| 92 | }
|
---|
| 93 |
|
---|
| 94 | // Serialize the response, returns a promise with headers and the body part of the hash
|
---|
| 95 | export function serializeRes(oriRes, url, {
|
---|
| 96 | loadSpec = false
|
---|
| 97 | } = {}) {
|
---|
| 98 | const res = {
|
---|
| 99 | ok: oriRes.ok,
|
---|
| 100 | url: oriRes.url || url,
|
---|
| 101 | status: oriRes.status,
|
---|
| 102 | statusText: oriRes.statusText,
|
---|
| 103 | headers: serializeHeaders(oriRes.headers)
|
---|
| 104 | };
|
---|
| 105 | const contentType = res.headers['content-type'];
|
---|
| 106 | const useText = loadSpec || shouldDownloadAsText(contentType);
|
---|
| 107 | const getBody = useText ? oriRes.text : oriRes.blob || oriRes.buffer;
|
---|
| 108 | return getBody.call(oriRes).then(body => {
|
---|
| 109 | res.text = body;
|
---|
| 110 | res.data = body;
|
---|
| 111 | if (useText) {
|
---|
| 112 | try {
|
---|
| 113 | const obj = parseBody(body, contentType);
|
---|
| 114 | res.body = obj;
|
---|
| 115 | res.obj = obj;
|
---|
| 116 | } catch (e) {
|
---|
| 117 | res.parseError = e;
|
---|
| 118 | }
|
---|
| 119 | }
|
---|
| 120 | return res;
|
---|
| 121 | });
|
---|
| 122 | }
|
---|
| 123 | function serializeHeaderValue(value) {
|
---|
| 124 | const isMulti = value.includes(', ');
|
---|
| 125 | return isMulti ? value.split(', ') : value;
|
---|
| 126 | }
|
---|
| 127 |
|
---|
| 128 | // Serialize headers into a hash, where mutliple-headers result in an array.
|
---|
| 129 | //
|
---|
| 130 | // eg: Cookie: one
|
---|
| 131 | // Cookie: two
|
---|
| 132 | // = { Cookie: [ "one", "two" ]
|
---|
| 133 | export function serializeHeaders(headers = {}) {
|
---|
| 134 | if (typeof headers.entries !== 'function') return {};
|
---|
| 135 | return Array.from(headers.entries()).reduce((acc, [header, value]) => {
|
---|
| 136 | acc[header] = serializeHeaderValue(value);
|
---|
| 137 | return acc;
|
---|
| 138 | }, {});
|
---|
| 139 | }
|
---|
| 140 | export function isFile(obj, navigatorObj) {
|
---|
| 141 | if (!navigatorObj && typeof navigator !== 'undefined') {
|
---|
| 142 | // eslint-disable-next-line no-undef
|
---|
| 143 | navigatorObj = navigator;
|
---|
| 144 | }
|
---|
| 145 | if (navigatorObj && navigatorObj.product === 'ReactNative') {
|
---|
| 146 | if (obj && typeof obj === 'object' && typeof obj.uri === 'string') {
|
---|
| 147 | return true;
|
---|
| 148 | }
|
---|
| 149 | return false;
|
---|
| 150 | }
|
---|
| 151 | if (typeof File !== 'undefined' && obj instanceof File) {
|
---|
| 152 | return true;
|
---|
| 153 | }
|
---|
| 154 | if (typeof Blob !== 'undefined' && obj instanceof Blob) {
|
---|
| 155 | return true;
|
---|
| 156 | }
|
---|
| 157 | if (ArrayBuffer.isView(obj)) {
|
---|
| 158 | return true;
|
---|
| 159 | }
|
---|
| 160 | return obj !== null && typeof obj === 'object' && typeof obj.pipe === 'function';
|
---|
| 161 | }
|
---|
| 162 | function isArrayOfFile(obj, navigatorObj) {
|
---|
| 163 | return Array.isArray(obj) && obj.some(v => isFile(v, navigatorObj));
|
---|
| 164 | }
|
---|
| 165 | const STYLE_SEPARATORS = {
|
---|
| 166 | form: ',',
|
---|
| 167 | spaceDelimited: '%20',
|
---|
| 168 | pipeDelimited: '|'
|
---|
| 169 | };
|
---|
| 170 | const SEPARATORS = {
|
---|
| 171 | csv: ',',
|
---|
| 172 | ssv: '%20',
|
---|
| 173 | tsv: '%09',
|
---|
| 174 | pipes: '|'
|
---|
| 175 | };
|
---|
| 176 |
|
---|
| 177 | /**
|
---|
| 178 | * Specialized sub-class of File class, that only
|
---|
| 179 | * accepts string data and retain this data in `data`
|
---|
| 180 | * public property throughout the lifecycle of its instances.
|
---|
| 181 | *
|
---|
| 182 | * This sub-class is exclusively used only when Encoding Object
|
---|
| 183 | * is defined within the Media Type Object (OpenAPI 3.x.y).
|
---|
| 184 | */
|
---|
| 185 | class FileWithData extends File {
|
---|
| 186 | constructor(data, name = '', options = {}) {
|
---|
| 187 | super([data], name, options);
|
---|
| 188 | this.data = data;
|
---|
| 189 | }
|
---|
| 190 | valueOf() {
|
---|
| 191 | return this.data;
|
---|
| 192 | }
|
---|
| 193 | toString() {
|
---|
| 194 | return this.valueOf();
|
---|
| 195 | }
|
---|
| 196 | }
|
---|
| 197 |
|
---|
| 198 | // Formats a key-value and returns an array of key-value pairs.
|
---|
| 199 | //
|
---|
| 200 | // Return value example 1: [['color', 'blue']]
|
---|
| 201 | // Return value example 2: [['color', 'blue,black,brown']]
|
---|
| 202 | // Return value example 3: [['color', ['blue', 'black', 'brown']]]
|
---|
| 203 | // Return value example 4: [['color', 'R,100,G,200,B,150']]
|
---|
| 204 | // Return value example 5: [['R', '100'], ['G', '200'], ['B', '150']]
|
---|
| 205 | // Return value example 6: [['color[R]', '100'], ['color[G]', '200'], ['color[B]', '150']]
|
---|
| 206 | function formatKeyValue(key, input, skipEncoding = false) {
|
---|
| 207 | const {
|
---|
| 208 | collectionFormat,
|
---|
| 209 | allowEmptyValue,
|
---|
| 210 | serializationOption,
|
---|
| 211 | encoding
|
---|
| 212 | } = input;
|
---|
| 213 | // `input` can be string
|
---|
| 214 | const value = typeof input === 'object' && !Array.isArray(input) ? input.value : input;
|
---|
| 215 | const encodeFn = skipEncoding ? k => k.toString() : k => encodeURIComponent(k);
|
---|
| 216 | const encodedKey = encodeFn(key);
|
---|
| 217 | if (typeof value === 'undefined' && allowEmptyValue) {
|
---|
| 218 | return [[encodedKey, '']];
|
---|
| 219 | }
|
---|
| 220 |
|
---|
| 221 | // file
|
---|
| 222 | if (isFile(value) || isArrayOfFile(value)) {
|
---|
| 223 | return [[encodedKey, value]];
|
---|
| 224 | }
|
---|
| 225 |
|
---|
| 226 | // for OAS 3 Parameter Object for serialization
|
---|
| 227 | if (serializationOption) {
|
---|
| 228 | return formatKeyValueBySerializationOption(key, value, skipEncoding, serializationOption);
|
---|
| 229 | }
|
---|
| 230 |
|
---|
| 231 | // for OAS 3 Encoding Object
|
---|
| 232 | if (encoding) {
|
---|
| 233 | if ([typeof encoding.style, typeof encoding.explode, typeof encoding.allowReserved].some(type => type !== 'undefined')) {
|
---|
| 234 | const {
|
---|
| 235 | style,
|
---|
| 236 | explode,
|
---|
| 237 | allowReserved
|
---|
| 238 | } = encoding;
|
---|
| 239 | return formatKeyValueBySerializationOption(key, value, skipEncoding, {
|
---|
| 240 | style,
|
---|
| 241 | explode,
|
---|
| 242 | allowReserved
|
---|
| 243 | });
|
---|
| 244 | }
|
---|
| 245 | if (typeof encoding.contentType === 'string') {
|
---|
| 246 | if (encoding.contentType.startsWith('application/json')) {
|
---|
| 247 | // if value is a string, assume value is already a JSON string
|
---|
| 248 | const json = typeof value === 'string' ? value : JSON.stringify(value);
|
---|
| 249 | const encodedJson = encodeFn(json);
|
---|
| 250 | const file = new FileWithData(encodedJson, 'blob', {
|
---|
| 251 | type: encoding.contentType
|
---|
| 252 | });
|
---|
| 253 | return [[encodedKey, file]];
|
---|
| 254 | }
|
---|
| 255 | const encodedData = encodeFn(String(value));
|
---|
| 256 | const blob = new FileWithData(encodedData, 'blob', {
|
---|
| 257 | type: encoding.contentType
|
---|
| 258 | });
|
---|
| 259 | return [[encodedKey, blob]];
|
---|
| 260 | }
|
---|
| 261 |
|
---|
| 262 | // Primitive
|
---|
| 263 | if (typeof value !== 'object') {
|
---|
| 264 | return [[encodedKey, encodeFn(value)]];
|
---|
| 265 | }
|
---|
| 266 |
|
---|
| 267 | // Array of primitives
|
---|
| 268 | if (Array.isArray(value) && value.every(v => typeof v !== 'object')) {
|
---|
| 269 | return [[encodedKey, value.map(encodeFn).join(',')]];
|
---|
| 270 | }
|
---|
| 271 |
|
---|
| 272 | // Array or object
|
---|
| 273 | return [[encodedKey, encodeFn(JSON.stringify(value))]];
|
---|
| 274 | }
|
---|
| 275 |
|
---|
| 276 | // for OAS 2 Parameter Object
|
---|
| 277 | // Primitive
|
---|
| 278 | if (typeof value !== 'object') {
|
---|
| 279 | return [[encodedKey, encodeFn(value)]];
|
---|
| 280 | }
|
---|
| 281 |
|
---|
| 282 | // Array
|
---|
| 283 | if (Array.isArray(value)) {
|
---|
| 284 | if (collectionFormat === 'multi') {
|
---|
| 285 | // In case of multipart/formdata, it is used as array.
|
---|
| 286 | // Otherwise, the caller will convert it to a query by qs.stringify.
|
---|
| 287 | return [[encodedKey, value.map(encodeFn)]];
|
---|
| 288 | }
|
---|
| 289 | return [[encodedKey, value.map(encodeFn).join(SEPARATORS[collectionFormat || 'csv'])]];
|
---|
| 290 | }
|
---|
| 291 |
|
---|
| 292 | // Object
|
---|
| 293 | return [[encodedKey, '']];
|
---|
| 294 | }
|
---|
| 295 | function formatKeyValueBySerializationOption(key, value, skipEncoding, serializationOption) {
|
---|
| 296 | const style = serializationOption.style || 'form';
|
---|
| 297 | const explode = typeof serializationOption.explode === 'undefined' ? style === 'form' : serializationOption.explode;
|
---|
| 298 | // eslint-disable-next-line no-nested-ternary
|
---|
| 299 | const escape = skipEncoding ? false : serializationOption && serializationOption.allowReserved ? 'unsafe' : 'reserved';
|
---|
| 300 | const encodeFn = v => encodeDisallowedCharacters(v, {
|
---|
| 301 | escape
|
---|
| 302 | });
|
---|
| 303 | const encodeKeyFn = skipEncoding ? k => k : k => encodeDisallowedCharacters(k, {
|
---|
| 304 | escape
|
---|
| 305 | });
|
---|
| 306 |
|
---|
| 307 | // Primitive
|
---|
| 308 | if (typeof value !== 'object') {
|
---|
| 309 | return [[encodeKeyFn(key), encodeFn(value)]];
|
---|
| 310 | }
|
---|
| 311 |
|
---|
| 312 | // Array
|
---|
| 313 | if (Array.isArray(value)) {
|
---|
| 314 | if (explode) {
|
---|
| 315 | // In case of multipart/formdata, it is used as array.
|
---|
| 316 | // Otherwise, the caller will convert it to a query by qs.stringify.
|
---|
| 317 | return [[encodeKeyFn(key), value.map(encodeFn)]];
|
---|
| 318 | }
|
---|
| 319 | return [[encodeKeyFn(key), value.map(encodeFn).join(STYLE_SEPARATORS[style])]];
|
---|
| 320 | }
|
---|
| 321 |
|
---|
| 322 | // Object
|
---|
| 323 | if (style === 'deepObject') {
|
---|
| 324 | return Object.keys(value).map(valueKey => [encodeKeyFn(`${key}[${valueKey}]`), encodeFn(value[valueKey])]);
|
---|
| 325 | }
|
---|
| 326 | if (explode) {
|
---|
| 327 | return Object.keys(value).map(valueKey => [encodeKeyFn(valueKey), encodeFn(value[valueKey])]);
|
---|
| 328 | }
|
---|
| 329 | return [[encodeKeyFn(key), Object.keys(value).map(valueKey => [`${encodeKeyFn(valueKey)},${encodeFn(value[valueKey])}`]).join(',')]];
|
---|
| 330 | }
|
---|
| 331 | function buildFormData(reqForm) {
|
---|
| 332 | /**
|
---|
| 333 | * Build a new FormData instance, support array as field value
|
---|
| 334 | * OAS2.0 - when collectionFormat is multi
|
---|
| 335 | * OAS3.0 - when explode of Encoding Object is true
|
---|
| 336 | *
|
---|
| 337 | * This function explicitly handles Buffers (for backward compatibility)
|
---|
| 338 | * if provided as a values to FormData. FormData can only handle USVString
|
---|
| 339 | * or Blob.
|
---|
| 340 | *
|
---|
| 341 | * @param {Object} reqForm - ori req.form
|
---|
| 342 | * @return {FormData} - new FormData instance
|
---|
| 343 | */
|
---|
| 344 | return Object.entries(reqForm).reduce((formData, [name, input]) => {
|
---|
| 345 | // eslint-disable-next-line no-restricted-syntax
|
---|
| 346 | for (const [key, value] of formatKeyValue(name, input, true)) {
|
---|
| 347 | if (Array.isArray(value)) {
|
---|
| 348 | // eslint-disable-next-line no-restricted-syntax
|
---|
| 349 | for (const v of value) {
|
---|
| 350 | if (ArrayBuffer.isView(v)) {
|
---|
| 351 | const blob = new Blob([v]);
|
---|
| 352 | formData.append(key, blob);
|
---|
| 353 | } else {
|
---|
| 354 | formData.append(key, v);
|
---|
| 355 | }
|
---|
| 356 | }
|
---|
| 357 | } else if (ArrayBuffer.isView(value)) {
|
---|
| 358 | const blob = new Blob([value]);
|
---|
| 359 | formData.append(key, blob);
|
---|
| 360 | } else {
|
---|
| 361 | formData.append(key, value);
|
---|
| 362 | }
|
---|
| 363 | }
|
---|
| 364 | return formData;
|
---|
| 365 | }, new FormData());
|
---|
| 366 | }
|
---|
| 367 |
|
---|
| 368 | // Encodes an object using appropriate serializer.
|
---|
| 369 | export function encodeFormOrQuery(data) {
|
---|
| 370 | /**
|
---|
| 371 | * Encode parameter names and values
|
---|
| 372 | * @param {Object} result - parameter names and values
|
---|
| 373 | * @param {string} parameterName - Parameter name
|
---|
| 374 | * @return {object} encoded parameter names and values
|
---|
| 375 | */
|
---|
| 376 | const encodedQuery = Object.keys(data).reduce((result, parameterName) => {
|
---|
| 377 | // eslint-disable-next-line no-restricted-syntax
|
---|
| 378 | for (const [key, value] of formatKeyValue(parameterName, data[parameterName])) {
|
---|
| 379 | if (value instanceof FileWithData) {
|
---|
| 380 | result[key] = value.valueOf();
|
---|
| 381 | } else {
|
---|
| 382 | result[key] = value;
|
---|
| 383 | }
|
---|
| 384 | }
|
---|
| 385 | return result;
|
---|
| 386 | }, {});
|
---|
| 387 | return qs.stringify(encodedQuery, {
|
---|
| 388 | encode: false,
|
---|
| 389 | indices: false
|
---|
| 390 | }) || '';
|
---|
| 391 | }
|
---|
| 392 |
|
---|
| 393 | // If the request has a `query` object, merge it into the request.url, and delete the object
|
---|
| 394 | // If file and/or multipart, also create FormData instance
|
---|
| 395 | export function mergeInQueryOrForm(req = {}) {
|
---|
| 396 | const {
|
---|
| 397 | url = '',
|
---|
| 398 | query,
|
---|
| 399 | form
|
---|
| 400 | } = req;
|
---|
| 401 | const joinSearch = (...strs) => {
|
---|
| 402 | const search = strs.filter(a => a).join('&'); // Only truthy value
|
---|
| 403 | return search ? `?${search}` : ''; // Only add '?' if there is a str
|
---|
| 404 | };
|
---|
| 405 | if (form) {
|
---|
| 406 | const hasFile = Object.keys(form).some(key => {
|
---|
| 407 | const {
|
---|
| 408 | value
|
---|
| 409 | } = form[key];
|
---|
| 410 | return isFile(value) || isArrayOfFile(value);
|
---|
| 411 | });
|
---|
| 412 | const contentType = req.headers['content-type'] || req.headers['Content-Type'];
|
---|
| 413 | if (hasFile || /multipart\/form-data/i.test(contentType)) {
|
---|
| 414 | const formdata = buildFormData(req.form);
|
---|
| 415 | req.formdata = formdata;
|
---|
| 416 | req.body = formdata;
|
---|
| 417 | } else {
|
---|
| 418 | req.body = encodeFormOrQuery(form);
|
---|
| 419 | }
|
---|
| 420 | delete req.form;
|
---|
| 421 | }
|
---|
| 422 | if (query) {
|
---|
| 423 | const [baseUrl, oriSearch] = url.split('?');
|
---|
| 424 | let newStr = '';
|
---|
| 425 | if (oriSearch) {
|
---|
| 426 | const oriQuery = qs.parse(oriSearch);
|
---|
| 427 | const keysToRemove = Object.keys(query);
|
---|
| 428 | keysToRemove.forEach(key => delete oriQuery[key]);
|
---|
| 429 | newStr = qs.stringify(oriQuery, {
|
---|
| 430 | encode: true
|
---|
| 431 | });
|
---|
| 432 | }
|
---|
| 433 | const finalStr = joinSearch(newStr, encodeFormOrQuery(query));
|
---|
| 434 | req.url = baseUrl + finalStr;
|
---|
| 435 | delete req.query;
|
---|
| 436 | }
|
---|
| 437 | return req;
|
---|
| 438 | }
|
---|
| 439 |
|
---|
| 440 | // Wrap a http function ( there are otherways to do this, consider this deprecated )
|
---|
| 441 | export function makeHttp(httpFn, preFetch, postFetch) {
|
---|
| 442 | postFetch = postFetch || (a => a);
|
---|
| 443 | preFetch = preFetch || (a => a);
|
---|
| 444 | return req => {
|
---|
| 445 | if (typeof req === 'string') {
|
---|
| 446 | req = {
|
---|
| 447 | url: req
|
---|
| 448 | };
|
---|
| 449 | }
|
---|
| 450 | self.mergeInQueryOrForm(req);
|
---|
| 451 | req = preFetch(req);
|
---|
| 452 | return postFetch(httpFn(req));
|
---|
| 453 | };
|
---|
| 454 | } |
---|