From 7c0a40dfc8f77c0d2b8d42a21c6cb244da241102 Mon Sep 17 00:00:00 2001 From: Andrei Dumitrescu <5057797+andreidcm@users.noreply.github.com> Date: Wed, 20 May 2020 23:48:17 +0200 Subject: [PATCH] feat: Thin wrapper over node-fetch --- .babelrc | 19 -- .browserslistrc | 8 - .eslintrc | 5 +- .gitignore | 51 +----- .nycrc | 19 ++ CHANGELOG.md | 2 +- README.md | 93 +++++++++- src/browser.js | 132 -------------- src/fn.filter-by-value.js | 17 -- src/{fn.request-error.js => fn.http-error.js} | 10 +- src/{fn.set.js => fn.set-props.js} | 12 +- src/index.js | 172 ++++++++++++++++++ src/node.js | 132 -------------- tests/index.js | 29 +++ 14 files changed, 331 insertions(+), 370 deletions(-) delete mode 100644 .babelrc delete mode 100644 .browserslistrc create mode 100644 .nycrc delete mode 100644 src/browser.js delete mode 100644 src/fn.filter-by-value.js rename src/{fn.request-error.js => fn.http-error.js} (72%) rename src/{fn.set.js => fn.set-props.js} (59%) create mode 100644 src/index.js delete mode 100644 src/node.js create mode 100644 tests/index.js diff --git a/.babelrc b/.babelrc deleted file mode 100644 index bcaaf8b..0000000 --- a/.babelrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "presets": [ - // Use the latest JavaScript without needing to micromanage which syntax - // transforms (and optionally, browser polyfills) are needed by your - // target environment. - // - // Uses .browserslistrc to determine what plugins to use. - // - // https://babeljs.io/docs/en/babel-preset-env - "@babel/preset-env", - ], - - "plugins": [ - // Enables the re-use of Babel's injected helper code to save on codesize - "@babel/plugin-transform-runtime", - ], -} - - diff --git a/.browserslistrc b/.browserslistrc deleted file mode 100644 index f59bf2e..0000000 --- a/.browserslistrc +++ /dev/null @@ -1,8 +0,0 @@ -# Browsers that we support -# Use "npx browserslist" to list exact browsers - -> 0.5% -last 2 versions -Firefox ESR -not dead -node 12 diff --git a/.eslintrc b/.eslintrc index 6e49f37..ea6c6d3 100644 --- a/.eslintrc +++ b/.eslintrc @@ -4,11 +4,10 @@ "root": true, "extends": [ - "@mutant-ws/eslint-config/targets/html", + "@mutant-ws/eslint-config/targets/node", ], "globals": { - require: true, fixture: true, test: true }, @@ -23,8 +22,6 @@ "lifetime": 5, }, - // Can add a path segment here that will act like a source root, for - // in-project aliasing (i.e. `import MyStore from 'stores/my-store'`) "import/resolver": { "node": { "extensions": [".js"], diff --git a/.gitignore b/.gitignore index de15247..b78809d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,6 @@ logs npm-debug.log* yarn-debug.log* yarn-error.log* -lerna-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids @@ -20,12 +16,12 @@ lib-cov # Coverage directory used by tools like istanbul coverage -*.lcov # nyc test coverage .nyc_output +.coveralls.yml -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) @@ -44,21 +40,12 @@ jspm_packages/ # TypeScript v1 declaration files typings/ -# TypeScript cache -*.tsbuildinfo - # Optional npm cache directory .npm # Optional eslint cache .eslintcache -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - # Optional REPL history .node_repl_history @@ -70,36 +57,14 @@ typings/ # dotenv environment variables file .env -.env.test -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output +# next.js build output .next -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and *not* Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ +# Babel compile +dist/ -# TernJS port file +# Sublime text +*.sublime-project +*.sublime-workspace .tern-port diff --git a/.nycrc b/.nycrc new file mode 100644 index 0000000..ad4e231 --- /dev/null +++ b/.nycrc @@ -0,0 +1,19 @@ +{ + "lines": 100, + "statements": 100, + "functions": 100, + "branches": 100, + "include": [ + "src/**/*.js" + ], + "exclude": [ + "src/**/*.test.js", + "src/**/*.bench.js" + ], + "reporter": [], + "cache": true, + "all": true, + "sourceMap": true, + "instrument": true, + "report-dir": "./coverage" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ffcbe..f63efee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,4 +6,4 @@ Releases and changelog are automaticly handled by [semantic-release](https://git All releases are based on Angular's [Git commit message](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines) patterns. -See the [releases section](https://github.com/mutant-ws/fetch/releases) for details. +See the [releases section](https://github.com/mutant-ws/fetch-node/releases) for details. diff --git a/README.md b/README.md index 95d651a..d98111c 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,101 @@ -# fetch +# fetch-node + +Thin wrapper over [`node-fetch`](https://github.com/node-fetch/node-fetch). Sister libray of [`@mutant-ws/fetch-browser`](https://github.com/mutant-ws/fetch-browser). -* [About](#about) +* [Install](#install) +* [Initialize](#initialize) + * [Default headers](#default-headers) + * [Query string parameters](#query-string-parameters) +* [`GET`](#get) +* [`PATCH`](#patch) +* [`POST`](#post) +* [`DELETE`](#delete) +* [`MULTIPART`](#multipart) * [Changelog](#changelog) -## About +## Install + +```bash +npm i @mutant-ws/fetch-node +``` + +## Initialize + +```javascript +import { set } from "@mutant-ws/fetch-node" + +set({ + // Throws if not set and using relative paths + baseURL: "http://localhost", +}) +``` + +### Default headers + +```javascript +import { set } from "@mutant-ws/fetch-node" + +set({ + // Persistent headers + headers: { + // Library defaults + "accept": "application/json", + "content-type": "application/json", + + // Set JWT for authorized requests + authorization: "signed-payload-with-base64-over", + }, +}) +``` + +### Query string parameters + +There is no built-in way to handle query params but you can set a custom +transform function. + +```javascript +import { set } from "@mutant-ws/fetch-node" +import { stringify } from "qs" + +set({ + // Throws if query params passed and no stringify function defined + queryStringifyFn: source => + stringify(source, { + allowDots: true, + encode: false, + arrayFormat: "brackets", + strictNullHandling: true, + }) +}) +``` + +## `GET` + +```javascript +import { GET } from "@mutant-ws/fetch-node" + +const myIP = await GET("https://api.ipify.org", { + query: { + format: "json" + } +}) +// => {"ip":"213.127.80.141"} +``` + +## `PATCH` + +## `POST` + +## `DELETE` + +## `MULTIPART` ## Changelog -See the [releases section](https://github.com/mutant-ws/fetch/releases) for details. +See the [releases section](https://github.com/mutant-ws/fetch-node/releases) for details. diff --git a/src/browser.js b/src/browser.js deleted file mode 100644 index 2a74375..0000000 --- a/src/browser.js +++ /dev/null @@ -1,132 +0,0 @@ -/* eslint-disable import/exports-last */ - -import { - pipe, - reduce, - startsWith, - trim, - when, - same, - is, - isEmpty, -} from "@mutant-ws/m" - -import { set } from "./fn.set" -import { filterByValue } from "./fn.filter-by-value" -import { RequestError } from "./fn.request-error" - -/** - * Library options - */ -const props = { - baseURL: "", - headers: {}, - queryStringifyFn: null, -} - -/** - * `fetch` wrapper - * - * @param {String} url API endpoint - * @param {String} opt.method HTTP Method - * @param {Object} opt.headers HTTP Headers - * @param {Object} opt.body HTTP Body - * @param {Object} opt.query Query params - * - * @return {Promise} Resolves with response object if code is 20*. - * Reject all other response codes. - */ -const request = ( - url, - { method, body = {}, headers = {}, query = {}, isFile = false } = {} -) => { - const reqContent = { - method, - headers: filterByValue(is)({ - Accept: "application/json", - "Content-Type": "application/json", - ...props.headers, - ...headers, - }), - } - - // Avoid "HEAD or GET Request cannot have a body" - if (method !== "GET") { - reqContent.body = isFile ? body : JSON.stringify(body) - } - - const reqURL = pipe( - when( - isEmpty, - same(url), - source => `${url}?${props.queryStringifyFn(source)}` - ), - trim("/"), - source => `${props.baseURL}/${source}` - )(query) - - return window - .fetch(reqURL, reqContent) - .then(response => { - const isJSON = startsWith( - "application/json", - response.headers.get("Content-Type") - ) - - return Promise.all([response, isJSON ? response.json() : response.text()]) - }) - .then(([response, data]) => { - /* - * The Promise returned from fetch() won't reject on HTTP error status - * even if the response is an HTTP 404 or 500. Instead, it will resolve - * normally, and it will only reject on network failure or if anything - * prevented the request from completing. - */ - if (response.ok) { - return data - } - - throw new RequestError(response.statusText, { - status: response.status, - body: data, - url: reqURL, - }) - }) -} - -export const useProps = () => [props, set(props)] - -export const FILE = (url, { body = {}, headers } = {}) => { - const form = new FormData() - - return request(url, { - method: "POST", - body: pipe( - Object.entries, - reduce((acc, [key, value]) => { - acc.append(key, value) - - return acc - }, form) - )(body), - headers: { - ...headers, - - // remove content-type header or browser boundery wont get set - "Content-Type": null, - }, - isFile: true, - }) -} - -export const GET = (url, { query, headers } = {}) => - request(url, { method: "GET", query, headers }) - -export const POST = (url, { body, query, headers } = {}) => - request(url, { method: "POST", body, query, headers }) - -export const PATCH = (url, { body, query, headers } = {}) => - request(url, { method: "PATCH", body, query, headers }) - -export const DELETE = (url, { body, query, headers } = {}) => - request(url, { method: "DELETE", body, query, headers }) diff --git a/src/fn.filter-by-value.js b/src/fn.filter-by-value.js deleted file mode 100644 index 94eba8f..0000000 --- a/src/fn.filter-by-value.js +++ /dev/null @@ -1,17 +0,0 @@ -import { pipe, reduce } from "@mutant-ws/m" - -/** - * Filter object keys by testing value - * - * @param {Function} fn Predicate - * - * @returns {Object} - */ -export const filterByValue = fn => - pipe( - Object.entries, - reduce( - (acc, [key, value]) => (fn(value) ? { ...acc, [key]: value } : acc), - {} - ) - ) diff --git a/src/fn.request-error.js b/src/fn.http-error.js similarity index 72% rename from src/fn.request-error.js rename to src/fn.http-error.js index def8b09..8f2186c 100644 --- a/src/fn.request-error.js +++ b/src/fn.http-error.js @@ -1,5 +1,3 @@ -/* eslint-disable import/exports-last */ - /** * Custom Error thrown when fetch resolves with a status !== 20* * @@ -8,13 +6,15 @@ * @param {Number} opt.status Response status * @param {String|Object} opt.body Response body */ -export function RequestError(message, { url, status, body }) { +function HTTPError(message, { url, status, body }) { this.message = `${status} Server error: ${message}` - this.name = "RequestError" + this.name = "HTTPError" this.body = body this.status = status this.url = url this.stack = new Error().stack } -RequestError.prototype = new Error() +HTTPError.prototype = new Error() + +module.exports = { HTTPError } diff --git a/src/fn.set.js b/src/fn.set-props.js similarity index 59% rename from src/fn.set.js rename to src/fn.set-props.js index f0dac19..84fe22c 100644 --- a/src/fn.set.js +++ b/src/fn.set-props.js @@ -1,12 +1,12 @@ -import { trim, is, isObject } from "@mutant-ws/m" +const { trim, is, isObject } = require("@mutant-ws/m") -export const set = props => ({ baseURL, headers, queryStringifyFn }) => { +const setProps = props => ({ baseURL, headers, queryStringifyFn }) => { if (is(queryStringifyFn)) { if (typeof queryStringifyFn === "function") { props.queryStringifyFn = queryStringifyFn } else { throw new TypeError( - `mutant-fetch: "queryStringifyFn" should be of type function, received ${JSON.stringify( + `@mutant-ws/fetch-node: "queryStringifyFn" should be a function, received ${JSON.stringify( queryStringifyFn )}` ) @@ -21,7 +21,7 @@ export const set = props => ({ baseURL, headers, queryStringifyFn }) => { } } else { throw new TypeError( - `mutant-fetch: "headers" should be of type object, received ${JSON.stringify( + `@mutant-ws/fetch-node: "headers" should be an object, received ${JSON.stringify( headers )}` ) @@ -33,10 +33,12 @@ export const set = props => ({ baseURL, headers, queryStringifyFn }) => { props.baseURL = trim("/")(baseURL) } else { throw new TypeError( - `mutant-fetch: "baseURL" should be of type string, received ${JSON.stringify( + `@mutant-ws/fetch-node: "baseURL" should be a string, received ${JSON.stringify( baseURL )}` ) } } } + +module.exports = { setProps } diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..d077c14 --- /dev/null +++ b/src/index.js @@ -0,0 +1,172 @@ +/* eslint-disable import/exports-last */ + +const fetch = require("node-fetch") +const FormData = require("form-data") +const RFC3986 = require("rfc-3986") +const { + get, + pipe, + reduce, + startsWith, + trim, + when, + same, + toLower, + isEmpty, +} = require("@mutant-ws/m") + +const { setProps } = require("./fn.set-props") +const { HTTPError } = require("./fn.http-error") + +/** + * Config + */ +const props = { + baseURL: "", + headers: {}, + queryStringifyFn: null, +} + +/** + * `node-fetch` with qs support, default headers and rejects on status + * outside 200 + * + * @param {String} path API endpoint + * @param {String} opt.method HTTP Method + * @param {Object} opt.headers HTTP Headers + * @param {Object} opt.body HTTP Body + * @param {Object} opt.query Query params + * + * @return {Promise} Resolves with response object if code is 20*. + * Reject all other response codes. + */ +const request = ( + path, + { method, body = {}, headers = {}, query = {} } = {} +) => { + if (!isEmpty(query) && isEmpty(props.queryStringifyFn)) { + throw new TypeError( + `@mutant-ws/fetch-node: ${method}:${path} - Cannot send query params without providing "queryStringifyFn"` + ) + } + + const isPathURI = new RegExp(RFC3986.uri).test(path) + + if (isEmpty(props.baseURL) && !isPathURI) { + throw new TypeError( + `@mutant-ws/fetch-node: ${method}:${path} - Cannot make request with non-absolute path and no "baseURL"` + ) + } + + // - Remove all undefined values + // - toLower all keys + const HEADERS = reduce( + (acc, [key, value]) => + value === undefined + ? acc + : { + ...acc, + [toLower(key)]: value, + }, + {} + )( + Object.entries({ + accept: "application/json", + "content-type": "application/json", + ...props.headers, + ...headers, + }) + ) + + const isReqJSON = pipe( + get("content-type"), + startsWith("application/json") + )(HEADERS) + + const URI = pipe( + when( + isEmpty, + same(path), + source => `${path}?${props.queryStringifyFn(source)}` + ), + trim("/"), + source => (isPathURI ? source : `${props.baseURL}/${source}`) + )(query) + + return fetch(URI, { + method, + headers: HEADERS, + + // Avoid "HEAD or GET Request cannot have a body" + ...(method === "GET" + ? {} + : { body: isReqJSON ? JSON.stringify(body) : body }), + }) + .then(response => { + const isResJSON = startsWith("application/json")( + response.headers.get("Content-Type") + ) + + return Promise.all([ + response, + isResJSON ? response.json() : response.text(), + ]) + }) + .then(([response, data]) => { + /* + * The Promise returned from fetch() won't reject on HTTP error status + * even if the response is an HTTP 404 or 500. Instead, it will resolve + * normally, and it will only reject on network failure or if anything + * prevented the request from completing. + */ + if (response.ok) { + return data + } + + throw new HTTPError(response.statusText, { + status: response.status, + body: data, + path: URI, + }) + }) +} + +const set = setProps(props) + +const GET = (url, { query, headers } = {}) => + request(url, { method: "GET", query, headers }) + +const POST = (url, { body, query, headers } = {}) => + request(url, { method: "POST", body, query, headers }) + +const PATCH = (url, { body, query, headers } = {}) => + request(url, { method: "PATCH", body, query, headers }) + +const DELETE = (url, { body, query, headers } = {}) => + request(url, { method: "DELETE", body, query, headers }) + +const MULTIPART = (url, { body = {}, headers } = {}) => { + const form = new FormData() + + return request(url, { + method: "POST", + body: reduce((acc, [key, value]) => { + acc.append(key, value) + + return acc + }, form)(Object.entries(body)), + headers: { + ...form.getHeaders(), + ...headers, + }, + }) +} + +module.exports = { + set, + GET, + POST, + PATCH, + DELETE, + MULTIPART, +} diff --git a/src/node.js b/src/node.js deleted file mode 100644 index ea02baf..0000000 --- a/src/node.js +++ /dev/null @@ -1,132 +0,0 @@ -/* eslint-disable import/exports-last */ - -import fetch from "node-fetch" -import { - pipe, - reduce, - startsWith, - trim, - when, - same, - is, - isEmpty, -} from "@mutant-ws/m" - -import { set } from "./fn.set" -import { filterByValue } from "./fn.filter-by-value" -import { RequestError } from "./fn.request-error" - -/** - * Library options - */ -const props = { - baseURL: "", - headers: {}, - queryStringifyFn: null, -} - -/** - * `fetch` wrapper - * - * @param {String} url API endpoint - * @param {String} opt.method HTTP Method - * @param {Object} opt.headers HTTP Headers - * @param {Object} opt.body HTTP Body - * @param {Object} opt.query Query params - * - * @return {Promise} Resolves with response object if code is 20*. - * Reject all other response codes. - */ -const request = ( - url, - { method, body = {}, headers = {}, query = {}, isFile = false } = {} -) => { - const reqContent = { - method, - headers: filterByValue(is)({ - Accept: "application/json", - "Content-Type": "application/json", - ...props.headers, - ...headers, - }), - } - - // Avoid "HEAD or GET Request cannot have a body" - if (method !== "GET") { - reqContent.body = isFile ? body : JSON.stringify(body) - } - - const reqURL = pipe( - when( - isEmpty, - same(url), - source => `${url}?${props.queryStringifyFn(source)}` - ), - trim("/"), - source => `${props.baseURL}/${source}` - )(query) - - return fetch(reqURL, reqContent) - .then(response => { - const isJSON = startsWith( - "application/json", - response.headers.get("Content-Type") - ) - - return Promise.all([response, isJSON ? response.json() : response.text()]) - }) - .then(([response, data]) => { - /* - * The Promise returned from fetch() won't reject on HTTP error status - * even if the response is an HTTP 404 or 500. Instead, it will resolve - * normally, and it will only reject on network failure or if anything - * prevented the request from completing. - */ - if (response.ok) { - return data - } - - throw new RequestError(response.statusText, { - status: response.status, - body: data, - url: reqURL, - }) - }) -} - -export const useProps = () => [props, set(props)] - -export const FILE = (url, { body = {}, headers } = {}) => { - const form = new FormData() - - return request(url, { - method: "POST", - body: pipe( - Object.entries, - reduce((acc, [key, value]) => { - acc.append(key, value) - - return acc - }, form) - )(body), - headers: { - ...headers, - - // remove content-type header or browser boundery wont get set - "Content-Type": null, - }, - isFile: true, - }) -} - -export const GET = (url, { query, headers } = {}) => - request(url, { method: "GET", query, headers }) - -export const POST = (url, { body, query, headers } = {}) => - request(url, { method: "POST", body, query, headers }) - -export const PATCH = (url, { body, query, headers } = {}) => - request(url, { method: "PATCH", body, query, headers }) - -export const DELETE = (url, { body, query, headers } = {}) => - request(url, { method: "DELETE", body, query, headers }) diff --git a/tests/index.js b/tests/index.js new file mode 100644 index 0000000..57717ee --- /dev/null +++ b/tests/index.js @@ -0,0 +1,29 @@ +/* eslint-disable new-cap,no-sync */ + +const { describe } = require("riteway") +const qs = require("qs") + +const { set, GET } = require("../src") + +set({ + queryStringifyFn: source => qs.stringify(source), +}) + +describe("fetch-node", async assert => { + const data = await GET("https://api.ipify.org", { + query: { + format: "json", + }, + }) + + assert({ + given: "the burning need to know one's public IP", + should: "do a GET request to ipify.org", + actual: { + hasIpField: typeof data.ip === "string", + }, + expected: { + hasIpField: true, + }, + }) +})