├── .eslintrc ├── .gitignore ├── README.md ├── distributions ├── ffetch.js └── index.js ├── package.json ├── sources ├── FFetch.js └── index.js └── tests ├── FFetch.test.js └── index.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "eslint:recommended", 4 | "rules": { 5 | "comma-dangle": [1, "always-multiline"], 6 | 7 | "accessor-pairs": 2, 8 | "block-scoped-var": 2, 9 | "complexity": [2, 8], 10 | "consistent-return": 2, 11 | "curly": [0, "multi"], 12 | "default-case": 2, 13 | "dot-notation": [2, { "allowKeywords": true }], 14 | "dot-location": [2, "property"], 15 | "eqeqeq": [2, "allow-null"], 16 | "guard-for-in": 2, 17 | "no-alert": 1, 18 | "no-caller": 2, 19 | "no-div-regex": 2, 20 | "no-else-return": 1, 21 | "no-empty-label": 1, 22 | "no-eq-null": 0, 23 | "no-eval": 2, 24 | "no-extend-native": 2, 25 | "no-extra-bind": 2, 26 | "no-fallthrough": 2, 27 | "no-floating-decimal": 2, 28 | "no-implicit-coercion": [2, { "boolean": false }], 29 | "no-implied-eval": 2, 30 | "no-invalid-this": 2, 31 | "no-iterator": 2, 32 | "no-labels": 1, 33 | "no-lone-blocks": 2, 34 | "no-loop-func": 2, 35 | "no-multi-spaces": [1, { 36 | "exceptions": { 37 | "Property": true, 38 | "VariableDeclarator": true, 39 | "ImportDeclaration": true 40 | } 41 | }], 42 | "no-multi-str": 2, 43 | "no-native-reassign": 2, 44 | "no-new-func": 1, 45 | "no-new-wrappers": 2, 46 | "no-new": 2, 47 | "no-octal-escape": 2, 48 | "no-octal": 2, 49 | "no-param-reassign": 2, 50 | "no-process-env": 1, 51 | "no-proto": 2, 52 | "no-redeclare": 1, 53 | "no-return-assign": 2, 54 | "no-script-url": 1, 55 | "no-self-compare": 1, 56 | "no-sequences": 2, 57 | "no-throw-literal": 2, 58 | "no-unused-expressions": 1, 59 | "no-useless-call": 1, 60 | "no-useless-concat": 1, 61 | "no-void": 2, 62 | "no-warning-comments": 0, 63 | "no-with": 2, 64 | "radix": 2, 65 | "vars-on-top": 0, 66 | "wrap-iife": [2, "outside"], 67 | "yoda": 2, 68 | 69 | "init-declarations": [1, "always"], 70 | "no-catch-shadow": 2, 71 | "no-delete-var": 2, 72 | "no-label-var": 2, 73 | "no-shadow-restricted-names": 2, 74 | "no-shadow": 1, 75 | "no-undef-init": 2, 76 | "no-undefined": 2, 77 | "no-unused-vars": [1, { "vars": "all", "args": "after-used" }], 78 | "no-use-before-define": 0, 79 | 80 | "callback-return": [2, ["callback", "next", "done", "resolve", "reject"]], 81 | "handle-callback-err": 2, 82 | "no-mixed-requires": [1, true], 83 | "no-new-require": 2, 84 | "no-path-concat": 1, 85 | "no-process-exit": 0, 86 | "no-restricted-modules": 0, 87 | "no-sync": 2, 88 | 89 | "array-bracket-spacing": [1, "never"], 90 | "block-spacing": [1, "always"], 91 | "brace-style": [1, "1tbs", { "allowSingleLine": true }], 92 | "camelcase": [1, { "properties": "never" }], 93 | "comma-spacing": [1, { "before": false, "after": true }], 94 | "comma-style": 0, 95 | "computed-property-spacing": [1, "never"], 96 | "consistent-this": 2, 97 | "eol-last": 2, 98 | "func-names": 0, 99 | "func-style": [2, "expression"], 100 | "id-length": 0, 101 | "id-match": 0, 102 | "indent": [1, 2], 103 | "key-spacing": 0, 104 | "linebreak-style": [1, "unix"], 105 | "max-nested-callbacks": [1, 3], 106 | "new-cap": [2, { "newIsCap": true, "capIsNew": false }], 107 | "new-parens": 2, 108 | "newline-after-var": [1, "always"], 109 | "no-array-constructor": 2, 110 | "no-continue": 1, 111 | "no-inline-comments": 0, 112 | "no-lonely-if": 1, 113 | "no-mixed-spaces-and-tabs": [1, "smart-tabs"], 114 | "no-multiple-empty-lines": 1, 115 | "no-nested-ternary": 1, 116 | "no-negated-condition": 2, 117 | "no-new-object": 2, 118 | "no-spaced-func": 1, 119 | "no-ternary": 0, 120 | "no-trailing-spaces": 1, 121 | "no-underscore-dangle": 0, 122 | "no-unneeded-ternary": 1, 123 | "object-curly-spacing": [1, "always"], 124 | "one-var": [1, { "initialized": "never", "uninitialized": "always" }], 125 | "operator-assignment": [1, "never"], 126 | "operator-linebreak": 0, 127 | "padded-blocks": [1, "never"], 128 | "quote-props": [1, "as-needed"], 129 | "quotes": [1, "single"], 130 | "semi-spacing": [1, { "before": false, "after": true }], 131 | "semi": [1, "always"], 132 | "sort-vars": 0, 133 | "space-after-keywords": [1, "always"], 134 | "space-before-blocks": [1, "always"], 135 | "space-before-function-paren": [1, "never"], 136 | "space-in-parens": [1, "never"], 137 | "space-infix-ops": 1, 138 | "space-return-throw-case": 1, 139 | "space-unary-ops": 1, 140 | "spaced-comment": [1, "always"], 141 | "wrap-regex": 0, 142 | 143 | "arrow-parens": [1, "as-needed"], 144 | "arrow-spacing": [1, { "before": true, "after": true }], 145 | "constructor-super": 2, 146 | "generator-star-spacing": [1, "after"], 147 | "no-class-assign": 2, 148 | "no-const-assign": 2, 149 | "no-dupe-class-members": 2, 150 | "no-this-before-super": 2, 151 | "no-var": 2, 152 | "object-shorthand": [1, "always"], 153 | "prefer-arrow-callback": 2, 154 | "prefer-const": 1, 155 | "prefer-spread": 1, 156 | "prefer-reflect": 0, 157 | "prefer-template": 1, 158 | "require-yield": 2 159 | }, 160 | "env": { 161 | "es6": true, 162 | "node": true, 163 | "browser": true, 164 | "mocha": true 165 | }, 166 | "globals": { 167 | "expect": true 168 | }, 169 | "ecmaFeatures": { 170 | "arrowFunctions": true, 171 | "binaryLiterals": false, 172 | "blockBindings": true, 173 | "classes": true, 174 | "defaultParams": true, 175 | "destructuring": true, 176 | "forOf": true, 177 | "generators": true, 178 | "modules": true, 179 | "objectLiteralComputedProperties": true, 180 | "objectLiteralDuplicateProperties": false, 181 | "objectLiteralShorthandMethods": true, 182 | "objectLiteralShorthandProperties": true, 183 | "octalLiterals": false, 184 | "regexUFlag": false, 185 | "regexYFlag": false, 186 | "restParams": true, 187 | "spread": true, 188 | "superInFunctions": true, 189 | "templateString": true, 190 | "unicodeCodePointEscapes": false, 191 | "globalReturn": false, 192 | "jsx": true, 193 | "experimentalObjectRestSpread": true 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ffetch 2 | 3 | Simple thin fetch wrapper. `ffetch` means more human **"f"**riendly **"fetch"**. 4 | 5 | [![npm version](https://badge.fury.io/js/ffetch.svg)](http://badge.fury.io/js/ffetch) 6 | [![Circle CI](https://circleci.com/gh/axross/ffetch/tree/stable.svg?style=svg&circle-token=4ebcc03d8e89eec153012626ccb181ec2986ac64)](https://circleci.com/gh/axross/ffetch/tree/stable) 7 | [![Circle CI](https://circleci.com/gh/axross/ffetch/tree/master.svg?style=svg&circle-token=4ebcc03d8e89eec153012626ccb181ec2986ac64)](https://circleci.com/gh/axross/ffetch/tree/master) 8 | 9 | ## Example 10 | 11 | ```javascript 12 | import ffetch from 'ffetch'; 13 | 14 | // fetch from GET /path/to/api/page/3?q=github&order=id 15 | ffetch.get('/path/to/api/page/:page', { 16 | params: { page: 3 }, 17 | queries: { q: 'github', order: 'id' }, 18 | }) 19 | .then(res => res.json()) 20 | .then(json => console.log(json)) 21 | .catch(err => console.error(err)); 22 | ``` 23 | 24 | ```javascript 25 | import { FFetch } from 'ffetch'; 26 | 27 | // create your ffetch instance with config 28 | const ffetch = new FFetch({ 29 | baseUrl: 'http://your.web.api/v2', 30 | headers: { 31 | 'X-Auth-Token': '123456789ABCDEF0', 32 | }, 33 | }); 34 | 35 | // send JSON payload to PUT http://your.web.api/v2/path/to/api 36 | ffetch.put('/path/to/api', { 37 | body: { 38 | title: 'json payload', 39 | text: 'it will be stringified', 40 | }, 41 | }) 42 | .then(res => console.log(res)); 43 | ``` 44 | 45 | ## Requirement 46 | 47 | - `global.Promise()` 48 | 49 | ffetch works on both of the Browser and the Node.js but It needs Promise API. 50 | 51 | ## Usage 52 | 53 | #### Working on the Browser: 54 | 55 | ```javascript 56 | // Promise() polyfill 57 | import { Promise } from 'es6-promise'; 58 | 59 | window.Promise = Promise; 60 | ``` 61 | 62 | Then, use directly: 63 | 64 | ```javascript 65 | import ffetch from 'ffetch'; 66 | import fetch from 'whatwg-fetch'; // just a polyfill 67 | 68 | // call fetch() friendly 69 | ffetch.get(/* ... */) 70 | .then(res => /* ... */) 71 | .catch(err => /* ... */); 72 | ``` 73 | 74 | Or use your instance with options: 75 | 76 | ```javascript 77 | import { FFetch } from 'ffetch'; 78 | 79 | const ffetch = new FFetch({ 80 | fetch: () => { /* your custom fetch function */ }, 81 | }); 82 | 83 | // call fetch() friendly 84 | ffetch.get(/* ... */) 85 | .then(res => /* ... */) 86 | .catch(err => /* ... */); 87 | ``` 88 | 89 | #### Working on the Node.js: 90 | 91 | ```javascript 92 | import { Promise } from 'es6-promise'; 93 | import nodeFetch from 'node-fetch'; 94 | import { FFetch } from 'ffetch'; 95 | 96 | const ffetch = new FFetch({ 97 | fetch: nodeFetch, 98 | }); 99 | 100 | // call fetch() friendly 101 | ffetch.get(/* ... */) 102 | .then(res => /* ... */) 103 | .catch(err => /* ... */); 104 | ``` 105 | 106 | ## API 107 | 108 | ### ffetch.get(url: string [, options: object]): Promise 109 | ### ffetch.post() 110 | ### ffetch.put() 111 | ### ffetch.del() 112 | ### ffetch.head() 113 | ### ffetch.opt() 114 | 115 | Call `fetch()` like human friendly. 116 | 117 | ```javascript 118 | ffetch.get('/path/to/api/page/:page', { 119 | params: { page: 3 }, 120 | queries: { q: 'github', order: 'id' }, 121 | }) 122 | .then(res => res.json()) 123 | .then(json => console.log(json)) 124 | .catch(err => console.error(err)); 125 | ``` 126 | 127 | |argument |type | | 128 | |:--------------- |:---- |:--------------------------------------------------- | 129 | |`url` |string|URL of request. | 130 | |`options.params` |object|URL parameters. | 131 | |`options.queries`|object|URL queries. | 132 | |`options.headers`|object|Request headers. | 133 | |`options.body` | |Request body. If it is an object or an array, It will be a string by `JSON.stringify()`.| 134 | |`options.timeout`|number|If request exceeded this value, `ffetch()` throws an error(promisified).| 135 | |`options.***` | |Some other options. | 136 | 137 | ### new FFetch([options]) 138 | 139 | Create an instance for fetching. 140 | 141 | ```javascript 142 | 143 | import fetch from 'node-fetch'; 144 | 145 | const ffetch = new FFetch({ 146 | baseUrl: 'http://your.web.api/v2', 147 | headers: { 148 | 'X-Auth-Token': '123456789ABCDEF0', 149 | }, 150 | timeout: 30000, 151 | fetch, 152 | }); 153 | ``` 154 | 155 | |argument |type | | 156 | |:--------------- |:------ |:------------------------------------------------- | 157 | |`options.baseUrl`|string |URL prefix of each request. | 158 | |`options.headers`|object |Request headers. it will merge to each request. | 159 | |`options.timeout`|number |the default of `options.timeout` of such as `ffetch.get()`.| 160 | |`options.fetch` |function|Custom request function. default: '(global).fetch' | 161 | -------------------------------------------------------------------------------- /distributions/ffetch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true 5 | }); 6 | 7 | var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); 8 | 9 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 10 | 11 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } 12 | 13 | var _isPlainObject = require('is-plain-object'); 14 | 15 | var _isPlainObject2 = _interopRequireDefault(_isPlainObject); 16 | 17 | var _querystring = require('querystring'); 18 | 19 | var _querystring2 = _interopRequireDefault(_querystring); 20 | 21 | // get the global object 22 | /* eslint-disable no-new-func */ 23 | var self = Function('return this')(); 24 | /* eslint-enable no-new-func */ 25 | 26 | var AVAILABLE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']; 27 | var DEFAULT_TIMEOUT_MILLISEC = 60000; 28 | 29 | var FFetch = (function () { 30 | function FFetch() { 31 | var _ref = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; 32 | 33 | var _ref$baseUrl = _ref.baseUrl; 34 | var baseUrl = _ref$baseUrl === undefined ? '' : _ref$baseUrl; 35 | var _ref$headers = _ref.headers; 36 | var headers = _ref$headers === undefined ? {} : _ref$headers; 37 | var _ref$timeout = _ref.timeout; 38 | var timeout = _ref$timeout === undefined ? DEFAULT_TIMEOUT_MILLISEC : _ref$timeout; 39 | var _ref$fetch = _ref.fetch; 40 | var fetch = _ref$fetch === undefined ? self.fetch : _ref$fetch; 41 | 42 | _classCallCheck(this, FFetch); 43 | 44 | this.baseUrl = baseUrl; 45 | this.defaultHeaders = headers; 46 | this.defaultTimeout = timeout; 47 | this.fetch = function () { 48 | return fetch.apply(undefined, arguments); 49 | }; 50 | } 51 | 52 | _createClass(FFetch, [{ 53 | key: 'get', 54 | value: function get(url, options) { 55 | return this.friendlyFetch(url, Object.assign({}, options, { 56 | method: 'GET' 57 | })); 58 | } 59 | }, { 60 | key: 'post', 61 | value: function post(url, options) { 62 | return this.friendlyFetch(url, Object.assign({}, options, { 63 | method: 'POST' 64 | })); 65 | } 66 | }, { 67 | key: 'put', 68 | value: function put(url, options) { 69 | return this.friendlyFetch(url, Object.assign({}, options, { 70 | method: 'PUT' 71 | })); 72 | } 73 | }, { 74 | key: 'del', 75 | value: function del(url, options) { 76 | return this.friendlyFetch(url, Object.assign({}, options, { 77 | method: 'DELETE' 78 | })); 79 | } 80 | }, { 81 | key: 'head', 82 | value: function head(url, options) { 83 | return this.friendlyFetch(url, Object.assign({}, options, { 84 | method: 'HEAD' 85 | })); 86 | } 87 | }, { 88 | key: 'opt', 89 | value: function opt(url, options) { 90 | return this.friendlyFetch(url, Object.assign({}, options, { 91 | method: 'OPTIONS' 92 | })); 93 | } 94 | }, { 95 | key: 'friendlyFetch', 96 | value: function friendlyFetch(url, options) { 97 | var _this = this; 98 | 99 | var method = FFetch.sanitizeMethod(options.method); 100 | var fullUrl = FFetch.createFullUrl({ 101 | base: this.baseUrl + url, 102 | params: options.params, 103 | queries: options.queries 104 | }); 105 | var timeout = parseInt(options.timeout, 10); 106 | var headers = FFetch.lowercaseHeaderKeys(Object.assign({}, this.defaultHeaders, options.headers)); 107 | var body = options.body; 108 | 109 | // set default value if timeout is invalid 110 | if (typeof timeout !== 'number' || Number.isNaN(timeout) || timeout <= 0) { 111 | timeout = DEFAULT_TIMEOUT_MILLISEC; 112 | } 113 | 114 | // stringify body and add a headers if it is a plain object or an array 115 | if ((0, _isPlainObject2['default'])(body) || Array.isArray(body)) { 116 | body = JSON.stringify(body); 117 | headers = Object.assign({ 118 | 'content-type': 'application/json' 119 | }, headers); 120 | } 121 | 122 | var parsedOptions = Object.assign({}, options, { 123 | method: method, 124 | headers: headers, 125 | body: body 126 | }); 127 | 128 | return new Promise(function (resolve, reject) { 129 | var stid = setTimeout(function () { 130 | reject(new Error('Session timeout')); 131 | }, timeout); 132 | 133 | _this.fetch(fullUrl, parsedOptions).then(function (res) { 134 | clearTimeout(stid); 135 | 136 | resolve(res); 137 | })['catch'](function (err) { 138 | clearTimeout(stid); 139 | 140 | reject(err); 141 | }); 142 | }); 143 | } 144 | }], [{ 145 | key: 'sanitizeMethod', 146 | value: function sanitizeMethod(method) { 147 | if (!method) throw new TypeError('method is not given'); 148 | 149 | var upperCased = String(method).toUpperCase(); 150 | 151 | if (AVAILABLE_METHODS.indexOf(upperCased) === -1) { 152 | throw new TypeError('method must be a string of : ' + AVAILABLE_METHODS.join(', ')); 153 | } 154 | 155 | return upperCased; 156 | } 157 | }, { 158 | key: 'createFullUrl', 159 | value: function createFullUrl() { 160 | var _ref2 = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; 161 | 162 | var _ref2$base = _ref2.base; 163 | var base = _ref2$base === undefined ? '' : _ref2$base; 164 | var _ref2$params = _ref2.params; 165 | var params = _ref2$params === undefined ? {} : _ref2$params; 166 | var _ref2$queries = _ref2.queries; 167 | var queries = _ref2$queries === undefined ? {} : _ref2$queries; 168 | 169 | var url = base; 170 | 171 | if (!(0, _isPlainObject2['default'])(params)) { 172 | throw new TypeError('params is not a Plain-object'); 173 | } 174 | 175 | var _iteratorNormalCompletion = true; 176 | var _didIteratorError = false; 177 | var _iteratorError = undefined; 178 | 179 | try { 180 | for (var _iterator = Object.keys(params)[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { 181 | var key = _step.value; 182 | 183 | if (String(params[key]).startsWith(':')) { 184 | throw new TypeError('params.' + key + ' is invalid String. it must not start with ":".'); 185 | } 186 | 187 | while (url.indexOf(':' + key) !== -1) { 188 | url = url.replace(':' + key, params[key]); 189 | } 190 | } 191 | } catch (err) { 192 | _didIteratorError = true; 193 | _iteratorError = err; 194 | } finally { 195 | try { 196 | if (!_iteratorNormalCompletion && _iterator['return']) { 197 | _iterator['return'](); 198 | } 199 | } finally { 200 | if (_didIteratorError) { 201 | throw _iteratorError; 202 | } 203 | } 204 | } 205 | 206 | if (Object.keys(queries).length > 0) { 207 | url = url + '?' + _querystring2['default'].stringify(queries); 208 | } 209 | 210 | return url; 211 | } 212 | }, { 213 | key: 'lowercaseHeaderKeys', 214 | value: function lowercaseHeaderKeys(input) { 215 | var output = {}; 216 | 217 | // replace keys of headers to lower case 218 | var _iteratorNormalCompletion2 = true; 219 | var _didIteratorError2 = false; 220 | var _iteratorError2 = undefined; 221 | 222 | try { 223 | for (var _iterator2 = Object.keys(input)[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) { 224 | var key = _step2.value; 225 | 226 | output[key.toLowerCase()] = input[key]; 227 | } 228 | } catch (err) { 229 | _didIteratorError2 = true; 230 | _iteratorError2 = err; 231 | } finally { 232 | try { 233 | if (!_iteratorNormalCompletion2 && _iterator2['return']) { 234 | _iterator2['return'](); 235 | } 236 | } finally { 237 | if (_didIteratorError2) { 238 | throw _iteratorError2; 239 | } 240 | } 241 | } 242 | 243 | return output; 244 | } 245 | }]); 246 | 247 | return FFetch; 248 | })(); 249 | 250 | exports.FFetch = FFetch; -------------------------------------------------------------------------------- /distributions/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true 5 | }); 6 | 7 | var _FFetch = require('./FFetch'); 8 | 9 | exports.FFetch = _FFetch.FFetch; 10 | exports['default'] = new _FFetch.FFetch(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ffetch", 3 | "description": "Simple thin fetch wrapper. \"ffetch\" means more human \"f\"riendly \"fetch\".", 4 | "version": "0.1.1", 5 | "main": "./distributions/ffetch.js", 6 | "scripts": { 7 | "babel": "babel ./sources --out-dir ./distributions", 8 | "lint": "eslint ./sources/*.es6 ./tests/*.es6", 9 | "test": "babel-node ./tests/index.js ./tests/**/*.test.js | tap-notify | tap-diff", 10 | "prepublish": "npm run babel" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/axross/ffetch.git" 15 | }, 16 | "author": "axross (http://axross.me/)", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/axross/ffetch/issues" 20 | }, 21 | "homepage": "https://github.com/axross/ffetch#readme", 22 | "dependencies": { 23 | "is-plain-object": "^2.0.1", 24 | "querystring": "^0.2.0" 25 | }, 26 | "devDependencies": { 27 | "babel": "^5.8.23", 28 | "babel-eslint": "^4.1.3", 29 | "es6-promise": "^3.0.2", 30 | "eslint": "^1.7.1", 31 | "node-fetch": "^1.3.3", 32 | "tap-diff": "^0.1.0", 33 | "tap-notify": "0.0.3", 34 | "tape": "^4.2.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /sources/FFetch.js: -------------------------------------------------------------------------------- 1 | import isPlainObject from 'is-plain-object'; 2 | import querystring from 'querystring'; 3 | 4 | // get the global object 5 | /* eslint-disable no-new-func */ 6 | const self = Function('return this')(); 7 | /* eslint-enable no-new-func */ 8 | 9 | const AVAILABLE_METHODS = [ 10 | 'GET', 11 | 'POST', 12 | 'PUT', 13 | 'DELETE', 14 | 'HEAD', 15 | 'OPTIONS', 16 | ]; 17 | const DEFAULT_TIMEOUT_MILLISEC = 60000; 18 | 19 | export class FFetch { 20 | constructor({ baseUrl = '', headers = {}, timeout = DEFAULT_TIMEOUT_MILLISEC, fetch = self.fetch } = {}) { 21 | this.baseUrl = baseUrl; 22 | this.defaultHeaders = headers; 23 | this.defaultTimeout = timeout; 24 | this.fetch = (...args) => fetch(...args); 25 | } 26 | 27 | get(url, options) { 28 | return this.friendlyFetch(url, Object.assign({}, options, { 29 | method: 'GET', 30 | })); 31 | } 32 | 33 | post(url, options) { 34 | return this.friendlyFetch(url, Object.assign({}, options, { 35 | method: 'POST', 36 | })); 37 | } 38 | 39 | put(url, options) { 40 | return this.friendlyFetch(url, Object.assign({}, options, { 41 | method: 'PUT', 42 | })); 43 | } 44 | 45 | del(url, options) { 46 | return this.friendlyFetch(url, Object.assign({}, options, { 47 | method: 'DELETE', 48 | })); 49 | } 50 | 51 | head(url, options) { 52 | return this.friendlyFetch(url, Object.assign({}, options, { 53 | method: 'HEAD', 54 | })); 55 | } 56 | 57 | opt(url, options) { 58 | return this.friendlyFetch(url, Object.assign({}, options, { 59 | method: 'OPTIONS', 60 | })); 61 | } 62 | 63 | friendlyFetch(url, options) { 64 | const method = FFetch.sanitizeMethod(options.method); 65 | const fullUrl = FFetch.createFullUrl({ 66 | base: this.baseUrl + url, 67 | params: options.params, 68 | queries: options.queries, 69 | }); 70 | let timeout = parseInt(options.timeout, 10); 71 | let headers = FFetch.lowercaseHeaderKeys( 72 | Object.assign({}, this.defaultHeaders, options.headers) 73 | ); 74 | let body = options.body; 75 | 76 | // set default value if timeout is invalid 77 | if (typeof timeout !== 'number' || Number.isNaN(timeout) || timeout <= 0) { 78 | timeout = DEFAULT_TIMEOUT_MILLISEC; 79 | } 80 | 81 | // stringify body and add a headers if it is a plain object or an array 82 | if (isPlainObject(body) || Array.isArray(body)) { 83 | body = JSON.stringify(body); 84 | headers = Object.assign({ 85 | 'content-type': 'application/json', 86 | }, headers); 87 | } 88 | 89 | const parsedOptions = Object.assign({}, options, { 90 | method, 91 | headers, 92 | body, 93 | }); 94 | 95 | return new Promise((resolve, reject) => { 96 | const stid = setTimeout(() => { 97 | reject(new Error('Session timeout')); 98 | }, timeout); 99 | 100 | this.fetch(fullUrl, parsedOptions) 101 | .then(res => { 102 | clearTimeout(stid); 103 | 104 | resolve(res); 105 | }) 106 | .catch(err => { 107 | clearTimeout(stid); 108 | 109 | reject(err); 110 | }); 111 | }); 112 | } 113 | 114 | static sanitizeMethod(method) { 115 | if (!method) throw new TypeError('method is not given'); 116 | 117 | const upperCased = String(method).toUpperCase(); 118 | 119 | if (AVAILABLE_METHODS.indexOf(upperCased) === -1) { 120 | throw new TypeError( 121 | `method must be a string of : ${AVAILABLE_METHODS.join(', ')}` 122 | ); 123 | } 124 | 125 | return upperCased; 126 | } 127 | 128 | static createFullUrl({ base = '', params = {}, queries = {} } = {}) { 129 | let url = base; 130 | 131 | if (!isPlainObject(params)) { 132 | throw new TypeError('params is not a Plain-object'); 133 | } 134 | 135 | for (const key of Object.keys(params)) { 136 | if (String(params[key]).startsWith(':')) { 137 | throw new TypeError(`params.${key} is invalid String. it must not start with ":".`); 138 | } 139 | 140 | while (url.indexOf(`:${key}`) !== -1) { 141 | url = url.replace(`:${key}`, params[key]); 142 | } 143 | } 144 | 145 | if (Object.keys(queries).length > 0) { 146 | url = `${url}?${querystring.stringify(queries)}`; 147 | } 148 | 149 | return url; 150 | } 151 | 152 | static lowercaseHeaderKeys(input) { 153 | const output = {}; 154 | 155 | // replace keys of headers to lower case 156 | for (const key of Object.keys(input)) { 157 | output[key.toLowerCase()] = input[key]; 158 | } 159 | 160 | return output; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /sources/index.js: -------------------------------------------------------------------------------- 1 | import { FFetch } from './FFetch'; 2 | 3 | export { FFetch as FFetch }; 4 | export default new FFetch(); 5 | -------------------------------------------------------------------------------- /tests/FFetch.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import querystring from 'querystring'; 3 | import ffetch, { FFetch } from '../sources'; 4 | 5 | test('FFetch.createFullUrl() helps to parse the URL using base, params and queries', t => { 6 | const patterns = [ 7 | { 8 | input: { 9 | base: '/path/to/api/post/:id', 10 | params: { id: 25 }, 11 | queries: { foo: 'bar' }, 12 | }, 13 | expected: '/path/to/api/post/25', 14 | isNeedQueryString: true, 15 | }, 16 | { 17 | input: { 18 | base: '/path/to/api/post/:id:/:id', 19 | params: { id: 25 }, 20 | queries: {}, 21 | }, 22 | expected: '/path/to/api/post/25:/25', 23 | isNeedQueryString: false, 24 | }, 25 | { 26 | input: { 27 | base: '/path/to/api/post', 28 | params: {}, 29 | queries: { 30 | foo: 'bar', 31 | baz: 'qux', 32 | }, 33 | }, 34 | expected: '/path/to/api/post', 35 | isNeedQueryString: true, 36 | }, 37 | { 38 | input: { 39 | base: '/path/to/api/post/:id/comment/:commentId', 40 | params: { id: 25, commentId: 5963 }, 41 | queries: { 42 | foo: 'bar', 43 | columns: ['id', 'title', 'body', 'createdAt'], 44 | baz: 'qux', 45 | }, 46 | }, 47 | expected: '/path/to/api/post/25/comment/5963', 48 | isNeedQueryString: true, 49 | }, 50 | { 51 | input: { 52 | base: 'a', 53 | }, 54 | expected: 'a', 55 | isNeedQueryString: false, 56 | }, 57 | ]; 58 | 59 | t.plan(patterns.length); 60 | 61 | patterns.forEach(({ input, expected, isNeedQueryString }) => { 62 | t.equal( 63 | FFetch.createFullUrl(input), 64 | `${expected}${isNeedQueryString ? '?' : ''}${querystring.stringify(input.queries)}` 65 | ); 66 | }); 67 | }); 68 | 69 | test('FFetch.createFullUrl() throws a TypeError if params is not a plain-object', t => { 70 | const args = { 71 | base: '', 72 | queries: {}, 73 | }; 74 | 75 | const validPatterns = [ 76 | {}, 77 | { foo: 'bar' }, 78 | { foo: 12 }, 79 | /* eslint-disable no-undefined */ 80 | undefined, 81 | /* eslint-enable no-undefined */ 82 | ]; 83 | 84 | const invalidPatterns = [ 85 | 'str', 86 | 123, 87 | true, 88 | null, 89 | [], 90 | /^regexp$/, 91 | new Date(), 92 | () => {}, 93 | ]; 94 | 95 | t.plan(validPatterns.length + invalidPatterns.length); 96 | 97 | FFetch.createFullUrl('base', {}, 'str'); 98 | 99 | validPatterns.forEach(params => { 100 | t.doesNotThrow(() => { 101 | return FFetch.createFullUrl(Object.assign({}, args, { params })); 102 | }); 103 | }); 104 | 105 | invalidPatterns.forEach(params => { 106 | t.throws(() => { 107 | return FFetch.createFullUrl(Object.assign({}, args, { params })); 108 | }); 109 | }); 110 | }); 111 | 112 | test('FFetch.createFullUrl() throws a TypeError if params includes a string of value it starts with ":"', t => { 113 | t.plan(1); 114 | 115 | t.throws(() => { 116 | return FFetch.createFullUrl({ 117 | base: '', 118 | params: { foo: ':bar' }, 119 | }); 120 | }); 121 | }); 122 | 123 | test('FFetch.sanitizeMethod() uppercases a method', t => { 124 | const patterns = [ 125 | 'get', 126 | 'POST', 127 | 'pUt', 128 | 'Delete', 129 | ]; 130 | 131 | t.plan(patterns.length); 132 | 133 | patterns.forEach(method => { 134 | t.equal(FFetch.sanitizeMethod(method), method.toUpperCase()); 135 | }); 136 | }); 137 | 138 | test('FFetch.sanitizeMethod() throws a TypeError if method is not unavailable one', t => { 139 | const invalidPatterns = [ 140 | 'GETS', 141 | 'guts', 142 | 'Posting', 143 | 'REMOVE', 144 | 'PATCH', 145 | ]; 146 | 147 | t.plan(invalidPatterns.length); 148 | 149 | invalidPatterns.forEach(method => { 150 | t.throws(() => { 151 | return FFetch.sanitizeMethod(method); 152 | }); 153 | }); 154 | }); 155 | 156 | test('FFetch.lowercaseHeaderKeys()', t => { 157 | t.plan(1); 158 | 159 | t.deepEqual(FFetch.lowercaseHeaderKeys({ 160 | 'Content-Type': 'application/json', 161 | 'X-Auth-Token': '123456789ABCDEF0', 162 | 'are-you-ok': 'of cause', 163 | camelCase: 'Will be lowercased', 164 | }), { 165 | 'content-type': 'application/json', 166 | 'x-auth-token': '123456789ABCDEF0', 167 | 'are-you-ok': 'of cause', 168 | camelcase: 'Will be lowercased', 169 | }); 170 | }); 171 | 172 | test('An instance of FFetch includes members baseUrl, defaultHeaders and fetch from the first argument', t => { 173 | t.plan(6); 174 | 175 | const options = { 176 | baseUrl: 'http://base.url', 177 | headers: { 178 | 'x-auth-token': '123456789abcdef0', 179 | }, 180 | fetch: () => {}, 181 | timeout: 30000, 182 | }; 183 | 184 | const ff = new FFetch(options); 185 | 186 | t.equal(ff.baseUrl, options.baseUrl); 187 | t.equal(ff.defaultHeaders, options.headers); 188 | t.equal(ff.timeout, options.defaultTimeout); 189 | 190 | const ffNotReceiveAnyOptions = new FFetch(); 191 | 192 | t.equal(ffNotReceiveAnyOptions.baseUrl, ''); 193 | t.deepEqual(ffNotReceiveAnyOptions.defaultHeaders, {}); 194 | t.ok( 195 | ffNotReceiveAnyOptions.defaultTimeout > 0, 196 | 'should greater than 0' 197 | ); 198 | }); 199 | 200 | test('FFetch#friendlyFetch() calls this.fetch and returns Promise it handles this.fetch()', t => { 201 | t.plan(2); 202 | 203 | const ff = new FFetch({ 204 | fetch: () => Promise.resolve('response'), 205 | }); 206 | 207 | ff.friendlyFetch('/path/to/api', { method: 'GET' }) 208 | .then(res => t.equal(res, 'response')) 209 | .catch(() => t.fail('should not be called')); 210 | 211 | const maybeFailFF = new FFetch({ 212 | fetch: () => Promise.reject(new Error()), 213 | }); 214 | 215 | maybeFailFF.friendlyFetch('/path/to/api', { method: 'POST' }) 216 | .then(() => t.fail('should not be called')) 217 | .catch(err => t.ok(err instanceof Error, 'should be an Error')); 218 | }); 219 | 220 | test('FFetch#friendlyFetch() reject the Promise when time passes than timeout', t => { 221 | t.plan(2); 222 | 223 | let timeoutIdOfFF = null; 224 | 225 | const ff = new FFetch({ 226 | fetch: () => new Promise(resolve => { 227 | timeoutIdOfFF = setTimeout(() => resolve(), 1001); 228 | }), 229 | }); 230 | 231 | ff.friendlyFetch('/path/to/api', { 232 | method: 'GET', 233 | timeout: 1000, 234 | }) 235 | .then(() => t.fail('should not be called')) 236 | .catch(err => { 237 | t.ok(err instanceof Error, 'should be an Error'); 238 | t.equal(err.message, 'Session timeout'); 239 | 240 | clearTimeout(timeoutIdOfFF); 241 | }); 242 | }); 243 | 244 | test('FFetch#friendlyFetch uses this.baseUrl as prefix for this.url', t => { 245 | t.plan(1); 246 | 247 | const ff = new FFetch({ 248 | baseUrl: 'http://i.am.base', 249 | fetch: fullUrl => { 250 | t.equal(fullUrl, 'http://i.am.base/join/me'); 251 | 252 | return Promise.resolve(); 253 | }, 254 | }); 255 | 256 | ff.friendlyFetch('/join/me', { method: 'GET' }); 257 | }); 258 | 259 | test('FFetch#friendlyFetch merges this.headers to this.defaultHeaders', t => { 260 | t.plan(1); 261 | 262 | const ff = new FFetch({ 263 | headers: { 264 | foo: 'bar', 265 | }, 266 | fetch: (_, parsedOptions) => { 267 | t.deepEqual(parsedOptions.headers, { 268 | foo: 'bar', 269 | buz: 'qux', 270 | }); 271 | 272 | return Promise.resolve(); 273 | }, 274 | }); 275 | 276 | ff.friendlyFetch('/join/me', { 277 | method: 'GET', 278 | headers: { 279 | buz: 'qux', 280 | }, 281 | }); 282 | }); 283 | 284 | test('FFetch#friendlyFetch() resolves url, method, params, queries, headers and body', t => { 285 | t.plan(4); 286 | 287 | const ff = new FFetch({ 288 | fetch: (fullUrl, parsedOptions) => { 289 | t.equal( 290 | fullUrl, 291 | '/path/to/api/post/98/comment/345?foo=bar&baz=qux' 292 | ); 293 | t.deepEqual(parsedOptions.headers, { 294 | 'content-type': 'application/json', 295 | accept: 'application/json', 296 | 'x-access-token': '123456789ABCDEF0', 297 | }); 298 | t.equal(parsedOptions.body, JSON.stringify({ 299 | abc: 123, 300 | def: 456, 301 | })); 302 | 303 | return Promise.resolve(); 304 | }, 305 | }); 306 | 307 | ff.friendlyFetch('/path/to/api/post/:id/comment/:commentId', { 308 | method: 'GET', 309 | params: { 310 | id: 98, 311 | commentId: 345, 312 | }, 313 | queries: { 314 | foo: 'bar', 315 | baz: 'qux', 316 | }, 317 | headers: { 318 | 'Accept': 'application/json', 319 | 'X-Access-Token': '123456789ABCDEF0', 320 | }, 321 | body: { 322 | abc: 123, 323 | def: 456, 324 | }, 325 | }) 326 | .then(() => { 327 | t.ok(true, 'should be called'); 328 | }); 329 | }); 330 | 331 | test('FFetch#get() is just a shorthand to FFetch#friendlyFetch()', t => { 332 | t.plan(3); 333 | 334 | const cachedFriendlyFetch = FFetch.prototype.friendlyFetch; 335 | 336 | FFetch.prototype.friendlyFetch = function(url, options) { 337 | t.equal(url, '/path/to/api'); 338 | t.deepEqual(options, { 339 | method: 'GET', 340 | body: 'body', 341 | }); 342 | 343 | return cachedFriendlyFetch.call(this, url, options) 344 | .then(() => t.ok(true, 'should be called')); 345 | }; 346 | 347 | const ff = new FFetch({ 348 | fetch: () => Promise.resolve(), 349 | }); 350 | 351 | ff.get('/path/to/api', { body: 'body' }); 352 | 353 | FFetch.prototype.friendlyFetch = cachedFriendlyFetch; 354 | }); 355 | 356 | test('FFetch#post() is just a shorthand to FFetch#friendlyFetch()', t => { 357 | t.plan(3); 358 | 359 | const cachedFriendlyFetch = FFetch.prototype.friendlyFetch; 360 | 361 | FFetch.prototype.friendlyFetch = function(url, options) { 362 | t.equal(url, '/path/to/api'); 363 | t.deepEqual(options, { 364 | method: 'POST', 365 | body: 'body', 366 | }); 367 | 368 | return cachedFriendlyFetch.call(this, url, options) 369 | .then(() => t.ok(true, 'should be called')); 370 | }; 371 | 372 | const ff = new FFetch({ 373 | fetch: () => Promise.resolve(), 374 | }); 375 | 376 | ff.post('/path/to/api', { body: 'body' }); 377 | 378 | FFetch.prototype.friendlyFetch = cachedFriendlyFetch; 379 | }); 380 | 381 | test('FFetch#put() is just a shorthand to FFetch#friendlyFetch()', t => { 382 | t.plan(3); 383 | 384 | const cachedFriendlyFetch = FFetch.prototype.friendlyFetch; 385 | 386 | FFetch.prototype.friendlyFetch = function(url, options) { 387 | t.equal(url, '/path/to/api'); 388 | t.deepEqual(options, { 389 | method: 'PUT', 390 | body: 'body', 391 | }); 392 | 393 | return cachedFriendlyFetch.call(this, url, options) 394 | .then(() => t.ok(true, 'should be called')); 395 | }; 396 | 397 | const ff = new FFetch({ 398 | fetch: () => Promise.resolve(), 399 | }); 400 | 401 | ff.put('/path/to/api', { body: 'body' }); 402 | 403 | FFetch.prototype.friendlyFetch = cachedFriendlyFetch; 404 | }); 405 | 406 | test('FFetch#del() is just a shorthand to FFetch#friendlyFetch()', t => { 407 | t.plan(3); 408 | 409 | const cachedFriendlyFetch = FFetch.prototype.friendlyFetch; 410 | 411 | FFetch.prototype.friendlyFetch = function(url, options) { 412 | t.equal(url, '/path/to/api'); 413 | t.deepEqual(options, { 414 | method: 'DELETE', 415 | body: 'body', 416 | }); 417 | 418 | return cachedFriendlyFetch.call(this, url, options) 419 | .then(() => t.ok(true, 'should be called')); 420 | }; 421 | 422 | const ff = new FFetch({ 423 | fetch: () => Promise.resolve(), 424 | }); 425 | 426 | ff.del('/path/to/api', { body: 'body' }); 427 | 428 | FFetch.prototype.friendlyFetch = cachedFriendlyFetch; 429 | }); 430 | 431 | test('FFetch#head() is just a shorthand to FFetch#friendlyFetch()', t => { 432 | t.plan(3); 433 | 434 | const cachedFriendlyFetch = FFetch.prototype.friendlyFetch; 435 | 436 | FFetch.prototype.friendlyFetch = function(url, options) { 437 | t.equal(url, '/path/to/api'); 438 | t.deepEqual(options, { 439 | method: 'HEAD', 440 | body: 'body', 441 | }); 442 | 443 | return cachedFriendlyFetch.call(this, url, options) 444 | .then(() => t.ok(true, 'should be called')); 445 | }; 446 | 447 | const ff = new FFetch({ 448 | fetch: () => Promise.resolve(), 449 | }); 450 | 451 | ff.head('/path/to/api', { body: 'body' }); 452 | 453 | FFetch.prototype.friendlyFetch = cachedFriendlyFetch; 454 | }); 455 | 456 | test('FFetch#opt() is just a shorthand to FFetch#friendlyFetch()', t => { 457 | t.plan(3); 458 | 459 | const cachedFriendlyFetch = FFetch.prototype.friendlyFetch; 460 | 461 | FFetch.prototype.friendlyFetch = function(url, options) { 462 | t.equal(url, '/path/to/api'); 463 | t.deepEqual(options, { 464 | method: 'OPTIONS', 465 | body: 'body', 466 | }); 467 | 468 | return cachedFriendlyFetch.call(this, url, options) 469 | .then(() => t.ok(true, 'should be called')); 470 | }; 471 | 472 | const ff = new FFetch({ 473 | fetch: () => Promise.resolve(), 474 | }); 475 | 476 | ff.opt('/path/to/api', { body: 'body' }); 477 | 478 | FFetch.prototype.friendlyFetch = cachedFriendlyFetch; 479 | }); 480 | 481 | test('ffetch is just a plain instance of FFetch', t => { 482 | t.plan(1); 483 | 484 | t.ok(ffetch instanceof FFetch, 'should be just an instance'); 485 | }); 486 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | import { Promise } from 'es6-promise'; 2 | import glob from 'glob' 3 | import fetch from 'node-fetch' 4 | import path from 'path' 5 | 6 | global.Promise = Promise; 7 | global.fetch = fetch; 8 | 9 | process.argv.slice(2).forEach(arg => { 10 | glob(arg, (_, files) => { 11 | files.forEach(file => { 12 | require(path.resolve(process.cwd(), file)); 13 | }); 14 | }); 15 | }); 16 | --------------------------------------------------------------------------------