├── .babelrc ├── .eslintrc ├── .flowconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── src ├── Headers.js ├── Logger.js ├── attemptRequest.js ├── config.js ├── errors.js ├── index.js ├── types.js └── utilities.js └── test ├── .eslintrc ├── attemptRequest.js └── fetch.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "plugins": [ 5 | "istanbul" 6 | ] 7 | } 8 | }, 9 | "plugins": [ 10 | "@babel/transform-flow-strip-types" 11 | ], 12 | "presets": [ 13 | [ 14 | "@babel/env", 15 | { 16 | "targets": { 17 | "node": 8 18 | } 19 | } 20 | ] 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "canonical", 4 | "canonical/flowtype" 5 | ], 6 | "root": true, 7 | "rules": { 8 | "flowtype/no-weak-types": 0, 9 | "no-continue": 0 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/.*/test/.* 3 | /dist/.* 4 | /test/.* 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | node_modules 4 | *.log 5 | .* 6 | !.babelrc 7 | !.eslintrc 8 | !.flowconfig 9 | !.gitignore 10 | !.npmignore 11 | !.travis.yml 12 | package-lock.json 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | coverage 3 | .* 4 | *.log 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | before_install: 5 | - npm config set depth 0 6 | script: 7 | - npm run lint 8 | - npm run test 9 | - npm run build 10 | # - nyc --silent npm run test 11 | # - nyc report --reporter=text-lcov | coveralls 12 | # - nyc check-coverage --lines 80 13 | after_success: 14 | - npm run build 15 | - semantic-release 16 | notifications: 17 | email: false 18 | sudo: false 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Gajus Kuizinas (http://gajus.com/) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the Gajus Kuizinas (http://gajus.com/) nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL ANUARY BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xfetch 2 | 3 | [![Travis build status](http://img.shields.io/travis/gajus/xfetch/master.svg?style=flat-square)](https://travis-ci.org/gajus/xfetch) 4 | [![Coveralls](https://img.shields.io/coveralls/gajus/xfetch.svg?style=flat-square)](https://coveralls.io/github/gajus/xfetch) 5 | [![NPM version](http://img.shields.io/npm/v/xfetch.svg?style=flat-square)](https://www.npmjs.org/package/xfetch) 6 | [![Canonical Code Style](https://img.shields.io/badge/code%20style-canonical-blue.svg?style=flat-square)](https://github.com/gajus/canonical) 7 | 8 | A light-weight HTTP client for Node.js. 9 | 10 | * [API](#api) 11 | * [Configuration](#configuration) 12 | * [Behaviour](#behaviour) 13 | * [HTTP proxy](#http-proxy) 14 | * [Throws an error if response code is non-2xx](#throws-an-error-if-response-code-is-non-2xx) 15 | * [Timeout](#timeout) 16 | * [Cookbook](#cookbook) 17 | * [Retry request](#retry-request) 18 | * [Validate response](#validate-response) 19 | * [Make cookies persist between requests](#make-cookies-persist-between-requests) 20 | 21 | ## Motivation 22 | 23 | It started as a light-wrapper of `node-fetch` due to the lack of [`HTTP_PROXY` support](https://github.com/bitinn/node-fetch/issues/195). 24 | 25 | The surface grew to incorporate new requirements. In comparison to the WHATWG [Fetch](https://fetch.spec.whatwg.org/), xfetch API is designed to keep the code minimal by providing short-cuts to common operations. 26 | 27 | On top of the `node-fetch`, xfetch implements: 28 | 29 | * [HTTP proxy](#http-proxy) support. 30 | * [Response validation](#validate-response). 31 | * [Retry request](#retry-request) strategy. 32 | * [In-built CookieJar](#make-cookies-persist-between-requests). 33 | * Strictly typed API. 34 | 35 | ## API 36 | 37 | ```js 38 | type HeadersConfigurationType = { 39 | [key: string]: string | number 40 | }; 41 | 42 | type RawHeadersType = {| 43 | [key: string]: $ReadOnlyArray 44 | |}; 45 | 46 | type HeadersType = {| 47 | +raw: () => RawHeadersType, 48 | +get: (name: string) => string 49 | |}; 50 | 51 | type IsResponseRedirectType = (Response: ResponseType) => boolean; 52 | type IsResponseValidType = (response: ResponseType) => boolean | Promise; 53 | 54 | type HttpMethodType = 'get' | 'post' | 'delete' | 'post' | 'trace'; 55 | 56 | /** 57 | * @see https://github.com/tim-kos/node-retry#retrytimeoutsoptions 58 | */ 59 | type RetryConfigurationType = { 60 | factor?: number, 61 | maxTimeout?: number, 62 | minTimeout?: number, 63 | randomize?: boolean, 64 | retries?: number 65 | }; 66 | 67 | type ResponseType = {| 68 | +headers: HeadersType, 69 | +json: () => Promise, 70 | +status: number, 71 | +text: () => Promise, 72 | +url: string 73 | |} | string; 74 | 75 | /** 76 | * @property isResponseValid Used to validate response. Refer to [Validate response](#validate-response). 77 | * @property retry Used to retry requests that produce response that does not pass validation. Refer to [Retry request](#retry-request) and [Validating response](#validating-response). 78 | * @property jar An instance of `tough-cookie` [`CookieJar`](https://github.com/salesforce/tough-cookie#cookiejar). Used to collect & set cookies. 79 | * @property timeout Timeout in milliseconds. 80 | */ 81 | type UserConfigurationType = { 82 | +body?: string | URLSearchParams | FormData, 83 | +compress?: boolean, 84 | +headers?: HeadersConfigurationType, 85 | +isResponseRedirect?: IsResponseRedirectType, 86 | +isResponseValid?: IsResponseValidType, 87 | +jar?: CookieJar, 88 | +method?: HttpMethodType, 89 | +query?: Object, 90 | +responseType?: 'full' | 'text' | 'json', 91 | +retry?: RetryConfigurationType, 92 | +timeout?: number 93 | }; 94 | 95 | type fetch = (url: string, configuration?: UserConfigurationType) => Promise; 96 | 97 | ``` 98 | 99 | ## Behaviour 100 | 101 | ### HTTP proxy 102 | 103 | Uses `PROTOCOL_PROXY` environment variable value to configure HTTP(S) proxy and supports `NO_PROXY` exclusions. 104 | 105 | ``` 106 | export NO_PROXY=".localdomain,192.168.1." 107 | export HTTP_PROXY="http://host:port" 108 | ``` 109 | 110 | > Note: You must also configure `NODE_TLS_REJECT_UNAUTHORIZED=0`. 111 | > This is a lazy workaround to enable the proxy to work with TLS. 112 | 113 | ### Throws an error if response code is non-2xx or 3xx 114 | 115 | Throws `UnexpectedResponseCodeError` error if response code is non-2xx or 3xx. 116 | 117 | This behaviour can be overridden using `isResponseValid` configuration. 118 | 119 | ### Timeout 120 | 121 | `xfetch` defaults to a 60 minutes timeout after which `ResponseTimeoutError` error is thrown. 122 | 123 | A timeout error does not trigger the request retry strategy. 124 | 125 | ```js 126 | import fetch, { 127 | ResponseTimeoutError 128 | } from 'xfetch'; 129 | 130 | try { 131 | await fetch('http://gajus.com/', { 132 | timeout: 30 * 1000 133 | }); 134 | } catch (error) { 135 | if (error instanceof ResponseTimeoutError) { 136 | // Request has not received a response within 30 seconds. 137 | } 138 | 139 | throw error; 140 | } 141 | 142 | ``` 143 | 144 | The default timeout can be configured using `XFETCH_REQUEST_TIMEOUT` (milliseconds) environment variable. 145 | 146 | ## Cookbook 147 | 148 | ### Retry request 149 | 150 | Requests that result in non-2xx response will be retried. 151 | 152 | `retry` option is used to configure request retry strategy. 153 | 154 | The `retry` configuration shape matches [configuration of the `retry`](https://github.com/tim-kos/node-retry) module. 155 | 156 | ### Validate response 157 | 158 | Define a custom validator function to force use the request retry strategy. 159 | 160 | A custom validator is configured using `isResponseValid` configuration, e.g. 161 | 162 | ```js 163 | import xfetch, { 164 | UnexpectedResponseError 165 | }; 166 | 167 | const isResponseValid = async (response) => { 168 | const body = await response.text(); 169 | 170 | if (body.includes('rate error')) { 171 | throw new UnexpectedResponseError(response); 172 | } 173 | 174 | return true; 175 | } 176 | 177 | await xfetch('http://gajus.com', { 178 | isResponseValid 179 | }); 180 | 181 | ``` 182 | 183 | A custom validator must return a boolean flag indicating whether the response is valid. A custom validator can throw an error that extends `UnexpectedResponseError` error. 184 | 185 | `xfetch` default validator can be imported and used to extend a custom validator, e.g. 186 | 187 | ```js 188 | import xfetch, { 189 | UnexpectedResponseError, 190 | isResponseValid as defaultIsResponseValid 191 | }; 192 | 193 | const isResponseValid = async (response) => { 194 | const responseIsValid = await defaultIsResponseValid(response); 195 | 196 | if (!responseIsValid) { 197 | return responseIsValid; 198 | } 199 | 200 | const body = await response.text(); 201 | 202 | if (body.includes('rate error')) { 203 | throw new UnexpectedResponseError(response); 204 | } 205 | 206 | return true; 207 | } 208 | 209 | await xfetch('http://gajus.com', { 210 | isResponseValid 211 | }); 212 | 213 | ``` 214 | 215 | ## Make cookies persist between requests 216 | 217 | `jar` parameter can be passed an instance of `tough-cookie` [`CookieJar`](https://github.com/salesforce/tough-cookie#cookiejar) to collect cookies and use them for the following requests. 218 | 219 | ```js 220 | import xfetch, { 221 | CookieJar 222 | }; 223 | 224 | const jar = new CookieJar(); 225 | 226 | await xfetch('http://gajus.com/this-url-sets-cookies', { 227 | jar 228 | }); 229 | 230 | await xfetch('http://gajus.com/this-url-requires-cookies-to-be-present', { 231 | jar 232 | }); 233 | 234 | ``` 235 | 236 | > Note: 237 | > 238 | > `xfetch` exports `CookieJar` class that can be used to construct an instance of `tough-cookie` [`CookieJar`](https://github.com/salesforce/tough-cookie#cookiejar). 239 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "email": "gajus@gajus.com", 4 | "name": "Gajus Kuizinas", 5 | "url": "http://gajus.com" 6 | }, 7 | "ava": { 8 | "require": [ 9 | "@babel/register" 10 | ] 11 | }, 12 | "dependencies": { 13 | "bluefeather": "^2.7.1", 14 | "es6-error": "^4.1.1", 15 | "form-data": "^2.3.2", 16 | "get-url-proxy": "^1.1.2", 17 | "got": "^8.3.1", 18 | "http-proxy-agent": "^2.1.0", 19 | "https-proxy-agent": "^2.2.1", 20 | "retry": "^0.12.0", 21 | "roarr": "^2.3.0", 22 | "tough-cookie": "^2.3.4" 23 | }, 24 | "description": "A light-weight HTTP client for Node.js.", 25 | "devDependencies": { 26 | "@babel/cli": "^7.0.0-beta.49", 27 | "@babel/core": "^7.0.0-beta.49", 28 | "@babel/node": "^7.0.0-beta.49", 29 | "@babel/plugin-transform-flow-strip-types": "^7.0.0-beta.49", 30 | "@babel/preset-env": "^7.0.0-beta.49", 31 | "@babel/register": "^7.0.0-beta.49", 32 | "ava": "git+https://github.com/avajs/ava.git", 33 | "babel-plugin-istanbul": "^4.1.6", 34 | "coveralls": "^3.0.1", 35 | "eslint": "^4.19.1", 36 | "eslint-config-canonical": "^9.3.2", 37 | "flow-bin": "^0.73.0", 38 | "flow-copy-source": "^1.3.0", 39 | "husky": "^0.14.3", 40 | "nock": "^9.2.6", 41 | "nyc": "^11.8.0", 42 | "semantic-release": "^15.5.0" 43 | }, 44 | "engines": { 45 | "node": ">8" 46 | }, 47 | "keywords": [ 48 | "promise" 49 | ], 50 | "license": "BSD-3-Clause", 51 | "main": "./dist/index.js", 52 | "name": "xfetch", 53 | "nyc": { 54 | "include": [ 55 | "src/**/*.js" 56 | ], 57 | "instrument": false, 58 | "reporter": [ 59 | "text-lcov" 60 | ], 61 | "require": [ 62 | "@babel/register" 63 | ], 64 | "sourceMap": false 65 | }, 66 | "repository": { 67 | "type": "git", 68 | "url": "https://github.com/gajus/xfetch" 69 | }, 70 | "scripts": { 71 | "build": "rm -fr ./dist && NODE_ENV=production babel ./src --out-dir ./dist --copy-files --source-maps && flow-copy-source src dist", 72 | "lint": "eslint ./src ./test && flow", 73 | "precommit": "npm run lint && npm run test && npm run build", 74 | "test": "NODE_ENV=development nyc --reporter=text ava --verbose --serial" 75 | }, 76 | "version": "1.0.1" 77 | } 78 | -------------------------------------------------------------------------------- /src/Headers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-style, sort-keys, no-tabs, jsdoc/check-tag-names, no-param-reassign, no-restricted-syntax, no-negated-condition, no-eq-null, eqeqeq, no-use-before-define, id-match, no-nested-ternary */ 2 | 3 | /** 4 | * headers.js 5 | * 6 | * Headers class offers convenient helpers 7 | * @see https://github.com/bitinn/node-fetch/blob/fa6548ed316a3c512147a3bb4f4d6e986d738be6/src/headers.js#L352 8 | */ 9 | 10 | const invalidTokenRegex = /[^^_`a-zA-Z\-0-9!#$%&'*+.|~]/; 11 | const invalidHeaderCharRegex = /[^\t\u0020-\u007e\u0080-\u00ff]/; 12 | 13 | function validateName (name) { 14 | name = `${name}`; 15 | if (invalidTokenRegex.test(name)) { 16 | throw new TypeError(`${name} is not a legal HTTP header name`); 17 | } 18 | } 19 | 20 | function validateValue (value) { 21 | value = `${value}`; 22 | if (invalidHeaderCharRegex.test(value)) { 23 | throw new TypeError(`${value} is not a legal HTTP header value`); 24 | } 25 | } 26 | 27 | /** 28 | * Find the key in the map object given a header name. 29 | * 30 | * Returns undefined if not found. 31 | * 32 | * @param String Name Header name. 33 | * @return String|Undefined. 34 | */ 35 | function find (map, name) { 36 | name = name.toLowerCase(); 37 | for (const key in map) { 38 | if (key.toLowerCase() === name) { 39 | return key; 40 | } 41 | } 42 | 43 | return undefined; 44 | } 45 | 46 | const MAP = Symbol('map'); 47 | 48 | export default class Headers { 49 | /** 50 | * Headers class. 51 | * 52 | * @param Object Headers Response headers. 53 | * @return Void. 54 | */ 55 | constructor (init = undefined) { 56 | this[MAP] = Object.create(null); 57 | 58 | if (init instanceof Headers) { 59 | const rawHeaders = init.raw(); 60 | const headerNames = Object.keys(rawHeaders); 61 | 62 | for (const headerName of headerNames) { 63 | for (const value of rawHeaders[headerName]) { 64 | this.append(headerName, value); 65 | } 66 | } 67 | 68 | return; 69 | } 70 | 71 | // We don't worry about converting prop to ByteString here as append() 72 | // will handle it. 73 | if (init == null) { 74 | // no op 75 | } else if (typeof init === 'object') { 76 | const method = init[Symbol.iterator]; 77 | 78 | if (method != null) { 79 | if (typeof method !== 'function') { 80 | throw new TypeError('Header pairs must be iterable'); 81 | } 82 | 83 | // sequence> 84 | // Note: per spec we have to first exhaust the lists then process them 85 | const pairs = []; 86 | 87 | for (const pair of init) { 88 | if (typeof pair !== 'object' || typeof pair[Symbol.iterator] !== 'function') { 89 | throw new TypeError('Each header pair must be iterable'); 90 | } 91 | pairs.push(Array.from(pair)); 92 | } 93 | 94 | for (const pair of pairs) { 95 | if (pair.length !== 2) { 96 | throw new TypeError('Each header pair must be a name/value tuple'); 97 | } 98 | this.append(pair[0], pair[1]); 99 | } 100 | } else { 101 | // record 102 | for (const key of Object.keys(init)) { 103 | const value = init[key]; 104 | 105 | this.append(key, value); 106 | } 107 | } 108 | } else { 109 | throw new TypeError('Provided initializer must be an object'); 110 | } 111 | } 112 | 113 | /** 114 | * Return combined header value given name. 115 | * 116 | * @param String Name Header name. 117 | * @return Mixed. 118 | */ 119 | get (name) { 120 | name = `${name}`; 121 | validateName(name); 122 | const key = find(this[MAP], name); 123 | 124 | if (key === undefined) { 125 | return null; 126 | } 127 | 128 | return this[MAP][key].join(', '); 129 | } 130 | 131 | /** 132 | * Iterate over all headers. 133 | * 134 | * @param Function Callback Executed for each item with parameters (value, name, thisArg). 135 | * @param Boolean ThisArg `this` context for callback function. 136 | * @return Void. 137 | */ 138 | forEach (callback, thisArg = undefined) { 139 | let pairs = getHeaders(this); 140 | let i = 0; 141 | 142 | while (i < pairs.length) { 143 | const [name, value] = pairs[i]; 144 | 145 | callback.call(thisArg, value, name, this); 146 | pairs = getHeaders(this); 147 | i++; 148 | } 149 | } 150 | 151 | /** 152 | * Overwrite header values given name. 153 | * 154 | * @param String Name Header name. 155 | * @param String Value Header value. 156 | * @return Void. 157 | */ 158 | set (name, value) { 159 | name = `${name}`; 160 | value = `${value}`; 161 | validateName(name); 162 | validateValue(value); 163 | const key = find(this[MAP], name); 164 | 165 | this[MAP][key !== undefined ? key : name] = [value]; 166 | } 167 | 168 | /** 169 | * Append a value onto existing header. 170 | * 171 | * @param String Name Header name. 172 | * @param String Value Header value. 173 | * @return Void. 174 | */ 175 | append (name, value) { 176 | name = `${name}`; 177 | value = `${value}`; 178 | validateName(name); 179 | validateValue(value); 180 | const key = find(this[MAP], name); 181 | 182 | if (key !== undefined) { 183 | this[MAP][key].push(value); 184 | } else { 185 | this[MAP][name] = [value]; 186 | } 187 | } 188 | 189 | /** 190 | * Check for header name existence. 191 | * 192 | * @param String Name Header name. 193 | * @return Boolean. 194 | */ 195 | has (name) { 196 | name = `${name}`; 197 | validateName(name); 198 | 199 | return find(this[MAP], name) !== undefined; 200 | } 201 | 202 | /** 203 | * Delete all header values given name. 204 | * 205 | * @param String Name Header name. 206 | * @return Void. 207 | */ 208 | delete (name) { 209 | name = `${name}`; 210 | validateName(name); 211 | const key = find(this[MAP], name); 212 | 213 | if (key !== undefined) { 214 | delete this[MAP][key]; 215 | } 216 | } 217 | 218 | /** 219 | * Return raw headers (non-spec api). 220 | * 221 | * @return Object. 222 | */ 223 | raw () { 224 | return this[MAP]; 225 | } 226 | 227 | /** 228 | * Get an iterator on keys. 229 | * 230 | * @return Iterator. 231 | */ 232 | keys () { 233 | return createHeadersIterator(this, 'key'); 234 | } 235 | 236 | /** 237 | * Get an iterator on values. 238 | * 239 | * @return Iterator. 240 | */ 241 | values () { 242 | return createHeadersIterator(this, 'value'); 243 | } 244 | 245 | /** 246 | * Get an iterator on entries. 247 | * 248 | * This is the default iterator of the Headers object. 249 | * 250 | * @return Iterator. 251 | */ 252 | [Symbol.iterator] () { 253 | return createHeadersIterator(this, 'key+value'); 254 | } 255 | } 256 | Headers.prototype.entries = Headers.prototype[Symbol.iterator]; 257 | 258 | Object.defineProperty(Headers.prototype, Symbol.toStringTag, { 259 | value: 'Headers', 260 | writable: false, 261 | enumerable: false, 262 | configurable: true 263 | }); 264 | 265 | Object.defineProperties(Headers.prototype, { 266 | get: {enumerable: true}, 267 | forEach: {enumerable: true}, 268 | set: {enumerable: true}, 269 | append: {enumerable: true}, 270 | has: {enumerable: true}, 271 | delete: {enumerable: true}, 272 | keys: {enumerable: true}, 273 | values: {enumerable: true}, 274 | entries: {enumerable: true} 275 | }); 276 | 277 | function getHeaders (headers, kind = 'key+value') { 278 | const keys = Object.keys(headers[MAP]).sort(); 279 | 280 | return keys.map( 281 | kind === 'key' ? 282 | (k) => { 283 | return k.toLowerCase(); 284 | } : 285 | kind === 'value' ? 286 | (k) => { 287 | return headers[MAP][k].join(', '); 288 | } : 289 | (k) => { 290 | return [k.toLowerCase(), headers[MAP][k].join(', ')]; 291 | } 292 | ); 293 | } 294 | 295 | const INTERNAL = Symbol('internal'); 296 | 297 | function createHeadersIterator (target, kind) { 298 | const iterator = Object.create(HeadersIteratorPrototype); 299 | 300 | iterator[INTERNAL] = { 301 | target, 302 | kind, 303 | index: 0 304 | }; 305 | 306 | return iterator; 307 | } 308 | 309 | const HeadersIteratorPrototype = Object.setPrototypeOf({ 310 | next () { 311 | // istanbul ignore if 312 | if (!this || 313 | Object.getPrototypeOf(this) !== HeadersIteratorPrototype) { 314 | throw new TypeError('Value of `this` is not a HeadersIterator'); 315 | } 316 | 317 | const { 318 | target, 319 | kind, 320 | index 321 | } = this[INTERNAL]; 322 | const values = getHeaders(target, kind); 323 | const len = values.length; 324 | 325 | if (index >= len) { 326 | return { 327 | value: undefined, 328 | done: true 329 | }; 330 | } 331 | 332 | this[INTERNAL].index = index + 1; 333 | 334 | return { 335 | value: values[index], 336 | done: false 337 | }; 338 | } 339 | }, Object.getPrototypeOf( 340 | Object.getPrototypeOf([][Symbol.iterator]()) 341 | )); 342 | 343 | Object.defineProperty(HeadersIteratorPrototype, Symbol.toStringTag, { 344 | value: 'HeadersIterator', 345 | writable: false, 346 | enumerable: false, 347 | configurable: true 348 | }); 349 | 350 | /** 351 | * Export the Headers object in a form that Node.js can consume. 352 | * 353 | * @param Headers Headers. 354 | * @return Object. 355 | */ 356 | export function exportNodeCompatibleHeaders (headers) { 357 | const obj = Object.assign({__proto__: null}, headers[MAP]); 358 | 359 | // http.request() only supports string as Host header. This hack makes 360 | // specifying custom Host header possible. 361 | const hostHeaderKey = find(headers[MAP], 'Host'); 362 | 363 | if (hostHeaderKey !== undefined) { 364 | obj[hostHeaderKey] = obj[hostHeaderKey][0]; 365 | } 366 | 367 | return obj; 368 | } 369 | 370 | /** 371 | * Create a Headers object from an object of headers, ignoring those that do 372 | * not conform to HTTP grammar productions. 373 | * 374 | * @param Object Obj Object of headers. 375 | * @return Headers. 376 | */ 377 | export function createHeadersLenient (obj) { 378 | const headers = new Headers(); 379 | 380 | for (const name of Object.keys(obj)) { 381 | if (invalidTokenRegex.test(name)) { 382 | continue; 383 | } 384 | if (Array.isArray(obj[name])) { 385 | for (const val of obj[name]) { 386 | if (invalidHeaderCharRegex.test(val)) { 387 | continue; 388 | } 389 | if (headers[MAP][name] === undefined) { 390 | headers[MAP][name] = [val]; 391 | } else { 392 | headers[MAP][name].push(val); 393 | } 394 | } 395 | } else if (!invalidHeaderCharRegex.test(obj[name])) { 396 | headers[MAP][name] = [obj[name]]; 397 | } 398 | } 399 | 400 | return headers; 401 | } 402 | -------------------------------------------------------------------------------- /src/Logger.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import Logger from 'roarr'; 4 | 5 | export default Logger.child({ 6 | package: 'xfetch' 7 | }); 8 | -------------------------------------------------------------------------------- /src/attemptRequest.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import retry from 'retry'; 4 | import Logger from './Logger'; 5 | import type { 6 | IsResponseValidType, 7 | RequestHandlerType, 8 | ResponseType, 9 | RetryConfigurationType 10 | } from './types'; 11 | import { 12 | UnexpectedResponseError 13 | } from './errors'; 14 | 15 | const log = Logger.child({ 16 | namespace: 'attemptRequest' 17 | }); 18 | 19 | const defaultConfiguration = { 20 | factor: 2, 21 | maxTimeout: Infinity, 22 | minTimeout: 1000, 23 | randomize: false, 24 | retries: 0 25 | }; 26 | 27 | export default async ( 28 | requestHandler: RequestHandlerType, 29 | isResponseValid: IsResponseValidType, 30 | userRetryConfiguration: RetryConfigurationType 31 | ): Promise => { 32 | const retryConfiguration: RetryConfigurationType = { 33 | ...defaultConfiguration, 34 | ...userRetryConfiguration 35 | }; 36 | 37 | const operation = retry.operation(retryConfiguration); 38 | 39 | let currentAttempt = -1; 40 | 41 | return new Promise((resolve, reject) => { 42 | operation.attempt(async () => { 43 | ++currentAttempt; 44 | 45 | log.debug('making %d request attempt (%d allowed retries)', currentAttempt + 1, retryConfiguration.retries); 46 | 47 | try { 48 | const response = await requestHandler(currentAttempt); 49 | 50 | log.debug('received response (status code) %d', response.status); 51 | 52 | const responseIsValid = await isResponseValid(response, currentAttempt); 53 | 54 | if (responseIsValid === true) { 55 | resolve(response); 56 | } else { 57 | throw new UnexpectedResponseError(response); 58 | } 59 | } catch (error) { 60 | if (error instanceof UnexpectedResponseError) { 61 | if (!operation.retry(error)) { 62 | log.debug('maximum number of attempts has been exhausted'); 63 | 64 | reject(error); 65 | 66 | return; 67 | } 68 | 69 | log.debug('response is invalid... going to make another attempt'); 70 | } else { 71 | reject(error); 72 | } 73 | } 74 | }); 75 | }); 76 | }; 77 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /* eslint-disable no-process-env */ 4 | 5 | const DEFAULT_REQUEST_TIMEOUT = 60 * 60 * 1000; 6 | const REQUEST_TIMEOUT = process.env.XFETCH_REQUEST_TIMEOUT ? parseInt(process.env.XFETCH_REQUEST_TIMEOUT, 10) : null; 7 | 8 | if (isNaN(REQUEST_TIMEOUT)) { 9 | throw new TypeError('Unexpected XFETCH_REQUEST_TIMEOUT value.'); 10 | } 11 | 12 | export { 13 | DEFAULT_REQUEST_TIMEOUT, 14 | REQUEST_TIMEOUT 15 | }; 16 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import ExtendableError from 'es6-error'; 4 | import type { 5 | ResponseType 6 | } from './types'; 7 | 8 | export class UnexpectedResponseError extends ExtendableError { 9 | response: ResponseType; 10 | 11 | constructor (response: ResponseType) { 12 | super('Unexpected response.'); 13 | 14 | this.response = response; 15 | } 16 | } 17 | 18 | export class UnexpectedResponseCodeError extends UnexpectedResponseError { 19 | constructor (response: ResponseType) { 20 | super(response); 21 | 22 | this.message = 'Unexpected response code.'; 23 | } 24 | } 25 | 26 | export class ResponseTimeoutError extends ExtendableError { 27 | constructor () { 28 | super('Response not received with the configured timeout.'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | // eslint-disable-next-line filenames/match-exported 4 | import { 5 | parse as parseUrl, 6 | URLSearchParams 7 | } from 'url'; 8 | import got, { 9 | HTTPError, 10 | RequestError 11 | } from 'got'; 12 | import { 13 | promisify 14 | } from 'bluefeather'; 15 | import { 16 | CookieJar 17 | } from 'tough-cookie'; 18 | import FormData from 'form-data'; 19 | import HttpProxyAgent from 'http-proxy-agent'; 20 | import HttpsProxyAgent from 'https-proxy-agent'; 21 | import getProxy from 'get-url-proxy/cached'; 22 | import Logger from './Logger'; 23 | import type { 24 | ConfigurationType, 25 | CreateRequestType, 26 | HttpClientConfigurationType, 27 | IsResponseRedirectType, 28 | IsResponseValidType, 29 | ResponseType, 30 | UserConfigurationType 31 | } from './types'; 32 | import attemptRequest from './attemptRequest'; 33 | import { 34 | ResponseTimeoutError, 35 | UnexpectedResponseCodeError, 36 | UnexpectedResponseError 37 | } from './errors'; 38 | import { 39 | omit 40 | } from './utilities'; 41 | import { 42 | createHeadersLenient 43 | } from './Headers'; 44 | import { 45 | DEFAULT_REQUEST_TIMEOUT, 46 | REQUEST_TIMEOUT 47 | } from './config'; 48 | 49 | const log = Logger.child({ 50 | namespace: 'client' 51 | }); 52 | 53 | const isResponseValid: IsResponseValidType = async (response) => { 54 | if (!String(response.status).startsWith('2') && !String(response.status).startsWith('3')) { 55 | throw new UnexpectedResponseCodeError(response); 56 | } 57 | 58 | return true; 59 | }; 60 | 61 | const isResponseRedirect: IsResponseRedirectType = (response) => { 62 | return String(response.status).startsWith('3'); 63 | }; 64 | 65 | const handleRedirect = async (response, configuration) => { 66 | let location = response.headers.get('location'); 67 | 68 | if (!location) { 69 | throw new Error('Missing the location header.'); 70 | } 71 | 72 | if (location.startsWith('/')) { 73 | const urlTokens = parseUrl(response.url); 74 | 75 | if (!urlTokens.protocol) { 76 | throw new Error('Unexpected state.'); 77 | } 78 | 79 | if (!urlTokens.host) { 80 | throw new Error('Unexpected state.'); 81 | } 82 | 83 | location = urlTokens.protocol + '//' + urlTokens.host + location; 84 | } 85 | 86 | const originalMethod = configuration.method && configuration.method.toLowerCase(); 87 | 88 | const safeMethods = [ 89 | 'get', 90 | 'head', 91 | 'options', 92 | 'trace' 93 | ]; 94 | 95 | const nextMethod = safeMethods.includes(originalMethod) ? originalMethod : 'get'; 96 | 97 | // eslint-disable-next-line no-use-before-define 98 | return createRequest(location, { 99 | ...omit(configuration, 'body'), 100 | method: nextMethod 101 | }); 102 | }; 103 | 104 | const getHost = (url: string): string => { 105 | const urlTokens = parseUrl(url); 106 | 107 | if (!urlTokens.hostname) { 108 | throw new Error('Invalid URL.'); 109 | } 110 | 111 | return urlTokens.port === 80 ? urlTokens.host : urlTokens.hostname; 112 | }; 113 | 114 | const createConfiguration = async (url: string, userConfiguration: UserConfigurationType): Promise => { 115 | let cookie; 116 | 117 | if (userConfiguration.jar) { 118 | const getCookieString = promisify(userConfiguration.jar.getCookieString.bind(userConfiguration.jar)); 119 | 120 | cookie = await getCookieString(url); 121 | } 122 | 123 | let agent; 124 | 125 | const proxy = getProxy(url); 126 | 127 | if (proxy) { 128 | log.debug('using proxy %s', proxy); 129 | 130 | // eslint-disable-next-line no-process-env 131 | if (process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0') { 132 | throw new Error('Must configure NODE_TLS_REJECT_UNAUTHORIZED.'); 133 | } 134 | 135 | const AgentConstructor = url.toLowerCase().startsWith('https://') ? HttpsProxyAgent : HttpProxyAgent; 136 | 137 | agent = new AgentConstructor(proxy); 138 | } 139 | 140 | const host = getHost(url); 141 | 142 | const headers = userConfiguration.headers || {}; 143 | 144 | headers.host = host; 145 | 146 | if (cookie) { 147 | headers.cookie = cookie; 148 | } 149 | 150 | const responseType = userConfiguration.responseType || 'text'; 151 | 152 | return { 153 | ...userConfiguration, 154 | agent, 155 | headers, 156 | responseType 157 | }; 158 | }; 159 | 160 | // eslint-disable-next-line complexity 161 | const createHttpClientConfiguration = (configuration: ConfigurationType): HttpClientConfigurationType => { 162 | const fetchConfiguration: Object = { 163 | cache: false, 164 | decompress: true, 165 | followRedirect: false, 166 | method: configuration.method ? configuration.method.toUpperCase() : 'GET', 167 | retries: 0, 168 | throwHttpErrors: false, 169 | timeout: configuration.timeout || REQUEST_TIMEOUT || DEFAULT_REQUEST_TIMEOUT 170 | }; 171 | 172 | // @todo Test unexpected options. 173 | 174 | const fetchConfigurationOptionalProperties = [ 175 | 'query', 176 | 'agent', 177 | 'body', 178 | 'headers', 179 | 'timeout' 180 | ]; 181 | 182 | for (const fetchConfigurationOptionalProperty of fetchConfigurationOptionalProperties) { 183 | if (configuration[fetchConfigurationOptionalProperty]) { 184 | fetchConfiguration[fetchConfigurationOptionalProperty] = configuration[fetchConfigurationOptionalProperty]; 185 | } 186 | } 187 | 188 | if (fetchConfiguration.body && fetchConfiguration.body instanceof URLSearchParams) { 189 | fetchConfiguration.body = fetchConfiguration.body.toString(); 190 | 191 | // @todo Use Headers. 192 | // @todo Ensure that content-type is not already set. 193 | fetchConfiguration.headers = fetchConfiguration.headers || {}; 194 | fetchConfiguration.headers['content-type'] = 'application/x-www-form-urlencoded'; 195 | } 196 | 197 | return fetchConfiguration; 198 | }; 199 | 200 | const createRequest: CreateRequestType = async (url, userConfiguration = {}) => { 201 | log.debug('requesting URL %s', url); 202 | 203 | const configuration = await createConfiguration(url, userConfiguration); 204 | 205 | const createRequestAttempt = async (): Promise => { 206 | const httpClientConfiguration = createHttpClientConfiguration(configuration); 207 | 208 | let response; 209 | 210 | try { 211 | response = await got(url, httpClientConfiguration); 212 | } catch (error) { 213 | if (error instanceof RequestError && error.code === 'ETIMEDOUT') { 214 | throw new ResponseTimeoutError(); 215 | } 216 | 217 | if (error instanceof HTTPError) { 218 | throw error; 219 | } 220 | 221 | throw error; 222 | } 223 | 224 | const headers = createHeadersLenient(response.headers); 225 | 226 | if (userConfiguration.jar) { 227 | const setCookie = promisify(userConfiguration.jar.setCookie.bind(userConfiguration.jar)); 228 | 229 | const cookies = headers.raw()['set-cookie']; 230 | 231 | if (cookies) { 232 | for (const cookie of cookies) { 233 | await setCookie(cookie, url); 234 | } 235 | } 236 | } 237 | 238 | return { 239 | headers, 240 | json: () => { 241 | return JSON.parse(response.body); 242 | }, 243 | status: response.statusCode, 244 | text: () => { 245 | return response.body; 246 | }, 247 | url 248 | }; 249 | }; 250 | 251 | const finalResponse = await attemptRequest(createRequestAttempt, configuration.isResponseValid || isResponseValid, configuration.retry || {}); 252 | 253 | const finalIsResponseRedirect = configuration.isResponseRedirect || isResponseRedirect; 254 | 255 | if (finalIsResponseRedirect(finalResponse)) { 256 | log.debug('response identified as a redirect'); 257 | 258 | return handleRedirect(finalResponse, configuration); 259 | } 260 | 261 | if (configuration.responseType === 'text') { 262 | return finalResponse.text(); 263 | } 264 | 265 | if (configuration.responseType === 'json') { 266 | return finalResponse.json(); 267 | } 268 | 269 | return finalResponse; 270 | }; 271 | 272 | export default createRequest; 273 | 274 | export { 275 | CookieJar, 276 | FormData, 277 | isResponseRedirect, 278 | isResponseValid, 279 | ResponseTimeoutError, 280 | UnexpectedResponseCodeError, 281 | UnexpectedResponseError, 282 | URLSearchParams 283 | }; 284 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /* eslint-disable no-unused-vars, no-use-before-define */ 4 | 5 | import { 6 | URLSearchParams 7 | } from 'url'; 8 | import HttpProxyAgent from 'http-proxy-agent'; 9 | import HttpsProxyAgent from 'https-proxy-agent'; 10 | import FormData from 'form-data'; 11 | import { 12 | CookieJar 13 | } from 'tough-cookie'; 14 | 15 | export type HttpMethodType = string; 16 | 17 | /** 18 | * @see https://github.com/tim-kos/node-retry#retrytimeoutsoptions 19 | */ 20 | export type RetryConfigurationType = { 21 | factor?: number, 22 | maxTimeout?: number, 23 | minTimeout?: number, 24 | randomize?: boolean, 25 | retries?: number 26 | }; 27 | 28 | /** 29 | * A callback that when called initiates a request. 30 | */ 31 | export type RequestHandlerType = (attemptNumber: number) => Promise; 32 | 33 | /** 34 | * A callback that handles HTTP response. It must return true to expected response or false to indicate unsuccessful response. 35 | */ 36 | export type IsResponseValidType = (response: ResponseType, currentAttempt: number) => boolean | Promise; 37 | 38 | export type HeadersConfigurationType = { 39 | [key: string]: string | number 40 | }; 41 | 42 | export type IsResponseRedirectType = (Response: ResponseType) => boolean; 43 | 44 | export type UserConfigurationType = { 45 | +body?: string | URLSearchParams | FormData, 46 | +compress?: boolean, 47 | +headers?: HeadersConfigurationType, 48 | +isResponseRedirect?: IsResponseRedirectType, 49 | +isResponseValid?: IsResponseValidType, 50 | +jar?: CookieJar, 51 | +method?: HttpMethodType, 52 | +query?: Object, 53 | +responseType?: 'full' | 'text' | 'json', 54 | +retry?: RetryConfigurationType, 55 | +timeout?: number 56 | }; 57 | 58 | export type ConfigurationType = { 59 | +agent?: HttpProxyAgent | HttpsProxyAgent, 60 | +body?: string | URLSearchParams | FormData, 61 | +compress?: boolean, 62 | +headers: HeadersConfigurationType, 63 | +isResponseRedirect: IsResponseRedirectType, 64 | +isResponseValid?: IsResponseValidType, 65 | +jar?: CookieJar, 66 | +method?: HttpMethodType, 67 | +query?: Object, 68 | +retry?: RetryConfigurationType, 69 | +responseType: 'full' | 'text' | 'json', 70 | +timeout: number 71 | }; 72 | 73 | export type HttpClientConfigurationType = { 74 | +agent?: HttpProxyAgent | HttpsProxyAgent, 75 | +body?: string | URLSearchParams | FormData, 76 | +compress?: boolean, 77 | +headers: HeadersConfigurationType, 78 | +method: HttpMethodType, 79 | +redirect: 'manual' 80 | }; 81 | 82 | export type RawHeadersType = {| 83 | [key: string]: $ReadOnlyArray 84 | |}; 85 | 86 | export type HeadersType = {| 87 | +raw: () => RawHeadersType, 88 | +get: (name: string) => string 89 | |}; 90 | 91 | export type ResponseType = {| 92 | +headers: HeadersType, 93 | +json: () => Promise, 94 | +status: number, 95 | +text: () => Promise, 96 | +url: string 97 | |}; 98 | 99 | export type CreateRequestType = (inputUrl: string, userConfiguration?: UserConfigurationType) => Promise<*>; 100 | -------------------------------------------------------------------------------- /src/utilities.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export const omit = (source: T, keyName: string): T => { 4 | const { 5 | // eslint-disable-next-line no-unused-vars 6 | [keyName]: deletedKey, 7 | ...result 8 | } = source; 9 | 10 | return result; 11 | }; 12 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "canonical/ava", 3 | "rules": { 4 | "id-length": 0 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/attemptRequest.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import test from 'ava'; 4 | import attemptRequest from '../src/attemptRequest'; 5 | 6 | const createResponse = (status: number = 200): any => { 7 | return { 8 | status 9 | }; 10 | }; 11 | 12 | test('requestHandler() callback is called with the number of the attempt (zero-based index)', async (t) => { 13 | t.plan(4); 14 | 15 | let responseNumber = 0; 16 | 17 | await attemptRequest(async (attemptNumber) => { 18 | t.true(attemptNumber === responseNumber++); 19 | 20 | return createResponse(); 21 | }, (response, attemptNumber) => { 22 | if (attemptNumber === 3) { 23 | return true; 24 | } 25 | 26 | return false; 27 | }, { 28 | minTimeout: 0, 29 | retries: 3 30 | }); 31 | }); 32 | 33 | test('attemptRequest isResponseValid callback isResponseValid() callback is called with the number of the attempt (zero-based index)', async (t) => { 34 | t.plan(4); 35 | 36 | let responseNumber; 37 | 38 | responseNumber = 0; 39 | 40 | await attemptRequest(() => { 41 | return createResponse(responseNumber++); 42 | }, (response, attemptNumber) => { 43 | t.true(attemptNumber === response.status); 44 | 45 | if (response.status < 3) { 46 | return false; 47 | } else { 48 | return true; 49 | } 50 | }, { 51 | minTimeout: 0, 52 | retries: 3 53 | }); 54 | }); 55 | 56 | test('attemptRequest isResponseValid callback is using isResponseValid callback to validate the response', async (t) => { 57 | t.plan(2); 58 | 59 | const response0 = await attemptRequest(() => { 60 | return createResponse(); 61 | }, (response) => { 62 | t.true(response.status === 200); 63 | 64 | return true; 65 | }); 66 | 67 | t.true(response0.status === 200); 68 | }); 69 | 70 | test('attemptRequest isResponseValid callback retries a request of which response does not validate against isResponseValid', async (t) => { 71 | let firstRequest; 72 | 73 | firstRequest = true; 74 | 75 | const response0 = await attemptRequest(() => { 76 | if (firstRequest) { 77 | firstRequest = false; 78 | 79 | return createResponse(500); 80 | } 81 | 82 | return createResponse(); 83 | }, (response) => { 84 | return response.status === 200; 85 | }, { 86 | retries: 1 87 | }); 88 | 89 | t.true(response0.status === 200); 90 | }); 91 | -------------------------------------------------------------------------------- /test/fetch.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | 3 | import test, { 4 | before, 5 | beforeEach 6 | } from 'ava'; 7 | import nock from 'nock'; 8 | import fetch, { 9 | CookieJar, 10 | FormData, 11 | ResponseTimeoutError, 12 | UnexpectedResponseCodeError, 13 | URLSearchParams 14 | } from '../src'; 15 | 16 | before(() => { 17 | nock.disableNetConnect(); 18 | }); 19 | 20 | beforeEach(() => { 21 | delete process.env.HTTP_PROXY; 22 | delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; 23 | }); 24 | 25 | test('throws an error if HTTP_PROXY is configured and NODE_TLS_REJECT_UNAUTHORIZED is not configured', async (t) => { 26 | process.env.HTTP_PROXY = 'http://127.0.0.1:8080'; 27 | 28 | delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; 29 | 30 | await t.throws(fetch('http://gajus.com/')); 31 | }); 32 | 33 | test('throws UnexpectedResponseCode if response code is not 2xx', async (t) => { 34 | nock('http://gajus.com') 35 | .get('/') 36 | .reply(500); 37 | 38 | await t.throws(fetch('http://gajus.com/'), UnexpectedResponseCodeError); 39 | }); 40 | 41 | test('throws ResponseTimeoutError if response is not received in `timeout` time', async (t) => { 42 | nock('http://gajus.com') 43 | .get('/') 44 | .delay(2000) 45 | .reply(200, 'Hello, World!'); 46 | 47 | await t.throws(fetch('http://gajus.com/', { 48 | timeout: 1 49 | }), ResponseTimeoutError); 50 | }); 51 | 52 | test('{responseType: text} resolves the response body ', async (t) => { 53 | nock('http://gajus.com') 54 | .get('/') 55 | .reply(200, 'foo'); 56 | 57 | const response = await fetch('http://gajus.com/'); 58 | 59 | t.true(response === 'foo'); 60 | }); 61 | 62 | test('query parameter appends query to the URL', async (t) => { 63 | nock('http://gajus.com') 64 | .get('/') 65 | .query({ 66 | foo: 'bar' 67 | }) 68 | .reply(200, 'foo'); 69 | 70 | const response = await fetch('http://gajus.com/', { 71 | query: { 72 | foo: 'bar' 73 | } 74 | }); 75 | 76 | t.true(response === 'foo'); 77 | }); 78 | 79 | test('query parameter throws an error when URL already contains query parameters', async (t) => { 80 | await t.throws(fetch('http://gajus.com/?foo=bar', { 81 | query: { 82 | baz: 'qux' 83 | } 84 | })); 85 | }); 86 | 87 | test('{responseType: json} resolves the response body and parses using JSON.parse ', async (t) => { 88 | nock('http://gajus.com') 89 | .get('/') 90 | .reply(200, '"foo"'); 91 | 92 | const response = await fetch('http://gajus.com/', { 93 | responseType: 'json' 94 | }); 95 | 96 | t.true(response === 'foo'); 97 | }); 98 | 99 | test('text() resolves the response body', async (t) => { 100 | nock('http://gajus.com') 101 | .get('/') 102 | .reply(200, 'foo'); 103 | 104 | const response = await fetch('http://gajus.com/', { 105 | responseType: 'full' 106 | }); 107 | 108 | t.true(await response.text() === 'foo'); 109 | }); 110 | 111 | test('json() resolves to the response body', async (t) => { 112 | nock('http://gajus.com') 113 | .get('/') 114 | .reply(200, '{"foo":"bar"}'); 115 | 116 | const response = await fetch('http://gajus.com/', { 117 | responseType: 'full' 118 | }); 119 | 120 | const responseBody = await response.json(); 121 | 122 | t.deepEqual(responseBody, { 123 | foo: 'bar' 124 | }); 125 | }); 126 | 127 | test('headers.raw() resolves response headers', async (t) => { 128 | nock('http://gajus.com') 129 | .get('/') 130 | .reply(200, 'foo', { 131 | 'x-foo': 'bar' 132 | }); 133 | 134 | const response = await fetch('http://gajus.com/', { 135 | responseType: 'full' 136 | }); 137 | 138 | const responseHeaders = response.headers.raw(); 139 | 140 | t.deepEqual(responseHeaders, { 141 | 'x-foo': [ 142 | 'bar' 143 | ] 144 | }); 145 | }); 146 | 147 | test('headers.get() resolves response header', async (t) => { 148 | nock('http://gajus.com') 149 | .get('/') 150 | .reply(200, 'foo', { 151 | 'x-foo': 'bar' 152 | }); 153 | 154 | const response = await fetch('http://gajus.com/', { 155 | responseType: 'full' 156 | }); 157 | 158 | const responseHeaderValue = response.headers.get('x-foo'); 159 | 160 | t.true(responseHeaderValue === 'bar'); 161 | }); 162 | 163 | test('follows 3xx redirects', async (t) => { 164 | nock('http://gajus.com') 165 | .get('/') 166 | .reply(301, undefined, { 167 | Location: 'http://gajus.com/foo' 168 | }); 169 | 170 | nock('http://gajus.com') 171 | .get('/foo') 172 | .reply(200, 'bar'); 173 | 174 | const response = await fetch('http://gajus.com/', { 175 | responseType: 'full' 176 | }); 177 | 178 | t.true(await response.text() === 'bar'); 179 | t.true(response.url === 'http://gajus.com/foo'); 180 | }); 181 | 182 | test('follows 3xx redirects (absolute path)', async (t) => { 183 | nock('http://gajus.com') 184 | .get('/') 185 | .reply(301, undefined, { 186 | Location: '/foo' 187 | }); 188 | 189 | nock('http://gajus.com') 190 | .get('/foo') 191 | .reply(200, 'bar'); 192 | 193 | const response = await fetch('http://gajus.com/', { 194 | responseType: 'full' 195 | }); 196 | 197 | t.true(await response.text() === 'bar'); 198 | t.true(response.url === 'http://gajus.com/foo'); 199 | }); 200 | 201 | test('follows 3xx redirects (responseType text)', async (t) => { 202 | nock('http://gajus.com') 203 | .get('/') 204 | .reply(301, undefined, { 205 | Location: 'http://gajus.com/foo' 206 | }); 207 | 208 | nock('http://gajus.com') 209 | .get('/foo') 210 | .reply(200, 'foo'); 211 | 212 | const response = await fetch('http://gajus.com/', { 213 | responseType: 'text' 214 | }); 215 | 216 | t.true(await response === 'foo'); 217 | }); 218 | 219 | test('follows 3xx redirects (responseType JSON)', async (t) => { 220 | nock('http://gajus.com') 221 | .get('/') 222 | .reply(301, undefined, { 223 | Location: 'http://gajus.com/foo' 224 | }); 225 | 226 | nock('http://gajus.com') 227 | .get('/foo') 228 | .reply(200, '"foo"'); 229 | 230 | const response = await fetch('http://gajus.com/', { 231 | responseType: 'json' 232 | }); 233 | 234 | t.true(await response === 'foo'); 235 | }); 236 | 237 | test('follows 3xx redirect preserves the original headers', async (t) => { 238 | nock('http://gajus.com', { 239 | reqheaders: { 240 | 'x-foo': 'FOO' 241 | } 242 | }) 243 | .get('/') 244 | .reply(301, '', { 245 | location: 'http://gajus.com/foo' 246 | }); 247 | 248 | nock('http://gajus.com', { 249 | reqheaders: { 250 | 'x-foo': 'FOO' 251 | } 252 | }) 253 | .get('/foo') 254 | .reply(200, 'bar'); 255 | 256 | const response = await fetch('http://gajus.com/', { 257 | headers: { 258 | 'x-foo': 'FOO' 259 | } 260 | }); 261 | 262 | t.true(response === 'bar'); 263 | }); 264 | 265 | test('3xx redirect preserves the original request method if it is safe (GET, HEAD, OPTIONS or TRACE)', async (t) => { 266 | const safeMethods = [ 267 | 'get', 268 | 'head', 269 | 'options' 270 | 271 | // @todo Nock does not implement "trace" method. 272 | // 'trace' 273 | ]; 274 | 275 | for (const safeMethod of safeMethods) { 276 | nock('http://gajus.com')[safeMethod]('/') 277 | .reply(301, 'foo', { 278 | location: 'http://gajus.com/foo' 279 | }); 280 | 281 | nock('http://gajus.com')[safeMethod]('/foo') 282 | .reply(200, 'bar'); 283 | 284 | const response = await fetch('http://gajus.com/', { 285 | method: safeMethod 286 | }); 287 | 288 | t.true(response === 'bar'); 289 | } 290 | }); 291 | 292 | test('3xx redirect changes the request method to GET if the original request method is not safe to repeat (e.g. POST)', async (t) => { 293 | nock('http://gajus.com') 294 | .post('/') 295 | .reply(301, 'foo', { 296 | location: 'http://gajus.com/foo' 297 | }); 298 | 299 | nock('http://gajus.com') 300 | .get('/foo') 301 | .reply(200, 'bar'); 302 | 303 | const response = await fetch('http://gajus.com/', { 304 | method: 'post' 305 | }); 306 | 307 | t.true(response === 'bar'); 308 | }); 309 | 310 | test('3xx redirect changes the request method to GET if the original request method is not safe to repeat (e.g. POST) (with body)', async (t) => { 311 | nock('http://gajus.com') 312 | .post('/', 'foo') 313 | .reply(301, 'foo', { 314 | location: 'http://gajus.com/foo' 315 | }); 316 | 317 | nock('http://gajus.com') 318 | .get('/foo') 319 | .reply(200, 'bar'); 320 | 321 | const response = await fetch('http://gajus.com/', { 322 | body: 'foo', 323 | method: 'post' 324 | }); 325 | 326 | t.true(response === 'bar'); 327 | }); 328 | 329 | test('redirects persist cookies in a cookie jar', async (t) => { 330 | const jar = new CookieJar(); 331 | 332 | nock('http://gajus.com') 333 | .get('/') 334 | .reply(301, 'foo', { 335 | location: 'http://gajus.com/foo', 336 | 'set-cookie': 'foo=bar' 337 | }); 338 | 339 | nock('http://gajus.com', { 340 | reqheaders: { 341 | cookie: 'foo=bar' 342 | } 343 | }) 344 | .get('/foo') 345 | .reply(200, 'bar'); 346 | 347 | const response = await fetch('http://gajus.com/', { 348 | jar 349 | }); 350 | 351 | t.true(response === 'bar'); 352 | }); 353 | 354 | test('FormData', async (t) => { 355 | const formData = new FormData(); 356 | 357 | formData.append('foo', 'bar'); 358 | 359 | nock('http://gajus.com') 360 | .post('/', (body) => { 361 | return body.replace(/[0-9]+/g, 'X') === '----------------------------X\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n----------------------------X--\r\n'; 362 | }) 363 | .reply(200, 'foo'); 364 | 365 | const response = await fetch('http://gajus.com/', { 366 | body: formData, 367 | method: 'post' 368 | }); 369 | 370 | t.true(response === 'foo'); 371 | }); 372 | 373 | test('URLSearchParams', async (t) => { 374 | const formData = new URLSearchParams(); 375 | 376 | formData.append('foo', 'bar'); 377 | 378 | nock('http://gajus.com') 379 | .post('/', { 380 | foo: 'bar' 381 | }) 382 | .reply(200, 'foo'); 383 | 384 | const response = await fetch('http://gajus.com/', { 385 | body: formData, 386 | method: 'post' 387 | }); 388 | 389 | t.true(response === 'foo'); 390 | }); 391 | --------------------------------------------------------------------------------