├── .travis.yml ├── tsconfig.json ├── .npmignore ├── .gitignore ├── src ├── RequestFn.ts ├── handle-qs.ts ├── ResponsePromise.ts ├── Options.ts ├── browser.ts └── index.ts ├── test ├── mock-dom.js ├── mock-server.js └── index.js ├── LICENSE ├── package.json ├── HISTORY.md └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | - "8" 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "lib", 6 | "strict": true 7 | } 8 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | HISTORY.md 3 | lib-cov 4 | *.seed 5 | *.log 6 | *.csv 7 | *.dat 8 | *.out 9 | *.pid 10 | *.gz 11 | pids 12 | logs 13 | results 14 | npm-debug.log 15 | node_modules 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /dist 3 | lib-cov 4 | *.seed 5 | *.log 6 | *.csv 7 | *.dat 8 | *.out 9 | *.pid 10 | *.gz 11 | pids 12 | logs 13 | results 14 | npm-debug.log 15 | node_modules 16 | package-lock.json 17 | /lib 18 | -------------------------------------------------------------------------------- /src/RequestFn.ts: -------------------------------------------------------------------------------- 1 | import {HttpVerb} from 'http-basic'; 2 | import {Options} from './Options'; 3 | import {ResponsePromise} from './ResponsePromise'; 4 | 5 | type RequestFn = (method: HttpVerb, url: string, options?: Options) => ResponsePromise; 6 | 7 | export {RequestFn}; 8 | -------------------------------------------------------------------------------- /test/mock-dom.js: -------------------------------------------------------------------------------- 1 | const JSDOM = require('jsdom').JSDOM; 2 | const dom = new JSDOM('', {url: 'http://localhost:3000'}); 3 | 4 | exports.enable = function enable() { 5 | global.window = dom.window; 6 | global.XMLHttpRequest = dom.window.XMLHttpRequest; 7 | global.location = dom.window.location; 8 | global.FormData = dom.window.FormData; 9 | } 10 | exports.disable = function disable() { 11 | delete global.window; 12 | delete global.XMLHttpRequest; 13 | delete global.location; 14 | delete global.FormData; 15 | } -------------------------------------------------------------------------------- /src/handle-qs.ts: -------------------------------------------------------------------------------- 1 | import {parse, stringify} from 'qs'; 2 | 3 | export default function handleQs(url: string, query: {[key: string]: any}): string { 4 | const [start, part2] = url.split('?'); 5 | let qs = (part2 || '').split('#')[0]; 6 | const end = part2 && part2.split('#').length > 1 ? '#' + part2.split('#')[1] : ''; 7 | 8 | const baseQs = parse(qs); 9 | for (var i in query) { 10 | baseQs[i] = query[i]; 11 | } 12 | qs = stringify(baseQs); 13 | if (qs !== '') { 14 | qs = '?' + qs; 15 | } 16 | return start + qs + end; 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Forbes Lindesay 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /src/ResponsePromise.ts: -------------------------------------------------------------------------------- 1 | import Promise = require('promise'); 2 | import Response = require('http-response-object'); 3 | 4 | export declare class ResponsePromise extends Promise> { 5 | getBody(encoding: string): Promise; 6 | getBody(): Promise; 7 | } 8 | 9 | function getBody(this: Promise>, encoding?: string) { 10 | if (!encoding) { 11 | return this.then(getBodyBinary); 12 | } 13 | if (encoding === 'utf8') { 14 | return this.then(getBodyUTF8); 15 | } 16 | return this.then(getBodyWithEncoding(encoding)); 17 | } 18 | function getBodyWithEncoding(encoding: string): (res: Response) => string { 19 | return res => res.getBody(encoding); 20 | } 21 | function getBodyBinary(res: Response): Buffer | string { 22 | return res.getBody(); 23 | } 24 | function getBodyUTF8(res: Response): string { 25 | return res.getBody('utf8'); 26 | } 27 | 28 | function toResponsePromise(result: Promise>): ResponsePromise { 29 | (result as any).getBody = getBody; 30 | return (result as any); 31 | } 32 | 33 | export default toResponsePromise; 34 | exports.ResponsePromise = undefined; 35 | -------------------------------------------------------------------------------- /src/Options.ts: -------------------------------------------------------------------------------- 1 | import { Agent } from 'http'; 2 | import { IncomingHttpHeaders } from 'http'; 3 | import Response = require('http-response-object'); 4 | import {ICache, CachedResponse} from 'http-basic'; 5 | import FormData = require('form-data'); 6 | 7 | interface Options { 8 | allowRedirectHeaders?: string[]; 9 | cache?: 'file' | 'memory' | ICache; 10 | agent?: boolean | Agent; 11 | followRedirects?: boolean; 12 | gzip?: boolean; 13 | headers?: IncomingHttpHeaders; 14 | maxRedirects?: number; 15 | maxRetries?: number; 16 | retry?: boolean | ((err: NodeJS.ErrnoException | null, res: Response | void, attemptNumber: number) => boolean); 17 | retryDelay?: number | ((err: NodeJS.ErrnoException | null, res: Response | void, attemptNumber: number) => number); 18 | socketTimeout?: number; 19 | timeout?: number; 20 | 21 | isMatch?: (requestHeaders: IncomingHttpHeaders, cachedResponse: CachedResponse, defaultValue: boolean) => boolean; 22 | isExpired?: (cachedResponse: CachedResponse, defaultValue: boolean) => boolean; 23 | canCache?: (res: Response, defaultValue: boolean) => boolean; 24 | 25 | // extra options 26 | 27 | qs?: {[key: string]: any}; 28 | json?: any; 29 | form?: FormData; 30 | body?: string | Buffer | NodeJS.ReadableStream; 31 | } 32 | export {Options}; 33 | -------------------------------------------------------------------------------- /test/mock-server.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const http = require('http'); 3 | const concat = require('concat-stream'); 4 | const Busboy = require('busboy'); 5 | 6 | function createServer() { 7 | return http.createServer((req, res) => { 8 | if (req.method === 'GET' && req.url === '/') { 9 | res.statusCode = 200; 10 | res.setHeader('FoO', 'bar'); 11 | res.end('body'); 12 | return; 13 | } 14 | if (req.method === 'GET' && req.url === '/?foo=baz') { 15 | res.statusCode = 200; 16 | res.setHeader('FoO', 'baz'); 17 | res.end('body'); 18 | return; 19 | } 20 | if (req.method === 'POST' && req.url === '/') { 21 | assert(req.headers['content-type'] === 'application/json'); 22 | req.pipe(concat(body => { 23 | assert(JSON.parse(body.toString()).foo === 'baz'); 24 | res.statusCode = 200; 25 | res.end('json body'); 26 | })); 27 | return; 28 | } 29 | if (req.method === 'POST' && req.url === '/form') { 30 | assert(req.headers['content-length'] === '161'); 31 | assert(/^multipart\/form-data\; boundary=/.test(req.headers['content-type'])); 32 | const form = new Busboy({headers: req.headers}); 33 | const data = {}; 34 | form.on('field', (fieldName, value) => { 35 | data[fieldName] = value; 36 | }); 37 | form.on('finish', () => { 38 | assert(data.foo === 'baz'); 39 | res.statusCode = 200; 40 | res.end('form body'); 41 | }); 42 | req.pipe(form); 43 | return; 44 | } 45 | }).listen(3000); 46 | } 47 | 48 | module.exports = createServer; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "then-request", 3 | "version": "6.0.2", 4 | "description": "A request library that returns promises, inspired by request", 5 | "keywords": [], 6 | "main": "lib/index.js", 7 | "browser": "lib/browser.js", 8 | "types": "lib/index.d.ts", 9 | "files": [ 10 | "lib" 11 | ], 12 | "dependencies": { 13 | "@types/concat-stream": "^1.6.0", 14 | "@types/form-data": "0.0.33", 15 | "@types/node": "^8.0.0", 16 | "@types/qs": "^6.2.31", 17 | "caseless": "~0.12.0", 18 | "concat-stream": "^1.6.0", 19 | "form-data": "^2.2.0", 20 | "http-basic": "^8.1.1", 21 | "http-response-object": "^3.0.1", 22 | "promise": "^8.0.0", 23 | "qs": "^6.4.0" 24 | }, 25 | "devDependencies": { 26 | "browserify": "^14.4.0", 27 | "busboy": "^0.2.14", 28 | "exorcist": "^0.4.0", 29 | "flowgen2": "^2.2.2", 30 | "istanbul": "^0.4.5", 31 | "jsdom": "^11.1.0", 32 | "minifyify": "^7.3.5", 33 | "mkdirp": "^0.5.1", 34 | "multiparty": "^4.1.3", 35 | "rimraf": "^2.6.1", 36 | "testit": "^2.1.3", 37 | "typescript": "^2.4.0" 38 | }, 39 | "scripts": { 40 | "pretest": "npm run build:types", 41 | "test": "node test/index.js && istanbul cover test/index.js", 42 | "prepublishOnly": "npm run build", 43 | "prebuild": "rimraf dist && mkdirp dist", 44 | "build": "npm run build:types && npm run build:full && npm run build:min", 45 | "build:types": "tsc && flowgen lib/**/*", 46 | "build:full": "browserify -d --standalone request lib/browser.js | exorcist -u request.js.map dist/request.js.map > dist/request.js", 47 | "build:min": "browserify -d --standalone request lib/browser.js -p [minifyify --compressPath . --map request.min.js.map --output dist/request.min.js.map] > dist/request.min.js" 48 | }, 49 | "engines": { 50 | "node": ">=6.0.0" 51 | }, 52 | "repository": { 53 | "type": "git", 54 | "url": "https://github.com/then/then-request.git" 55 | }, 56 | "author": "ForbesLindesay", 57 | "license": "MIT" 58 | } -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | 2.1.1 / 2015-02-12 2 | ================== 3 | 4 | * Preserve header casing 5 | 6 | 2.1.0 / 2015-01-21 7 | ================== 8 | 9 | * Add retry, maxRedirects and timeout 10 | 11 | 2.0.3 / 2015-01-21 12 | ================== 13 | 14 | * Add a "dist" folder with prebuilt clients for browsers 15 | 16 | 2.0.1 / 2015-01-14 17 | ================== 18 | 19 | * Add support for 303 See Other redirects 20 | 21 | 1.1.0 / 2014-12-18 22 | ================== 23 | 24 | * Support not following redirects 25 | 26 | 1.0.5 / 2014-12-18 27 | ================== 28 | 29 | * Update dependencies 30 | 31 | 1.0.4 / 2014-09-29 32 | ================== 33 | 34 | * Update promise to 6.0.0 ([@ForbesLindesay](https://github.com/ForbesLindesay)) 35 | 36 | 1.0.3 / 2014-09-09 37 | ================== 38 | 39 | * Update qs to 2.2.3 ([@ForbesLindesay](https://github.com/ForbesLindesay)) 40 | 41 | 1.0.2 / 2014-09-09 42 | ================== 43 | 44 | * Add content-length header ([@ForbesLindesay](https://github.com/ForbesLindesay)) 45 | 46 | 1.0.1 / 2014-08-07 47 | ================== 48 | 49 | * Update dependencies ([@ForbesLindesay](https://github.com/ForbesLindesay)) 50 | 51 | 1.0.0 / 2014-08-01 52 | ================== 53 | 54 | * Completely new API ([@ForbesLindesay](https://github.com/ForbesLindesay)) 55 | * Gzip support ([@ForbesLindesay](https://github.com/ForbesLindesay)) 56 | * Caching support ([@ForbesLindesay](https://github.com/ForbesLindesay)) 57 | * Redirects support ([@ForbesLindesay](https://github.com/ForbesLindesay)) 58 | * Documentation ([@ForbesLindesay](https://github.com/ForbesLindesay)) 59 | 60 | 0.0.4 / 2014-04-02 61 | ================== 62 | 63 | * Fix for empty responses ([@ForbesLindesay](https://github.com/ForbesLindesay)) 64 | 65 | 0.0.3 / 2014-04-02 66 | ================== 67 | 68 | * Fix for empty responses ([@ForbesLindesay](https://github.com/ForbesLindesay)) 69 | 70 | 0.0.2 / 2014-04-01 71 | ================== 72 | 73 | * Fix dependencies ([@Volox](https://github.com/Volox)) 74 | * Update readme ([@Volox](https://github.com/Volox)) 75 | 76 | 0.0.1 / 2014-02-10 77 | ================== 78 | 79 | * Initial release ([@ForbesLindesay](https://github.com/ForbesLindesay)) 80 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('assert'); 4 | var test = require('testit'); 5 | var Promise = require('promise'); 6 | var createServer = require('./mock-server'); 7 | var mockDOM = require('./mock-dom'); 8 | 9 | test('./lib/handle-qs.js', function () { 10 | var handleQs = require('../lib/handle-qs.js').default; 11 | 12 | assert(handleQs('http://example.com/', {}) === 'http://example.com/'); 13 | assert(handleQs('http://example.com/?foo=bar', {}) === 'http://example.com/?foo=bar'); 14 | assert(handleQs('http://example.com/', {foo: 'bar'}) === 'http://example.com/?foo=bar'); 15 | assert(handleQs('http://example.com/', {foo: {bar: 'baz'}}) === 'http://example.com/?foo%5Bbar%5D=baz'); 16 | assert(handleQs('http://example.com/', {foo: 'bar', bing: 'bong'}) === 'http://example.com/?foo=bar&bing=bong'); 17 | assert(handleQs('http://example.com/?foo=bar', {bing: 'bong'}) === 'http://example.com/?foo=bar&bing=bong'); 18 | assert(handleQs('http://example.com/?foo=bar#ding', {bing: 'bong'}) === 'http://example.com/?foo=bar&bing=bong#ding'); 19 | }); 20 | 21 | var server = createServer(); 22 | 23 | function testEnv(env) { 24 | var request, FormData; 25 | test(env + ' - GET', function () { 26 | request = require(env === 'browser' ? '../lib/browser.js' : '../'); 27 | FormData = request.FormData; 28 | return request('GET', 'http://localhost:3000').then(function (res) { 29 | assert(res.statusCode === 200); 30 | assert(res.headers['foo'] === 'bar'); 31 | assert(res.body.toString() === 'body'); 32 | }); 33 | }); 34 | test(env + ' - GET query', function () { 35 | return request('GET', 'http://localhost:3000', {qs: {foo: 'baz'}}).then(function (res) { 36 | assert(res.statusCode === 200); 37 | assert(res.headers['foo'] === 'baz'); 38 | assert(res.body.toString() === 'body'); 39 | }); 40 | }); 41 | test(env + ' - GET -> .getBody("utf8")', function () { 42 | return request('GET', 'http://localhost:3000').getBody('utf8').then(function (body) { 43 | assert(body === 'body'); 44 | }); 45 | }); 46 | test(env + ' - POST json', function () { 47 | return request('POST', 'http://localhost:3000', {json: {foo: 'baz'}}).then(function (res) { 48 | assert(res.statusCode === 200); 49 | assert(res.body.toString() === 'json body'); 50 | }); 51 | }); 52 | test(env + ' - POST form', function () { 53 | const fd = new FormData(); 54 | fd.append('foo', 'baz'); 55 | return request('POST', 'http://localhost:3000/form', {form: fd}).then(function (res) { 56 | assert(res.statusCode === 200); 57 | assert(res.body.toString() === 'form body'); 58 | }); 59 | }); 60 | 61 | 62 | test(env + ' - invalid method', function () { 63 | return request({}, 'http://localhost:3000').then(function (res) { 64 | throw new Error('Expected an error'); 65 | }, function (err) { 66 | assert(err instanceof TypeError); 67 | }); 68 | }); 69 | test(env + ' - invalid url', function () { 70 | return request('GET', {}).then(function (res) { 71 | throw new Error('Expected an error'); 72 | }, function (err) { 73 | assert(err instanceof TypeError); 74 | }); 75 | }); 76 | test(env + ' - invalid options', function () { 77 | return request('GET', 'http://localhost:3000', 'options').then(function (res) { 78 | throw new Error('Expected an error'); 79 | }, function (err) { 80 | assert(err instanceof TypeError); 81 | }); 82 | }); 83 | } 84 | 85 | if (!process.env.CI || /^v8/.test(process.version)) { 86 | test('enable dom', () => mockDOM.enable()); 87 | testEnv('browser'); 88 | test('disable dom', () => mockDOM.enable()); 89 | } 90 | testEnv('server'); 91 | 92 | test('close mock server', () => { 93 | server.close(); 94 | }); -------------------------------------------------------------------------------- /src/browser.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import {HttpVerb} from 'http-basic/lib/HttpVerb'; 4 | import { IncomingHttpHeaders } from 'http'; 5 | import GenericResponse = require('http-response-object'); 6 | import Promise = require('promise'); 7 | import {Options} from './Options'; 8 | import toResponsePromise, {ResponsePromise} from './ResponsePromise'; 9 | import {RequestFn} from './RequestFn'; 10 | import handleQs from './handle-qs'; 11 | 12 | type Response = GenericResponse; 13 | export {HttpVerb, IncomingHttpHeaders as Headers, Options, ResponsePromise, Response}; 14 | 15 | function request(method: HttpVerb, url: string, options: Options): ResponsePromise { 16 | return toResponsePromise(new Promise(function (resolve, reject) { 17 | var xhr = new XMLHttpRequest(); 18 | 19 | // check types of arguments 20 | 21 | if (typeof method !== 'string') { 22 | throw new TypeError('The method must be a string.'); 23 | } 24 | if (typeof url !== 'string') { 25 | throw new TypeError('The URL/path must be a string.'); 26 | } 27 | if (options == null) { 28 | options = {}; 29 | } 30 | if (typeof options !== 'object') { 31 | throw new TypeError('Options must be an object (or null).'); 32 | } 33 | 34 | method = (method.toUpperCase() as any); 35 | 36 | 37 | function attempt(n: number, options: Options) { 38 | request(method, url, { 39 | qs: options.qs, 40 | headers: options.headers, 41 | timeout: options.timeout 42 | }).nodeify(function (err, res) { 43 | let retry = !!(err || res.statusCode >= 400); 44 | if (typeof options.retry === 'function') { 45 | retry = options.retry(err, res, n + 1); 46 | } 47 | if (n >= (options.maxRetries || 5)) { 48 | retry = false; 49 | } 50 | if (retry) { 51 | var delay = options.retryDelay; 52 | if (typeof options.retryDelay === 'function') { 53 | delay = options.retryDelay(err, res, n + 1); 54 | } 55 | delay = delay || 200; 56 | setTimeout(function () { 57 | attempt(n + 1, options); 58 | }, delay); 59 | } else { 60 | if (err) reject(err); 61 | else resolve(res); 62 | } 63 | }); 64 | } 65 | if (options.retry && method === 'GET') { 66 | return attempt(0, options); 67 | } 68 | 69 | let headers = options.headers || {}; 70 | 71 | // handle cross domain 72 | 73 | var match; 74 | var crossDomain = !!((match = /^([\w-]+:)?\/\/([^\/]+)/.exec(url)) && (match[2] != location.host)); 75 | if (!crossDomain) { 76 | headers = { 77 | ...headers, 78 | 'X-Requested-With': 'XMLHttpRequest', 79 | }; 80 | } 81 | 82 | // handle query string 83 | if (options.qs) { 84 | url = handleQs(url, options.qs); 85 | } 86 | 87 | // handle json body 88 | if (options.json) { 89 | options.body = JSON.stringify(options.json); 90 | headers = { 91 | ...headers, 92 | 'Content-Type': 'application/json', 93 | }; 94 | } 95 | 96 | if (options.form) { 97 | options.body = (options.form as any); 98 | } 99 | 100 | if (options.timeout) { 101 | xhr.timeout = options.timeout; 102 | const start = Date.now(); 103 | xhr.ontimeout = function () { 104 | var duration = Date.now() - start; 105 | var err = new Error('Request timed out after ' + duration + 'ms'); 106 | (err as any).timeout = true; 107 | (err as any).duration = duration; 108 | reject(err); 109 | }; 110 | } 111 | xhr.onreadystatechange = function () { 112 | if (xhr.readyState === 4) { 113 | var headers: {[key: string]: string} = {}; 114 | xhr.getAllResponseHeaders().split('\r\n').forEach(header => { 115 | var h = header.split(':'); 116 | if (h.length > 1) { 117 | headers[h[0].toLowerCase()] = h.slice(1).join(':').trim(); 118 | } 119 | }); 120 | var res = new GenericResponse(xhr.status, headers, xhr.responseText, url); 121 | resolve(res); 122 | } 123 | }; 124 | 125 | // method, url, async 126 | xhr.open(method, url, true); 127 | 128 | for (var name in headers) { 129 | xhr.setRequestHeader(name, (headers[name] as string)); 130 | } 131 | 132 | // avoid sending empty string (#319) 133 | xhr.send(options.body ? options.body : null); 134 | })); 135 | } 136 | 137 | const fd: any = FormData; 138 | export {fd as FormData}; 139 | export default (request as RequestFn); 140 | 141 | module.exports = request; 142 | module.exports.default = request; 143 | module.exports.FormData = fd; 144 | 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # then-request 2 | 3 | A request library that returns promises and supports both browsers and node.js 4 | 5 | [![Build Status](https://img.shields.io/travis/then/then-request/master.svg)](https://travis-ci.org/then/then-request) 6 | [![Dependency Status](https://img.shields.io/david/then/then-request.svg)](https://david-dm.org/then/then-request) 7 | [![NPM version](https://img.shields.io/npm/v/then-request.svg)](https://www.npmjs.org/package/then-request) 8 | 9 | 10 | Sponsor 11 | 12 | 13 | ## Installation 14 | 15 | npm install then-request 16 | 17 | ## Usage 18 | 19 | `request(method, url, options, callback?)` 20 | 21 | The following examples all work on both client and server. 22 | 23 | ```js 24 | var request = require('then-request'); 25 | 26 | request('GET', 'http://example.com').done(function (res) { 27 | console.log(res.getBody()); 28 | }); 29 | 30 | request('POST', 'http://example.com/json-api', {json: {some: 'values'}}).getBody('utf8').then(JSON.parse).done(function (res) { 31 | console.log(res); 32 | }); 33 | 34 | var FormData = request.FormData; 35 | var data = new FormData(); 36 | 37 | data.append('some', 'values'); 38 | 39 | request('POST', 'http://example.com/form-api', {form: data}).done(function (res) { 40 | console.log(res.getBody()); 41 | }); 42 | ``` 43 | 44 | Or with ES6 45 | 46 | ```js 47 | import request, {FormData} from 'then-request'; 48 | 49 | request('GET', 'http://example.com').done((res) => { 50 | console.log(res.getBody()); 51 | }); 52 | 53 | request('POST', 'http://example.com/json-api', {json: {some: 'values'}}).getBody('utf8').then(JSON.parse).done((res) => { 54 | console.log(res); 55 | }); 56 | 57 | var FormData = request.FormData; 58 | var data = new FormData(); 59 | 60 | data.append('some', 'values'); 61 | 62 | request('POST', 'http://example.com/form-api', {form: data}).done((res) => { 63 | console.log(res.getBody()); 64 | }); 65 | ``` 66 | 67 | **Method:** 68 | 69 | An HTTP method (e.g. `GET`, `POST`, `PUT`, `DELETE` or `HEAD`). It is not case sensitive. 70 | 71 | **URL:** 72 | 73 | A url as a string (e.g. `http://example.com`). Relative URLs are allowed in the browser. 74 | 75 | **Options:** 76 | 77 | - `qs` - an object containing querystring values to be appended to the uri 78 | - `headers` - http headers (default: `{}`) 79 | - `body` - body for PATCH, POST and PUT requests. Must be a `Buffer`, `ReadableStream` or `String` (only strings are accepted client side) 80 | - `json` - sets `body` but to JSON representation of value and adds `Content-type: application/json`. Does not have any affect on how the response is treated. 81 | - `form` - You can pass a `FormData` instance to the `form` option, this will manage all the appropriate headers for you. Does not have any affect on how the response is treated. 82 | - `cache` - only used in node.js (browsers already have their own caches) Can be `'memory'`, `'file'` or your own custom implementaton (see https://github.com/ForbesLindesay/http-basic#implementing-a-cache). 83 | - `followRedirects` - defaults to `true` but can be explicitly set to `false` on node.js to prevent then-request following redirects automatically. 84 | - `maxRedirects` - sets the maximum number of redirects to follow before erroring on node.js (default: `Infinity`) 85 | - `allowRedirectHeaders` (default: `null`) - an array of headers allowed for redirects (none if `null`). 86 | - `gzip` - defaults to `true` but can be explicitly set to `false` on node.js to prevent then-request automatically supporting the gzip encoding on responses. 87 | - `agent` - (default: `false`) - An `Agent` to controll keep-alive. When set to `false` use an `Agent` with default values. 88 | - `timeout` (default: `false`) - times out if no response is returned within the given number of milliseconds. 89 | - `socketTimeout` (default: `false`) - calls `req.setTimeout` internally which causes the request to timeout if no new data is seen for the given number of milliseconds. This option is ignored in the browser. 90 | - `retry` (default: `false`) - retry GET requests. Set this to `true` to retry when the request errors or returns a status code greater than or equal to 400 (can also be a function that takes `(err, req, attemptNo) => shouldRetry`) 91 | - `retryDelay` (default: `200`) - the delay between retries (can also be set to a function that takes `(err, res, attemptNo) => delay`) 92 | - `maxRetries` (default: `5`) - the number of times to retry before giving up. 93 | 94 | 95 | **Returns:** 96 | 97 | A [Promise](https://www.promisejs.org/) is returned that eventually resolves to the `Response`. The resulting Promise also has an additional `.getBody(encoding?)` method that is equivallent to calling `.then(function (res) { return res.getBody(encoding?); })`. 98 | 99 | ### Response 100 | 101 | Note that even for status codes that represent an error, the promise will be resolved as the request succeeded. You can call `getBody` if you want to error on invalid status codes. The response has the following properties: 102 | 103 | - `statusCode` - a number representing the HTTP status code 104 | - `headers` - http response headers 105 | - `body` - a string if in the browser or a buffer if on the server 106 | - `url` - the URL that was requested (in the case of redirects on the server, this is the final url that was requested) 107 | 108 | It also has a method `getBody(encoding?)` which looks like: 109 | 110 | ```js 111 | function getBody(encoding) { 112 | if (this.statusCode >= 300) { 113 | var err = new Error('Server responded with status code ' + this.statusCode + ':\n' + this.body.toString(encoding)); 114 | err.statusCode = this.statusCode; 115 | err.headers = this.headers; 116 | err.body = this.body; 117 | throw err; 118 | } 119 | return encoding ? this.body.toString(encoding) : this.body; 120 | } 121 | ``` 122 | 123 | ### FormData 124 | 125 | ```js 126 | var FormData = require('then-request').FormData; 127 | ``` 128 | 129 | Form data either exposes the node.js module, [form-data](https://www.npmjs.com/package/form-data), or the builtin browser object [FormData](https://developer.mozilla.org/en/docs/Web/API/FormData), as appropriate. 130 | 131 | They have broadly the same API, with the exception that form-data handles node.js streams and Buffers, while FormData handles the browser's `File` Objects. 132 | 133 | ## License 134 | 135 | MIT 136 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import GenericResponse = require('http-response-object'); 4 | import Promise = require('promise'); 5 | import concat = require('concat-stream'); 6 | import { IncomingHttpHeaders } from 'http'; 7 | import {Options} from './Options'; 8 | import toResponsePromise, {ResponsePromise} from './ResponsePromise'; 9 | import {RequestFn} from './RequestFn'; 10 | import handleQs from './handle-qs'; 11 | import _basicRequest, {HttpVerb} from 'http-basic'; 12 | import FormData = require('form-data'); 13 | 14 | type Response = GenericResponse; 15 | export {HttpVerb, IncomingHttpHeaders as Headers, Options, ResponsePromise, Response}; 16 | 17 | const caseless = require('caseless'); 18 | 19 | let basicRequest = _basicRequest; 20 | 21 | interface NormalizedBody { 22 | getHeaders(): Promise; 23 | pipe(stream: NodeJS.WritableStream): void; 24 | } 25 | class BufferBody implements NormalizedBody { 26 | private _body: Buffer; 27 | private _headers: IncomingHttpHeaders; 28 | constructor(body: Buffer, extraHeaders: IncomingHttpHeaders) { 29 | this._body = body; 30 | this._headers = extraHeaders; 31 | } 32 | getHeaders(): Promise { 33 | return Promise.resolve({'content-length': '' + this._body.length, ...this._headers}); 34 | } 35 | pipe(stream: NodeJS.WritableStream) { 36 | stream.end(this._body); 37 | } 38 | } 39 | class FormBody implements NormalizedBody { 40 | private _body: FormData; 41 | constructor(body: FormData) { 42 | this._body = body; 43 | } 44 | getHeaders(): Promise { 45 | const headers = this._body.getHeaders(); 46 | return new Promise((resolve, reject) => { 47 | let gotLength = false; 48 | this._body.getLength((err: any, length: number) => { 49 | if (gotLength) return; 50 | gotLength = true; 51 | if (err) { 52 | return reject( 53 | typeof err == 'string' 54 | ? new Error(err) 55 | : err 56 | ); 57 | } 58 | headers['content-length'] = '' + length; 59 | resolve(headers); 60 | }); 61 | }); 62 | } 63 | pipe(stream: NodeJS.WritableStream) { 64 | this._body.pipe(stream); 65 | } 66 | } 67 | class StreamBody implements NormalizedBody { 68 | private _body: NodeJS.ReadableStream; 69 | constructor(body: NodeJS.ReadableStream) { 70 | this._body = body; 71 | } 72 | getHeaders(): Promise { 73 | return Promise.resolve({}); 74 | } 75 | pipe(stream: NodeJS.WritableStream) { 76 | this._body.pipe(stream); 77 | } 78 | } 79 | function handleBody(options: Options): NormalizedBody { 80 | if (options.form) { 81 | return new FormBody(options.form); 82 | } 83 | const extraHeaders: {[key: string]: string | string[]} = {}; 84 | let body = options.body; 85 | if (options.json) { 86 | extraHeaders['content-type'] = 'application/json'; 87 | body = JSON.stringify(options.json); 88 | } 89 | if (typeof body === 'string') { 90 | body = Buffer.from(body); 91 | } 92 | if (!body) { 93 | body = Buffer.alloc(0); 94 | } 95 | if (!Buffer.isBuffer(body)) { 96 | if (typeof body.pipe === 'function') { 97 | return new StreamBody(body); 98 | } 99 | throw new TypeError('body should be a Buffer or a String'); 100 | } 101 | return new BufferBody(body, extraHeaders); 102 | } 103 | 104 | function request(method: HttpVerb, url: string, options: Options = {}): ResponsePromise { 105 | return toResponsePromise(new Promise((resolve: (v: Response) => void, reject: (e: any) => void) => { 106 | // check types of arguments 107 | 108 | if (typeof method !== 'string') { 109 | throw new TypeError('The method must be a string.'); 110 | } 111 | if (typeof url !== 'string') { 112 | throw new TypeError('The URL/path must be a string.'); 113 | } 114 | if (options == null) { 115 | options = {}; 116 | } 117 | if (typeof options !== 'object') { 118 | throw new TypeError('Options must be an object (or null).'); 119 | } 120 | 121 | method = (method.toUpperCase() as any); 122 | options.headers = options.headers || {}; 123 | var headers = caseless(options.headers); 124 | 125 | // handle query string 126 | if (options.qs) { 127 | url = handleQs(url, options.qs); 128 | } 129 | 130 | const duplex = !(method === 'GET' || method === 'DELETE' || method === 'HEAD'); 131 | if (duplex) { 132 | const body = handleBody(options); 133 | body.getHeaders().then(bodyHeaders => { 134 | Object.keys(bodyHeaders).forEach(key => { 135 | if (!headers.has(key)) { 136 | headers.set(key, bodyHeaders[key]); 137 | } 138 | }); 139 | ready(body); 140 | }).catch(reject); 141 | } else if (options.body) { 142 | throw new Error( 143 | 'You cannot pass a body to a ' + method + ' request.' 144 | ); 145 | } else { 146 | ready(); 147 | } 148 | function ready(body?: NormalizedBody) { 149 | const req = basicRequest(method, url, { 150 | allowRedirectHeaders: options.allowRedirectHeaders, 151 | headers: options.headers, 152 | followRedirects: options.followRedirects !== false, 153 | maxRedirects: options.maxRedirects, 154 | gzip: options.gzip !== false, 155 | cache: options.cache, 156 | agent: options.agent, 157 | timeout: options.timeout, 158 | socketTimeout: options.socketTimeout, 159 | retry: options.retry, 160 | retryDelay: options.retryDelay, 161 | maxRetries: options.maxRetries, 162 | 163 | isMatch: options.isMatch, 164 | isExpired: options.isExpired, 165 | canCache: options.canCache, 166 | }, (err: NodeJS.ErrnoException | null, res?: GenericResponse) => { 167 | if (err) return reject(err); 168 | if (!res) return reject(new Error('No request was received')); 169 | res.body.on('error', reject); 170 | res.body.pipe(concat((body: Buffer) => { 171 | resolve( 172 | new GenericResponse( 173 | res.statusCode, 174 | res.headers, 175 | Array.isArray(body) ? Buffer.alloc(0) : body, 176 | res.url 177 | ) 178 | ); 179 | })); 180 | }); 181 | 182 | if (req && body) { 183 | body.pipe(req); 184 | } 185 | } 186 | })); 187 | } 188 | 189 | export {FormData}; 190 | export default (request as RequestFn); 191 | 192 | module.exports = request; 193 | module.exports.default = request; 194 | module.exports.FormData = FormData; 195 | --------------------------------------------------------------------------------