├── .editorconfig ├── .gitignore ├── .travis.yml ├── httpie.d.ts ├── license ├── logo.png ├── package.json ├── readme.md ├── src └── index.js └── test ├── index.js └── server └── index.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = tab 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{json,yml,md}] 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | *-lock.json 4 | *.lock 5 | *.log 6 | dist 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8 4 | 5 | before_script: 6 | - npm i -g nyc codecov 7 | 8 | script: 9 | - nyc --include=src npm test 10 | 11 | after_success: 12 | - nyc report --reporter=text-lcov > coverage.lcov 13 | - codecov 14 | -------------------------------------------------------------------------------- /httpie.d.ts: -------------------------------------------------------------------------------- 1 | import {Url, URL} from "url"; 2 | import {IncomingMessage} from "http"; 3 | 4 | export interface HttpieOptions { 5 | body: any, 6 | headers: { 7 | [name: string]: string 8 | }, 9 | redirect: boolean, 10 | reviver: (key: string, value: any) => any, 11 | } 12 | 13 | export interface HttpieResponse extends IncomingMessage { 14 | data: T, 15 | } 16 | 17 | export declare function send(method: string, uri: string | Url | URL, opts?: Partial): Promise>; 18 | 19 | declare function method(uri: string | Url | URL, opts?: Partial): Promise>; 20 | 21 | export { 22 | method as get, 23 | method as post, 24 | method as patch, 25 | method as del, 26 | method as put, 27 | }; 28 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Luke Edwards (lukeed.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukeed/httpie/8d8f45e9d6be87a1782a0f0ad165047e50ea6009/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "httpie", 3 | "version": "1.1.2", 4 | "repository": "lukeed/httpie", 5 | "description": "A lightweight, Promise-based wrapper for Node.js HTTP requests~!", 6 | "module": "dist/httpie.mjs", 7 | "main": "dist/httpie.js", 8 | "types": "httpie.d.ts", 9 | "license": "MIT", 10 | "author": { 11 | "name": "Luke Edwards", 12 | "email": "luke.edwards05@gmail.com", 13 | "url": "lukeed.com" 14 | }, 15 | "engines": { 16 | "node": ">=8" 17 | }, 18 | "scripts": { 19 | "build": "bundt", 20 | "pretest": "npm run build", 21 | "test": "tape -r esm test/*.js | tap-spec" 22 | }, 23 | "files": [ 24 | "*.d.ts", 25 | "dist" 26 | ], 27 | "keywords": [ 28 | "http", 29 | "client", 30 | "request", 31 | "promise" 32 | ], 33 | "devDependencies": { 34 | "bundt": "^0.2.0", 35 | "esm": "^3.2.0", 36 | "tap-spec": "^5.0.0", 37 | "tape": "^4.9.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 | httpie 3 |
4 | 5 | 19 | 20 |
A Node.js HTTP client as easy as pie!
21 | 22 | ## Features 23 | 24 | * `Promise`- based HTTP requestor 25 | * Works with HTTP and HTTPS protocols 26 | * Automatically handles JSON requests and responses 27 | * Extremely lightweight with **no dependencies** 678 bytes! 28 | * Includes aliases for common HTTP verbs: `get`, `post`, `put`, `patch`, and `del` 29 | 30 | Additionally, this module is delivered as: 31 | 32 | * **ES Module**: [`dist/httpie.mjs`](https://unpkg.com/httpie/dist/httpie.mjs) 33 | * **CommonJS**: [`dist/httpie.js`](https://unpkg.com/httpie/dist/httpie.js) 34 | 35 | 36 | ## Install 37 | 38 | ``` 39 | $ npm install --save httpie 40 | ``` 41 | 42 | 43 | ## Usage 44 | 45 | > **Note:** The `async` syntax is for demo purposes – you may use Promises in a Node 6.x environment too! 46 | 47 | ```js 48 | import { get, post } from 'httpie'; 49 | 50 | try { 51 | const { data } = await get('https://pokeapi.co/api/v2/pokemon/1'); 52 | 53 | // Demo: Endpoint will echo what we've sent 54 | const res = await post('https://jsonplaceholder.typicode.com/posts', { 55 | body: { 56 | id: data.id, 57 | name: data.name, 58 | number: data.order, 59 | moves: data.moves.slice(0, 6) 60 | } 61 | }); 62 | 63 | console.log(res.statusCode); //=> 201 64 | console.log(res.data); //=> { id: 1, name: 'bulbasaur', number: 1, moves: [{...}, {...}] } 65 | } catch (err) { 66 | console.error('Error!', err.statusCode, err.message); 67 | console.error('~> headers:', err.headers); 68 | console.error('~> data:', err.data); 69 | } 70 | ``` 71 | 72 | 73 | ## API 74 | 75 | ### send(method, url, opts={}) 76 | Returns: `Promise` 77 | 78 | Any `httpie.send` request (and its aliases) will always return a Promise. 79 | 80 | If the response's `statusCode` is 400 or above, this Promise will reject with a formatted error – see [Error Handling](#error-handling). Otherwise, the Promise will resolve with the full [`ClientRequest`](https://nodejs.org/api/http.html#http_class_http_clientrequest) stream. 81 | 82 | The resolved response will receive a new `data` key, which will contain the response's full payload. Should the response return JSON content, then `httpie` will parse it and the `res.data` value will be the resulting JSON object! 83 | 84 | #### method 85 | Type: `String` 86 | 87 | The HTTP method name – it must be uppercase! 88 | 89 | #### url 90 | Type: `String` or [`URL`](https://nodejs.org/api/url.html#url_the_whatwg_url_api) 91 | 92 | If `url` is a string, it is automatically parsed with [`url.parse()`](https://nodejs.org/api/url.html#url_url_parse_urlstring_parsequerystring_slashesdenotehost) into an object. 93 | 94 | #### opts.body 95 | Type: `Mixed`
96 | Default: `undefined` 97 | 98 | The request's body, can be of any type! 99 | 100 | Any non-`Buffer` objects will be converted into a JSON string and the appropriate [`Content-Type`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) header will be attached. 101 | 102 | Additionally, `httpie` will _always_ set a value for the [`Content-Length`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length) header! 103 | 104 | #### opts.headers 105 | Type: `Object`
106 | Default: `{}` 107 | 108 | The custom headers to send with your request. 109 | 110 | #### opts.redirect 111 | Type: `Boolean`
112 | Default: `true` 113 | 114 | Whether or not redirect responses should be followed automatically. 115 | 116 | > **Note:** This may only happen with a 3xx status _and_ if the response had a [`Location`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Location) header. 117 | 118 | #### opts.reviver 119 | Type: `Function`
120 | Default: `undefined` 121 | 122 | An optional function that's passed directly to [`JSON.parse`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#Parameters), allowing you transform aspects of the response data before the `httpie` request resolves. 123 | 124 | > **Note:** This will _only_ run if `httpie` detects that JSON is contained in the response! 125 | 126 | ### get(url, opts={}) 127 | > Alias for [`send('GET', url, opts)`](#sendmethod-url-opts). 128 | 129 | ### post(url, opts={}) 130 | > Alias for [`send('POST', url, opts)`](#sendmethod-url-opts). 131 | 132 | ### put(url, opts={}) 133 | > Alias for [`send('PUT', url, opts)`](#sendmethod-url-opts). 134 | 135 | ### patch(url, opts={}) 136 | > Alias for [`send('PATCH', url, opts)`](#sendmethod-url-opts). 137 | 138 | ### del(url, opts={}) 139 | > Alias for [`send('DELETE', url, opts)`](#sendmethod-url-opts). 140 | 141 | 142 | ## Error Handling 143 | 144 | All responses with `statusCode >= 400` will result in a rejected `httpie` request. When this occurs, an Error instance is formatted with complete information: 145 | 146 | * `err.message` – `String` – Identical to `err.statusMessage`; 147 | * `err.statusMessage` – `String` – The response's `statusMessage` value; 148 | * `err.statusCode` – `Number` – The response's `statusCode` value; 149 | * `err.headers` – `Object` – The response's `headers` object; 150 | * `err.data` – `Mixed` – The response's payload; 151 | 152 | > **Important:** The error's `data` property may also be parsed to a JSON object, according to the response's headers. 153 | 154 | ```js 155 | import { get } from 'httpie'; 156 | 157 | get('https://example.com/404').catch(err => { 158 | console.error(`(${err.statusCode}) ${err.message}`) 159 | console.error(err.headers['content-type']); 160 | console.error(`~> ${err.data}`); 161 | }); 162 | //=> "(404) Not Found" 163 | //=> "text/html; charset=UTF-8" 164 | //=> ~> \n\n 165 | ``` 166 | 167 | ## License 168 | 169 | MIT © [Luke Edwards](https://lukeed.com) 170 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { request } from 'https'; 2 | import { globalAgent } from 'http'; 3 | import { parse, resolve } from 'url'; 4 | 5 | function toError(rej, res, err) { 6 | err = err || new Error(res.statusMessage); 7 | err.statusMessage = res.statusMessage; 8 | err.statusCode = res.statusCode; 9 | err.headers = res.headers; 10 | err.data = res.data; 11 | rej(err); 12 | } 13 | 14 | export function send(method, uri, opts={}) { 15 | return new Promise((res, rej) => { 16 | let out = ''; 17 | opts.method = method; 18 | let { redirect=true } = opts; 19 | if (uri && !!uri.toJSON) uri = uri.toJSON(); 20 | Object.assign(opts, typeof uri === 'string' ? parse(uri) : uri); 21 | opts.agent = opts.protocol === 'http:' ? globalAgent : void 0; 22 | 23 | let req = request(opts, r => { 24 | r.setEncoding('utf8'); 25 | 26 | r.on('data', d => { 27 | out += d; 28 | }); 29 | 30 | r.on('end', () => { 31 | let type = r.headers['content-type']; 32 | if (type && out && type.includes('application/json')) { 33 | try { 34 | out = JSON.parse(out, opts.reviver); 35 | } catch (err) { 36 | return toError(rej, r, err); 37 | } 38 | } 39 | r.data = out; 40 | if (r.statusCode >= 400) { 41 | toError(rej, r); 42 | } else if (r.statusCode > 300 && redirect && r.headers.location) { 43 | opts.path = resolve(opts.path, r.headers.location); 44 | return send(method, opts.path.startsWith('/') ? opts : opts.path, opts).then(res, rej); 45 | } else { 46 | res(r); 47 | } 48 | }); 49 | }); 50 | 51 | req.on('error', rej); 52 | 53 | if (opts.body) { 54 | let isObj = typeof opts.body === 'object' && !Buffer.isBuffer(opts.body); 55 | let str = isObj ? JSON.stringify(opts.body) : opts.body; 56 | isObj && req.setHeader('content-type', 'application/json'); 57 | req.setHeader('content-length', Buffer.byteLength(str)); 58 | req.write(str); 59 | } 60 | 61 | req.end(); 62 | }); 63 | } 64 | 65 | export const get = send.bind(null, 'GET'); 66 | export const post = send.bind(null, 'POST'); 67 | export const patch = send.bind(null, 'PATCH'); 68 | export const del = send.bind(null, 'DELETE'); 69 | export const put = send.bind(null, 'PUT'); 70 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import { parse, URL } from 'url'; 3 | import * as httpie from '../src'; 4 | import server from './server'; 5 | 6 | // Demo: https://reqres.in/api 7 | function isResponse(t, res, code, expected) { 8 | t.is(res.statusCode, code, `~> statusCode = ${code}`); 9 | t.ok(res.headers.etag, `~> headers.etag exists`); 10 | t.ok(res.headers['content-type'], `~> headers['content-type'] exists`); 11 | t.ok(res.headers['content-length'], `~> headers['content-length'] exists`); 12 | 13 | t.is(Object.prototype.toString.call(res.data), '[object Object]', '~> res.data is an object'); 14 | if (expected) t.same(res.data, expected, '~~> is expected response data!'); 15 | } 16 | 17 | test('exports', t => { 18 | t.plan(8); 19 | t.is(typeof httpie, 'object', 'exports an Object'); 20 | ['send', 'get', 'post', 'put', 'patch', 'del'].forEach(k => { 21 | t.is(typeof httpie[k], 'function', `~> httpie.${k} is a function`); 22 | }); 23 | let out = httpie.send('GET', 'https://www.google.com'); 24 | t.true(out instanceof Promise, '~> always returns a Promise!'); 25 | }); 26 | 27 | test('GET (200)', async t => { 28 | t.plan(8); 29 | let res = await httpie.get('https://reqres.in/api/users/2'); 30 | isResponse(t, res, 200); 31 | 32 | let data = res.data; 33 | t.ok(!!data.data, '~~> had "data" key'); 34 | t.is(data.data.id, 2, '~~> had "data.id" value'); 35 | t.is(data.data.first_name, 'Janet', '~~> had "data.first_name" value'); 36 | }); 37 | 38 | test('GET (404)', async t => { 39 | t.plan(9); 40 | try { 41 | await httpie.get('https://reqres.in/api/users/23'); 42 | t.pass('i will not run'); 43 | } catch (err) { 44 | t.true(err instanceof Error, '~> returns a true Error instance'); 45 | t.is(err.message, err.statusMessage, '~> the "message" and "statusMessage" are identical'); 46 | t.is(err.message, 'Not Found', '~~> Not Found'); 47 | isResponse(t, err, 404, {}); // +6 48 | } 49 | }); 50 | 51 | test('POST (201)', async t => { 52 | t.plan(9); 53 | 54 | let body = { 55 | name: 'morpheus', 56 | job: 'leader' 57 | }; 58 | 59 | let res = await httpie.post('https://reqres.in/api/users', { body }); 60 | 61 | isResponse(t, res, 201); 62 | t.ok(!!res.data.id, '~~> created item w/ "id" value'); 63 | t.is(res.data.job, body.job, '~~> created item w/ "job" value'); 64 | t.is(res.data.name, body.name, '~~> created item w/ "name" value'); 65 | t.ok(!!res.data.createdAt, '~~> created item w/ "createdAt" value'); 66 | }); 67 | 68 | test('PUT (200)', async t => { 69 | t.plan(8); 70 | 71 | let body = { 72 | name: 'morpheus', 73 | job: 'zion resident' 74 | }; 75 | 76 | let res = await httpie.put('https://reqres.in/api/users/2', { body }); 77 | 78 | isResponse(t, res, 200); 79 | t.is(res.data.job, body.job, '~~> created item w/ "job" value'); 80 | t.is(res.data.name, body.name, '~~> created item w/ "name" value'); 81 | t.ok(!!res.data.updatedAt, '~~> created item w/ "updatedAt" value'); 82 | }); 83 | 84 | test('PATCH (200)', async t => { 85 | t.plan(8); 86 | 87 | let body = { 88 | name: 'morpheus', 89 | job: 'rebel' 90 | }; 91 | 92 | let res = await httpie.patch('https://reqres.in/api/users/2', { body }); 93 | 94 | isResponse(t, res, 200); 95 | t.is(res.data.job, body.job, '~~> created item w/ "job" value'); 96 | t.is(res.data.name, body.name, '~~> created item w/ "name" value'); 97 | t.ok(!!res.data.updatedAt, '~~> created item w/ "updatedAt" value'); 98 | }); 99 | 100 | test('DELETE (204)', async t => { 101 | t.plan(2); 102 | let res = await httpie.del('https://reqres.in/api/users/2'); 103 | t.is(res.statusCode, 204); 104 | t.is(res.data, ''); 105 | }); 106 | 107 | test('GET (HTTP -> HTTPS)', async t => { 108 | t.plan(6); 109 | let res = await httpie.get('http://reqres.in/api/users'); 110 | t.is(res.req.agent.protocol, 'https:', '~> follow-up request with HTTPS'); 111 | isResponse(t, res, 200); 112 | }); 113 | 114 | test('GET (301 = redirect:false)', async t => { 115 | t.plan(4); 116 | let res = await httpie.get('http://reqres.in/api/users', { redirect:0 }); 117 | t.is(res.statusCode, 301, '~> statusCode = 301'); 118 | t.is(res.statusMessage, 'Moved Permanently', '~> "Moved Permanently"'); 119 | t.is(res.headers.location, 'https://reqres.in/api/users', '~> has "Location" header'); 120 | t.is(res.data, '', '~> res.data is empty string'); 121 | }); 122 | 123 | test('GET (delay)', async t => { 124 | t.plan(3); 125 | let now = Date.now(); 126 | let res = await httpie.send('GET', 'https://reqres.in/api/users?delay=5'); 127 | t.is(res.statusCode, 200, '~> res.statusCode = 200'); 128 | t.is(typeof res.data, 'object', '~> res.data is an object'); 129 | t.true(Date.now() - now >= 5e3, '~> waited at least 5 seconds'); 130 | }); 131 | 132 | test('POST (string body w/ object url)', async t => { 133 | t.plan(7); 134 | const body = 'peter@klaven'; 135 | const uri = parse('https://reqres.in/api/login'); 136 | await httpie.post(uri, { body }).catch(err => { 137 | t.is(err.message, 'Bad Request'); 138 | isResponse(t, err, 400, { 139 | error: 'Missing email or username' 140 | }); 141 | }); 142 | }); 143 | 144 | test('custom headers', async t => { 145 | t.plan(2); 146 | let headers = { 'X-FOO': 'BAR123' }; 147 | let res = await httpie.get('https://reqres.in/api/users', { headers }); 148 | let sent = res.req.getHeader('x-foo'); 149 | 150 | t.is(res.statusCode, 200, '~> statusCode = 200'); 151 | t.is(sent, 'BAR123', '~> sent custom "X-FOO" header'); 152 | }); 153 | 154 | function reviver(key, val) { 155 | if (key.includes('_')) return; // removes 156 | return typeof val === 'number' ? String(val) : val; 157 | } 158 | 159 | test('GET (reviver)', async t => { 160 | t.plan(5); 161 | let res = await httpie.get('https://reqres.in/api/users', { reviver }); 162 | t.is(res.statusCode, 200, '~> statusCode = 200'); 163 | 164 | t.is(res.data.per_page, undefined, '~> removed "per_page" key'); 165 | t.is(typeof res.data.page, 'string', '~> converted numbers to strings'); 166 | t.is(res.data.data[1].first_name, undefined, `~> (deep) removed "first_name" key`); 167 | t.is(typeof res.data.data[1].id, 'string', `~> (deep) converted numbers to strings`); 168 | }); 169 | 170 | test('GET (reviver w/ redirect)', async t => { 171 | t.plan(6); 172 | let res = await httpie.get('http://reqres.in/api/users', { reviver }); 173 | t.is(res.req.agent.protocol, 'https:', '~> follow-up request with HTTPS'); 174 | t.is(res.statusCode, 200, '~> statusCode = 200'); 175 | 176 | t.is(res.data.per_page, undefined, '~> removed "per_page" key'); 177 | t.is(typeof res.data.page, 'string', '~> converted numbers to strings'); 178 | t.is(res.data.data[1].first_name, undefined, `~> (deep) removed "first_name" key`); 179 | t.is(typeof res.data.data[1].id, 'string', `~> (deep) converted numbers to strings`); 180 | t.end(); 181 | }); 182 | 183 | test('via Url (legacy)', async t => { 184 | t.plan(5); 185 | let foo = parse('https://reqres.in/api/users/2'); 186 | let res = await httpie.get(foo); 187 | isResponse(t, res, 200); 188 | }); 189 | 190 | test('via URL (WHATWG)', async t => { 191 | t.plan(5); 192 | let foo = new URL('https://reqres.in/api/users/2'); 193 | let res = await httpie.get(foo); 194 | isResponse(t, res, 200); 195 | }); 196 | 197 | test('Error: Invalid JSON', async t => { 198 | t.plan(7); 199 | let ctx = await server(); 200 | await httpie.get(`http://localhost:${ctx.port}/any`).catch(err => { 201 | t.true(err instanceof SyntaxError, '~> caught SyntaxError'); 202 | t.true(err.message.includes('Unexpected token'), '~> had "Unexpected token" message'); 203 | t.true(err.stack.includes('JSON.parse'), '~> printed `JSON.parse` in stack'); 204 | 205 | t.is(err.statusCode, 200, `~> statusCode = 200`); 206 | t.ok(err.headers['content-type'], `~> headers['content-type'] exists`); 207 | t.ok(err.headers['content-length'], `~> headers['content-length'] exists`); 208 | t.is(err.data, undefined, '~> err.data is undefined'); 209 | 210 | ctx.close(); 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /test/server/index.js: -------------------------------------------------------------------------------- 1 | import { createServer } from 'http'; 2 | 3 | function handler(req, res) { 4 | res.setHeader('Content-Type', 'application/json'); 5 | res.end('{invalid_json'); 6 | } 7 | 8 | export default async function () { 9 | return new Promise(res => { 10 | let app = createServer(handler).listen(); 11 | let close = app.close.bind(app); 12 | let { port } = app.address(); 13 | return res({ port, close }); 14 | }); 15 | } 16 | --------------------------------------------------------------------------------