├── .gitignore ├── .eslintrc ├── example.js ├── package.json ├── test.js ├── index.js ├── index.mjs └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output/ 2 | node_modules/ -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | extends: 'standard', 3 | rules: { 4 | 'one-var': 'off', 5 | 'comma-dangle': 'off', 6 | }, 7 | } -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | const { fetch } = require('fetch-undici') 2 | const manifetch = require('./index') 3 | 4 | const prefix = 'https://jsonplaceholder.typicode.com' 5 | const get = manifetch({ fetch, prefix }) 6 | 7 | const manifest = { 8 | getPosts: ['GET', '/posts'], 9 | getPost: ['GET', '/posts/:id'], 10 | nestedDemonstration: { 11 | getPosts: ['GET', '/posts'], 12 | getPost: ['GET', '/posts/:id'], 13 | }, 14 | } 15 | 16 | const client = new Proxy(manifest, { get }) 17 | 18 | async function main (test = false) { 19 | const { json: posts } = await client.getPosts() 20 | const { json: post } = await client.getPost({ id: 10 }) 21 | if (!test) { 22 | console.log('First post out of collection:', posts[0]) 23 | console.log('Tenth post, individually requested', post) 24 | } 25 | return { client, posts, post } 26 | } 27 | 28 | module.exports = main 29 | 30 | if (require.main === module) { 31 | main().then(() => process.exit()) 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "lint": "eslint . --ext js --fix", 4 | "test": "tap test.js" 5 | }, 6 | "bugs": { 7 | "url": "https://github.com/terixjs/manifetch/issues" 8 | }, 9 | "bundleDependencies": false, 10 | "deprecated": false, 11 | "description": "A simple manifest-based fetch() API client building utility.", 12 | "files": [ 13 | "index.js", 14 | "index.mjs", 15 | "README.md" 16 | ], 17 | "homepage": "https://github.com/terixjs/manifetch", 18 | "license": "MIT", 19 | "main": "index.js", 20 | "name": "manifetch", 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/terixjs/manifetch.git" 24 | }, 25 | "version": "0.0.3", 26 | "devDependencies": { 27 | "eslint": "^7.22.0", 28 | "eslint-config-standard": "^16.0.2", 29 | "eslint-plugin-import": "^2.22.1", 30 | "eslint-plugin-node": "^11.1.0", 31 | "eslint-plugin-promise": "^4.3.1", 32 | "fetch-undici": "^1.0.5", 33 | "tap": "^14.11.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const main = require('./example') 5 | 6 | const firstPost = { 7 | userId: 1, 8 | id: 1, 9 | title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit', 10 | body: 'quia et suscipit\n' + 11 | 'suscipit recusandae consequuntur expedita et cum\n' + 12 | 'reprehenderit molestiae ut ut quas totam\n' + 13 | 'nostrum rerum est autem sunt rem eveniet architecto' 14 | } 15 | 16 | const tenthPost = { 17 | userId: 1, 18 | id: 10, 19 | title: 'optio molestias id quia eum', 20 | body: 'quo et expedita modi cum officia vel magni\n' + 21 | 'doloribus qui repudiandae\n' + 22 | 'vero nisi sit\n' + 23 | 'quos veniam quod sed accusamus veritatis error' 24 | } 25 | 26 | tap.test('manifetch', async (t) => { 27 | t.plan(2) 28 | 29 | const { client, posts, post } = await main(true) 30 | const { json: postsFromNested } = await client.nestedDemonstration.getPosts() 31 | const { json: postFromNested } = await client.nestedDemonstration.getPost({ id: 10 }) 32 | 33 | t.test('should work without params', (t) => { 34 | t.plan(2) 35 | t.strictSame(posts[0], firstPost) 36 | t.strictSame(postsFromNested[0], firstPost) 37 | }) 38 | 39 | t.test('should work with params', (t) => { 40 | t.plan(2) 41 | t.strictSame(post, tenthPost) 42 | t.strictSame(postFromNested, tenthPost) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | function manifetch (instance) { 2 | return getFetchWrapper.bind(instance) 3 | } 4 | 5 | module.exports = manifetch 6 | 7 | function getFetchWrapper (methods, method) { 8 | if (method in methods) { 9 | if (!Array.isArray(methods[method]) && typeof methods[method] === 'object') { 10 | return new Proxy(methods[method], { get: getFetchWrapper.bind(this) }) 11 | } 12 | const [httpMethod, path] = methods[method] 13 | const hasParams = path.match(/\/:(\w+)/) 14 | if (hasParams) { 15 | return async (params, options = {}) => { 16 | options.method = httpMethod 17 | // eslint-disable-next-line no-undef 18 | const response = await this.fetch(`${ 19 | this.prefix 20 | }${ 21 | applyParams(path, params) 22 | }`, options) 23 | const body = await response.text() 24 | return { 25 | body, 26 | json: tryJSONParse(body), 27 | status: response.status, 28 | headers: response.headers 29 | } 30 | } 31 | } else { 32 | return async (options = {}) => { 33 | options.method = httpMethod 34 | // eslint-disable-next-line no-undef 35 | const response = await this.fetch(`${this.prefix}${path}`, options) 36 | const body = await response.text() 37 | return { 38 | body, 39 | json: tryJSONParse(body), 40 | status: response.status, 41 | headers: response.headers 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | function applyParams (template, params) { 49 | try { 50 | return template.replace(/:(\w+)/g, (_, m) => { 51 | if (params[m]) { 52 | return params[m] 53 | } else { 54 | // eslint-disable-next-line no-throw-literal 55 | throw null 56 | } 57 | }) 58 | } catch (err) { 59 | if (err === null) { 60 | return err 61 | } else { 62 | throw err 63 | } 64 | } 65 | } 66 | 67 | function tryJSONParse (str) { 68 | try { 69 | return JSON.parse(str) 70 | } catch (_) { 71 | return undefined 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | function manifetch (instance) { 2 | return getFetchWrapper.bind(instance) 3 | } 4 | 5 | export default manifetch 6 | 7 | function getFetchWrapper (methods, method) { 8 | if (method in methods) { 9 | if (!Array.isArray(methods[method]) && typeof methods[method] === 'object') { 10 | return new Proxy(methods[method], { get: getFetchWrapper.bind(this) }) 11 | } 12 | const [httpMethod, path] = methods[method] 13 | const hasParams = path.match(/\/:(\w+)/) 14 | if (hasParams) { 15 | return async (params, options = {}) => { 16 | options.method = httpMethod 17 | // eslint-disable-next-line no-undef 18 | const response = await this.fetch(`${ 19 | this.prefix 20 | }${ 21 | applyParams(path, params) 22 | }`, options) 23 | const body = await response.text() 24 | return { 25 | body, 26 | json: tryJSONParse(body), 27 | status: response.status, 28 | headers: response.headers 29 | } 30 | } 31 | } else { 32 | return async (options = {}) => { 33 | options.method = httpMethod 34 | // eslint-disable-next-line no-undef 35 | const response = await this.fetch(`${this.prefix}${path}`, options) 36 | const body = await response.text() 37 | return { 38 | body, 39 | json: tryJSONParse(body), 40 | status: response.status, 41 | headers: response.headers 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | function applyParams (template, params) { 49 | try { 50 | return template.replace(/:(\w+)/g, (_, m) => { 51 | if (params[m]) { 52 | return params[m] 53 | } else { 54 | // eslint-disable-next-line no-throw-literal 55 | throw null 56 | } 57 | }) 58 | } catch (err) { 59 | if (err === null) { 60 | return err 61 | } else { 62 | throw err 63 | } 64 | } 65 | } 66 | 67 | function tryJSONParse (str) { 68 | try { 69 | return JSON.parse(str) 70 | } catch (_) { 71 | return undefined 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # manifetch 2 | 3 | A simple manifest-based [`fetch()`](https://fetch.spec.whatwg.org/) API client building utility. 4 | 5 | It is obvious in the sense it uses a staightforward, _**nearly obvious**_ pattern to **automatically build API clients from route definitions in a minimalist manifest**. Conceived originally as part of [`fastify-api`](https://github.com/galvez/fastify-api), which already provides a manifest compatible with `manifetch` based on your API route definitions. 6 | 7 | ## Install 8 | 9 | ``` 10 | $ npm i manifetch --save 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```js 16 | const fetch = require('undici-fetch') 17 | const manifetch = require('manifetch') 18 | 19 | const prefix = 'https://jsonplaceholder.typicode.com' 20 | const get = manifetch({ fetch, prefix }) 21 | 22 | const manifest = { 23 | getPosts: ['GET', '/posts'], 24 | getPost: ['GET', '/posts/:id'], 25 | nestedDemonstration: { 26 | getPosts: ['GET', '/posts'], 27 | getPost: ['GET', '/posts/:id'], 28 | }, 29 | } 30 | 31 | const client = new Proxy(manifest, { get }) 32 | 33 | async function main () { 34 | const { json: posts } = await client.getPosts() 35 | const { json: post } = await client.getPost({ id: 10 }) 36 | console.log('First post out of collection:', posts[0]) 37 | console.log('Tenth post, individually requested', post) 38 | } 39 | 40 | main().then(() => process.exit()) 41 | ``` 42 | 43 | See also [example][example] and [test][test]. 44 | 45 | [example]: https://github.com/terixjs/manifetch/blob/main/example.js 46 | [test]: https://github.com/terixjs/manifetch/blob/main/test.js 47 | 48 | ## Goals 49 | 50 | - [ ] **Work seamlessly** on **Node**, **Deno** and **on the browser** 51 | - [x] Facilitate `fetch()` usage with **minor**, **sensible API enhancements** 52 | - [x] **Automatically** construct API clients based on a **minimalist API manifest** 53 | - [ ] Support both a **minimalist manifest** and the **OpenAPI specification** 54 | 55 | ## Status 56 | 57 | - [x] Basic Node implementation using [undici-fetch][uf] for `fetch()` 58 | - [ ] Write comprehensive test suite based on [node-tap](https://node-tap.org/). 59 | - [ ] Write comprehensive usage examples for [`fastify-api`][fa] and [`fastify-vite`][fv] 60 | - [ ] Optimize `applyParams()` implementation with new **fast-apply-params** package 61 | - [ ] Memoize `Proxy` instances on the client, prepopulate all wrappers on the server 62 | 63 | [fa]: https://github.com/galvez/fastify-api 64 | [fv]: https://github.com/galvez/fastify-vite 65 | [uf]: https://github.com/Ethan-Arrowood/undici-fetch 66 | 67 | ## License 68 | 69 | MIT 70 | --------------------------------------------------------------------------------