├── .eslintrc ├── .gitignore ├── package.json ├── test.js ├── example.js ├── README.md └── index.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { extends: 'standard' } 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | node_modules 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "lint": "eslint . --ext js --fix", 4 | "test": "tap test.js", 5 | "dev-grouped": "node examples/grouped", 6 | "dev-mixed": "node examples/mixed", 7 | "dev-flat": "node examples/flat" 8 | }, 9 | "bugs": { 10 | "url": "https://github.com/galvez/fastify-api/issues" 11 | }, 12 | "bundleDependencies": false, 13 | "deprecated": false, 14 | "description": "A radically simple API routing and method injection library for Fastify", 15 | "files": [ 16 | "index.js", 17 | "examples", 18 | "README.md" 19 | ], 20 | "homepage": "https://github.com/galvez/fastify-api", 21 | "license": "MIT", 22 | "main": "index.js", 23 | "type": "commonjs", 24 | "name": "fastify-api", 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/galvez/fastify-api.git" 28 | }, 29 | "version": "0.2.0", 30 | "devDependencies": { 31 | "eslint": "^7.22.0", 32 | "eslint-config-standard": "^16.0.2", 33 | "eslint-plugin-import": "^2.22.1", 34 | "eslint-plugin-node": "^11.1.0", 35 | "eslint-plugin-promise": "^4.3.1", 36 | "tap": "^14.11.0" 37 | }, 38 | "dependencies": { 39 | "fast-path-set": "0.0.2" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const { getServer } = require('./example') 5 | 6 | getServer().then(async (fastify) => { 7 | await fastify.ready() 8 | tap.tearDown(() => { 9 | fastify.close() 10 | }) 11 | tap.test('should register all API methods', (t) => { 12 | t.plan(10) 13 | t.ok(typeof fastify.api.client.method === 'function') 14 | t.ok(typeof fastify.api.client.methodWithParams === 'function') 15 | t.ok(typeof fastify.api.client.nested.method === 'function') 16 | t.ok(typeof fastify.api.client.methodFromNamedFunction === 'function') 17 | t.ok(typeof fastify.api.client.topLevelMethod === 'function') 18 | t.ok(typeof fastify.api.client.nestedMethods.method === 'function') 19 | t.ok(typeof fastify.api.client.nestedMethods.otherMethod === 'function') 20 | t.ok(typeof fastify.api.client.nestedMethods.deeplyNestedMethods.method === 'function') 21 | t.ok(typeof fastify.api.client.nestedMethods.deeplyNestedMethods.otherMethod === 'function') 22 | t.ok(typeof fastify.api.client.methodWithOptions === 'function') 23 | }) 24 | tap.test('should register all API metadata', (t) => { 25 | t.plan(1) 26 | t.strictSame(fastify.api.meta, { 27 | methodFromNamedFunction: [ 28 | 'GET', 29 | '/4/method' 30 | ], 31 | topLevelMethod: [ 32 | 'GET', 33 | '/5/top-level-method/:id' 34 | ], 35 | nestedMethods: { 36 | method: [ 37 | 'GET', 38 | '/5/nested-methods/method/:id' 39 | ], 40 | otherMethod: [ 41 | 'GET', 42 | '/5/nested-methods/other-method/:id' 43 | ], 44 | deeplyNestedMethods: { 45 | method: [ 46 | 'GET', 47 | '/5/nested-methods/deeply-nested-methods/method/:id' 48 | ], 49 | otherMethod: [ 50 | 'GET', 51 | '/5/nested-methods/deeply-nested-methods/other-method/:id' 52 | ] 53 | } 54 | }, 55 | method: [ 56 | 'GET', 57 | '/1/method' 58 | ], 59 | methodWithParams: [ 60 | 'GET', 61 | '/2/method/:id' 62 | ], 63 | nested: { 64 | method: [ 65 | 'GET', 66 | '/3/nested/method/:id' 67 | ] 68 | }, 69 | methodWithOptions: [ 70 | 'GET', 71 | '/6/method' 72 | ] 73 | }) 74 | }) 75 | tap.test('show know when there are no params', async (t) => { 76 | t.plan(3) 77 | const direct = await fastify.inject({ url: '/1/method' }) 78 | const proxied = await fastify.inject({ url: '/invoke/1/method' }) 79 | const internal = await fastify.api.client.method() 80 | t.equal(direct.body, 'Hello from /1/method') 81 | t.equal(proxied.json().body, 'Hello from /1/method') 82 | t.equal(internal.body, 'Hello from /1/method') 83 | }) 84 | tap.test('top-level methods defined via api() helper should work', async (t) => { 85 | t.plan(3) 86 | const direct = await fastify.inject({ url: '/5/top-level-method/123' }) 87 | const proxied = await fastify.inject({ url: '/invoke/5/top-level-method' }) 88 | const internal = await fastify.api.client.topLevelMethod({ id: 123 }) 89 | t.strictSame(direct.json(), {"id":"123"}) 90 | t.strictSame(proxied.json().body, '{"id":"123"}') 91 | t.strictSame(internal.json, {"id":"123"}) 92 | }) 93 | tap.test('deeply-nested methods defined via api() helper should work', async (t) => { 94 | t.plan(3) 95 | const direct = await fastify.inject({ url: '/5/nested-methods/deeply-nested-methods/method/123' }) 96 | const proxied = await fastify.inject({ url: '/invoke/5/nested-methods/deeply-nested-methods/method' }) 97 | const internal = await fastify.api.client.nestedMethods.deeplyNestedMethods.method({ id: 123 }) 98 | t.strictSame(direct.json(), {"id":"123"}) 99 | t.strictSame(proxied.json().body, '{"id":"123"}') 100 | t.strictSame(internal.json, {"id":"123"}) 101 | }) 102 | tap.test('should capture additional options', async (t) => { 103 | t.plan(1) 104 | const internal = await fastify.api.client.methodWithOptions({ 105 | query: { 106 | arg: 1 107 | }, 108 | headers: { 109 | 'x-foobar': 1 110 | } 111 | }) 112 | t.equal(internal.body, 'Hello from /6/method/ with query.arg 1 and the x-foobar header 1') 113 | }) 114 | }) 115 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | 2 | async function getServer () { 3 | const fastify = require('fastify')() 4 | await fastify.register(require('./index')) 5 | 6 | // Original fastify.{method}() with `exposeAs` option, without params: 7 | 8 | fastify.get('/1/method', { exposeAs: 'method' }, (_, reply) => { 9 | reply.send('Hello from /1/method') 10 | }) 11 | fastify.get('/invoke/1/method', async (_, reply) => { 12 | try { 13 | const result = await fastify.api.client.method() 14 | reply.send(result) 15 | } catch (err) { 16 | console.error(err) 17 | } 18 | }) 19 | 20 | // Original fastify.() with `exposeAs` option, with params: 21 | 22 | fastify.get('/2/method/:id', { exposeAs: 'methodWithParams' }, ({ id }, _, reply) => { 23 | reply.send(`Hello from /2/method/ with id ${id}`) 24 | }) 25 | fastify.get('/invoke/2/method', async (req, reply) => { 26 | const result = await fastify.api.client.methodWithParams({ id: 123 }) 27 | reply.send(result) 28 | }) 29 | 30 | // Will automatically create a nested structure too, if needed: 31 | 32 | fastify.get('/3/nested/method/:id', { exposeAs: 'nested.method' }, ({ id }, _, reply) => { 33 | reply.send(`Hello from /3/nested/method/ with id ${id}`) 34 | }) 35 | fastify.get('/invoke/3/nested/method', async (req, reply) => { 36 | const result = await fastify.api.client.nested.method({ id: 123 }) 37 | reply.send(result) 38 | }) 39 | 40 | // Modified fastify.api.() setter if the handler is a named function: 41 | 42 | fastify.api.get('/4/method', function methodFromNamedFunction ({ id }, _, reply) { 43 | reply.send(`Hello from /4/method with id ${id}`) 44 | }) 45 | fastify.get('/invoke/4/method', async (req, reply) => { 46 | const result = await fastify.api.client.methodFromNamedFunction({ id: 123 }) 47 | reply.send(result) 48 | }) 49 | 50 | // Modified fastify.api(setter) helper to quickly define multiple methods. 51 | // Makes more sense if the setter function is coming from another file. 52 | 53 | fastify.api(({ get }) => ({ 54 | topLevelMethod: get('/5/top-level-method/:id', function ({ id }, _, reply) { 55 | reply.send({ id }) 56 | }), 57 | nestedMethods: { 58 | method: get('/5/nested-methods/method/:id', ({ id }, _, reply) => { 59 | reply.send({ id }) 60 | }), 61 | otherMethod: get('/5/nested-methods/other-method/:id', ({ id }, _, reply) => { 62 | reply.send({ id }) 63 | }), 64 | deeplyNestedMethods: { 65 | method: get('/5/nested-methods/deeply-nested-methods/method/:id', ({ id }, _, reply) => { 66 | reply.send({ id }) 67 | }), 68 | otherMethod: get('/5/nested-methods/deeply-nested-methods/other-method/:id', ({ id }, _, reply) => { 69 | reply.send({ id }) 70 | }) 71 | } 72 | } 73 | })) 74 | 75 | fastify.get('/invoke/5/top-level-method', async (req, reply) => { 76 | const result = await fastify.api.client.topLevelMethod({ id: 123 }) 77 | reply.send(result) 78 | }) 79 | fastify.get('/invoke/5/nested-methods/method', async (_, reply) => { 80 | const result = await fastify.api.client.nestedMethods.method({ id: 123 }) 81 | reply.send(result) 82 | }) 83 | fastify.get('/invoke/5/nested-methods/other-method', async (_, reply) => { 84 | const result = await fastify.api.client.nestedMethods.otherMethod({ id: 123 }) 85 | reply.send(result) 86 | }) 87 | fastify.get('/invoke/5/nested-methods/deeply-nested-methods/method', async (_, reply) => { 88 | const result = await fastify.api.client.nestedMethods.deeplyNestedMethods.method({ id: 123 }) 89 | reply.send(result) 90 | }) 91 | fastify.get('/invoke/5/nested-methods/deeply-nested-methods/other-method', async (_, reply) => { 92 | const result = await fastify.api.client.nestedMethods.deeplyNestedMethods.otherMethod({ id: 123 }) 93 | reply.send(result) 94 | }) 95 | 96 | // Any API method exposed in fastify.api.client can take options: 97 | 98 | fastify.get('/6/method', { exposeAs: 'methodWithOptions' }, (req, reply) => { 99 | reply.send(`Hello from /6/method/ with query.arg ${ 100 | req.query.arg 101 | } and the x-foobar header ${ 102 | req.headers['x-foobar'] 103 | }`) 104 | }) 105 | fastify.get('/invoke/6/method', async (_, reply) => { 106 | const result = await fastify.api.client.methodWithOptions({ 107 | query: { 108 | arg: 1 109 | }, 110 | headers: { 111 | 'x-foobar': 1 112 | } 113 | }) 114 | reply.send(result) 115 | }) 116 | 117 | fastify.get('/', (_, reply) => reply.send(fastify.api.meta)) 118 | 119 | return fastify 120 | } 121 | 122 | function listen (fastify) { 123 | fastify.listen(3000, (_, addr) => console.log(addr)) 124 | } 125 | 126 | module.exports = { getServer, listen } 127 | 128 | if (require.main === module) { 129 | getServer().then(listen) 130 | } 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastify-api 2 | 3 | A **radically simple** API **routing and method injection plugin** for [Fastify](https://fastify.dev). 4 | 5 | Uses [`fastify.inject`](https://github.com/fastify/light-my-request) under the hood, with _developer ergonomics_ in mind. 6 | 7 | Injects `fastify.api.client` with automatically mapped methods from route definitions. Also injects `fastify.api.meta`, which you can serve and use [`manifetch`](https://github.com/galvez/manifetch) to automatically build an API client for the browser. 8 | 9 | ## Usage 10 | 11 | 1. **Original fastify.{method}() with `exposeAs` option, without params:** 12 | 13 | ```js 14 | fastify.get('/1/method', { exposeAs: 'method' }, (_, reply) => { 15 | reply.send('Hello from /1/method') 16 | }) 17 | fastify.get('/invoke/1/method', async (_, reply) => { 18 | const result = await fastify.api.client.method() 19 | reply.send(result) 20 | }) 21 | ``` 22 | 23 | 2. **Original fastify.{method}() with `exposeAs` option, with params:** 24 | 25 | ```js 26 | fastify.get('/2/method/:id', { exposeAs: 'methodWithParams' }, ({ id }, _, reply) => { 27 | reply.send(`Hello from /2/method/ with id ${id}`) 28 | }) 29 | fastify.get('/invoke/2/method', async (req, reply) => { 30 | const result = await fastify.api.client.methodWithParams({ id: 123 }) 31 | reply.send(result) 32 | }) 33 | ``` 34 | 35 | 3. **Will automatically create a nested structure too, if needed:** 36 | 37 | ```js 38 | fastify.get('/3/nested/method/:id', { exposeAs: 'nested.method' }, ({ id }, _, reply) => { 39 | reply.send(`Hello from /3/nested/method/ with id ${id}`) 40 | }) 41 | fastify.get('/invoke/3/nested/method', async (req, reply) => { 42 | const result = await fastify.api.client.nested.method({ id: 123 }) 43 | reply.send(result) 44 | }) 45 | ``` 46 | 47 | 4. **Modified fastify.api.{method}() setter if the handler is a named function:** 48 | 49 | ```js 50 | fastify.api.get('/4/method', function methodFromNamedFunction ({ id }, _, reply) { 51 | reply.send(`Hello from /4/method with id ${id}`) 52 | }) 53 | fastify.get('/invoke/4/method', async (req, reply) => { 54 | const result = await fastify.api.client.methodFromNamedFunction({ id: 123 }) 55 | reply.send(result) 56 | }) 57 | ``` 58 | 59 | 5. **Modified fastify.api(setter) helper to quickly define multiple methods:** 60 | 61 | _Makes more sense if the setter function is coming from another file._ 62 | 63 | ```js 64 | fastify.api(({ get }) => ({ 65 | topLevelMethod: get('/5/top-level-method/:id', function ({ id }, _, reply) { 66 | reply.send({ id }) 67 | }), 68 | nestedMethods: { 69 | method: get('/5/nested-methods/method/:id', ({ id }, _, reply) => { 70 | reply.send({ id }) 71 | }), 72 | otherMethod: get('/5/nested-methods/other-method/:id', ({ id }, _, reply) => { 73 | reply.send({ id }) 74 | }), 75 | deeplyNestedMethods: { 76 | method: get('/5/nested-methods/deeply-nested-methods/method/:id', ({ id }, _, reply) => { 77 | reply.send({ id }) 78 | }), 79 | otherMethod: get('/5/nested-methods/deeply-nested-methods/other-method/:id', ({ id }, _, reply) => { 80 | reply.send({ id }) 81 | }) 82 | } 83 | } 84 | })) 85 | 86 | fastify.get('/invoke/5/top-level-method', async (req, reply) => { 87 | const result = await fastify.api.client.topLevelMethod({ id: 123 }) 88 | reply.send(result) 89 | }) 90 | fastify.get('/invoke/5/nested-methods/method', async (_, reply) => { 91 | const result = await fastify.api.client.nestedMethods.method({ id: 123 }) 92 | reply.send(result) 93 | }) 94 | fastify.get('/invoke/5/nested-methods/other-method', async (_, reply) => { 95 | const result = await fastify.api.client.nestedMethods.otherMethod({ id: 123 }) 96 | reply.send(result) 97 | }) 98 | fastify.get('/invoke/5/nested-methods/deeply-nested-methods/method', async (_, reply) => { 99 | const result = await fastify.api.client.nestedMethods.deeplyNestedMethods.method({ id: 123 }) 100 | reply.send(result) 101 | }) 102 | fastify.get('/invoke/5/nested-methods/deeply-nested-methods/other-method', async (_, reply) => { 103 | const result = await fastify.api.client.nestedMethods.deeplyNestedMethods.otherMethod({ id: 123 }) 104 | reply.send(result) 105 | }) 106 | ``` 107 | 108 | 6. **Any API method exposed in fastify.api.client can take options:** 109 | 110 | ```js 111 | fastify.get('/6/method', { exposeAs: 'methodWithOptions' }, (req, reply) => { 112 | reply.send(`Hello from /6/method/ with query.arg ${ 113 | req.query.arg 114 | } and the x-foobar header ${ 115 | req.headers['x-foobar'] 116 | }`) 117 | }) 118 | fastify.get('/invoke/6/method', async (_, reply) => { 119 | const result = await fastify.api.client.methodWithOptions({ 120 | query: { 121 | arg: 1 122 | }, 123 | headers: { 124 | 'x-foobar': 1 125 | } 126 | }) 127 | reply.send(result) 128 | }) 129 | ``` 130 | 131 | ## API responses 132 | 133 | If you call a route via HTTP, it'll operate normally as if weren't using the plugin. If you use `fastify.api.client.xyz()` to invoke it from another handler, you'll get an object containing `{ json, body, status, headers }` as response. If it's unable to parse a JSON document out of `body`, `json` is undefined. 134 | 135 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fp = require('fastify-plugin') 4 | const set = require('fast-path-set') 5 | const { assign } = Object 6 | 7 | async function fastifyApi (fastify, options) { 8 | const get = (...args) => registerMethod('get', ...args) 9 | const post = (...args) => registerMethod('post', ...args) 10 | const put = (...args) => registerMethod('put', ...args) 11 | const del = (...args) => registerMethod('delete', ...args) 12 | 13 | const api = function (setter) { 14 | const structure = setter({ get, post, put, del }) 15 | const binder = func => func.bind(fastify) 16 | const [methods, meta] = recursiveRegister(structure, binder) 17 | assign(api.meta, meta) 18 | assign(api.client, methods) 19 | } 20 | 21 | api.meta = {} 22 | api.client = {} 23 | 24 | function topLeverSetter (setter) { 25 | return (...args) => { 26 | const method = setter(...args) 27 | api.client[method.name] = method.func 28 | api.meta[method.name] = [method.method, method.url] 29 | } 30 | } 31 | 32 | api.get = topLeverSetter(get) 33 | api.post = topLeverSetter(post) 34 | api.put = topLeverSetter(put) 35 | api.del = topLeverSetter(del) 36 | 37 | function registerMethod (method, url, options, handler, returnWrappers = false) { 38 | // eslint-disable-next-line prefer-const 39 | let injector 40 | let wrapper 41 | const hasParams = url.match(/\/:(\w+)/) 42 | if (hasParams) { 43 | if (!handler) { 44 | handler = options 45 | wrapper = function (req, reply) { 46 | return handler.call(this, req.params, req, reply) 47 | } 48 | if (!returnWrappers) { 49 | fastify[method](url, wrapper) 50 | } 51 | } else { 52 | wrapper = function (req, reply) { 53 | return handler.call(this, req.params, req, reply) 54 | } 55 | if (!returnWrappers) { 56 | fastify[method](url, options, wrapper) 57 | } 58 | } 59 | } else { 60 | if (!handler) { 61 | handler = options 62 | wrapper = function (req, reply) { 63 | return handler.call(this, req, reply) 64 | } 65 | if (!returnWrappers) { 66 | fastify[method](url, wrapper) 67 | } 68 | } else { 69 | wrapper = function (req, reply) { 70 | return handler.call(this, req, reply) 71 | } 72 | if (!returnWrappers) { 73 | fastify[method](url, options, wrapper) 74 | } 75 | } 76 | } 77 | const ucMethod = method.toUpperCase() 78 | // eslint-disable-next-line prefer-const 79 | injector = async function (...args) { 80 | let reqURL = url 81 | let reqOptions = {} 82 | let params = {} 83 | if (hasParams) { 84 | reqOptions = args[1] || reqOptions 85 | params = args[0] 86 | reqURL = applyParams(url, params) 87 | if (!reqURL) { 88 | throw new Error('Provided params don\'t match this API method\'s URL format') 89 | } 90 | } else { 91 | reqOptions = args[0] || reqOptions 92 | } 93 | const virtualReq = { 94 | method: ucMethod, 95 | query: reqOptions.query, 96 | headers: reqOptions.headers, 97 | payload: reqOptions.body, 98 | url: reqURL 99 | } 100 | const res = await fastify.inject(virtualReq) 101 | return { 102 | status: res.statusCode, 103 | headers: res.headers, 104 | query: res.query, 105 | body: res.payload, 106 | get json () { 107 | return tryJSONParse(res.payload) 108 | } 109 | } 110 | } 111 | const apiMethod = new APIMethod(handler.name, injector, ucMethod, url) 112 | if (returnWrappers) { 113 | return [wrapper, apiMethod] 114 | } else { 115 | return apiMethod 116 | } 117 | } 118 | 119 | function registerFromRegularRoute (route) { 120 | if (!route.exposeAs) { 121 | return 122 | } 123 | const { exposeAs } = route 124 | const lcMethod = route.method.toLowerCase() 125 | const [wrapper, apiMethod] = registerMethod(lcMethod, route.url, route, route.handler, true) 126 | set(api.client, exposeAs, apiMethod.func) 127 | set(api.meta, exposeAs, [apiMethod.method, apiMethod.url]) 128 | route.handler = wrapper 129 | return route 130 | } 131 | 132 | fastify.addHook('onRoute', registerFromRegularRoute) 133 | fastify.decorate(options.decorateAs || 'api', api) 134 | } 135 | 136 | module.exports = fp(fastifyApi) 137 | 138 | function APIMethod (name, func, method, url) { 139 | this.name = name || null 140 | this.func = func 141 | this.method = method 142 | this.url = url 143 | } 144 | 145 | function applyParams (template, params) { 146 | try { 147 | return template.replace(/:(\w+)/g, (_, m) => { 148 | if (params[m]) { 149 | return params[m] 150 | } else { 151 | // eslint-disable-next-line no-throw-literal 152 | throw null 153 | } 154 | }) 155 | } catch (err) { 156 | return null 157 | } 158 | } 159 | 160 | function recursiveRegister (obj, binder, methods = {}, meta = {}) { 161 | for (const p in obj) { 162 | if (obj[p] instanceof APIMethod) { 163 | methods[obj[p].name || p] = obj[p].func 164 | meta[obj[p].name || p] = [obj[p].method, obj[p].url] 165 | } else if (obj[p] && typeof obj[p] === 'object') { 166 | const [childMethods, childMeta] = recursiveRegister(obj[p], binder) 167 | methods[p] = childMethods 168 | meta[p] = childMeta 169 | } 170 | } 171 | return [methods, meta] 172 | } 173 | 174 | function tryJSONParse (str) { 175 | try { 176 | return JSON.parse(str) 177 | } catch (_) { 178 | return undefined 179 | } 180 | } 181 | --------------------------------------------------------------------------------