├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── eslint.config.js ├── index.js ├── package.json ├── test ├── funkyPlugin.test.js └── helpers.js └── types ├── index.d.ts └── index.test-d.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - 'v*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 26 | with: 27 | license-check: true 28 | lint: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Vim swap files 133 | *.swp 134 | 135 | # macOS files 136 | .DS_Store 137 | 138 | # Clinic 139 | .clinic 140 | 141 | # lock files 142 | bun.lockb 143 | package-lock.json 144 | pnpm-lock.yaml 145 | yarn.lock 146 | 147 | # editor files 148 | .vscode 149 | .idea 150 | 151 | #tap files 152 | .tap/ 153 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Igor Savin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @fastify/funky 2 | 3 | [![NPM Version][npm-image]][npm-url] 4 | [![CI](https://github.com/fastify/fastify-funky/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify-funky/actions/workflows/ci.yml) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 6 | 7 | Support for Fastify routes returning functional structures, such as `fp-ts` Either, Task, TaskEither, or plain JavaScript parameterless functions. 8 | Let's go funky, let's go functional! 9 | 10 | ## Getting started 11 | 12 | First, install the package: 13 | 14 | ```bash 15 | npm i @fastify/funky 16 | ``` 17 | 18 | Next, set up the plugin: 19 | 20 | ```js 21 | const { fastifyFunky } = require('@fastify/funky') 22 | const fastify = require('fastify'); 23 | 24 | fastify.register(fastifyFunky); 25 | ``` 26 | 27 | `@fastify/funky` plugin is executed during `preSerialization` response lifecycle phase. 28 | 29 | ## Supported structures 30 | 31 | While the most convenient way to use this plugin is with `fp-ts` library, it is not required. 32 | `@fastify/funky` supports the following data structures: 33 | 34 | ### Parameterless functions: 35 | 36 | ```js 37 | app.get('/', (req, reply) => { 38 | // This will result in a response 200: { id: 1} 39 | return () => { return { id: 1} } 40 | 41 | // Same as above 42 | return () => { return { right: 43 | { id: 1 } 44 | }} 45 | 46 | // Same as above 47 | return () => { return Promise.resolve({ id: 1 })} 48 | 49 | // Same as above 50 | return () => { return Promise.resolve({ right: 51 | { id: 1 } 52 | })} 53 | 54 | // Plugin will pass-through this value without doing anything 55 | return (id) => { return Promise.resolve({ id })} 56 | }); 57 | ``` 58 | 59 | If the function returns an `Either` object, it will be handled in the same way as if you returned that `Either` object directly. 60 | 61 | If the function returns a Promise, it will be resolved. If Promise resolves to an `Either` object, it will be handled in the same way as if you returned that `Either` object directly. 62 | 63 | If the function directly returns anything else, or if its Promise resolves to anything else, that result is passed further along the chain as the plugin execution result. 64 | 65 | Note that functions with parameters will be ignored by the plugin and passed-through as-is. 66 | 67 | ### `Either` objects: 68 | 69 | Right value is passed further along the chain as the plugin execution result: 70 | 71 | ```js 72 | app.get('/', (req, reply) => { 73 | // This will result in a response 200: { id: 1} 74 | return { right: 75 | { id: 1 } 76 | } 77 | }); 78 | ``` 79 | 80 | Left value is passed to the error handler: 81 | 82 | ```js 83 | app.get('/', (req, reply) => { 84 | // This will propagate to fastify error handler, which by default will result in a response 500: Internal server error 85 | return { left: new Error('Invalid state') } 86 | }); 87 | ``` 88 | 89 | ## Using with fp-ts 90 | 91 | With the plugin registered, you can start returning entities of type Either, Task, or plain parameterless functions as router method results: 92 | 93 | ```js 94 | const { either, task, taskEither } = require('fp-ts') 95 | 96 | app.get('/', (req, reply) => { 97 | // This will result in a response 200: { id: 1} 98 | return either.right({ id: 1}) 99 | 100 | // Same as above 101 | return task.of(Promise.resolve({ id: 1})) 102 | 103 | // Same as above 104 | return taskEither.fromEither(either.right({ id: 1})) 105 | 106 | // Same as above 107 | return taskEither.fromTask(task.of(Promise.resolve({ id: 1}))) 108 | 109 | // Same as above 110 | return () => { return { id: 1} } 111 | 112 | // This will propagate to fastify error handler, which by default will result in a response 500: Internal server error 113 | return either.left(new Error('Invalid state')) 114 | 115 | // Same as above 116 | return taskEither.fromEither(either.left(new Error('Invalid state'))) 117 | }); 118 | ``` 119 | 120 | ## License 121 | 122 | Licensed under [MIT](./LICENSE). 123 | 124 | [npm-image]: https://img.shields.io/npm/v/@fastify/funky.svg 125 | [npm-url]: https://npmjs.com/package/@fastify/funky 126 | [downloads-image]: https://img.shields.io/npm/dm/fastify-funky.svg 127 | [downloads-url]: https://npmjs.org/package/@fastify/funky 128 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ignores: require('neostandard').resolveIgnoresFromGitignore(), 5 | ts: true 6 | }) 7 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const isPromise = require('node:util').types.isPromise 4 | const fp = require('fastify-plugin') 5 | 6 | function fastifyFunky (fastify, _opts, next) { 7 | fastify.addHook('preSerialization', (_req, res, payload, done) => { 8 | // Handle Either 9 | if (isEither(payload)) { 10 | return resolvePayload(done, payload.left, payload.right, res) 11 | } 12 | 13 | // Handle Task 14 | if (isTask(payload)) { 15 | const result = payload() 16 | if (isPromise(result)) { 17 | result 18 | .then((taskResult) => { 19 | if (isEither(taskResult)) { 20 | return resolvePayload(done, taskResult.left, taskResult.right, res) 21 | } 22 | 23 | return resolvePayload(done, null, taskResult, res) 24 | }) 25 | .catch(done) 26 | return 27 | } 28 | if (isEither(result)) { 29 | return resolvePayload(done, result.left, result.right, res) 30 | } 31 | return resolvePayload(done, null, result, this) 32 | } 33 | 34 | done(null, payload) 35 | }) 36 | 37 | next() 38 | } 39 | 40 | function resolvePayload (done, err, result, reply) { 41 | if (typeof result === 'string') { 42 | reply.type('text/plain; charset=utf-8').serializer(String) 43 | } 44 | return done(err, result) 45 | } 46 | 47 | function isEither (payload) { 48 | return payload.left || payload.right 49 | } 50 | 51 | function isTask (value) { 52 | return typeof value === 'function' && value.length === 0 53 | } 54 | 55 | module.exports = fp(fastifyFunky, { 56 | fastify: '5.x', 57 | name: '@fastify/funky' 58 | }) 59 | module.exports.default = fastifyFunky 60 | module.exports.fastifyFunky = fastifyFunky 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify/funky", 3 | "description": "Support for fastify routes returning functional structures, such as fp-ts Either, Task, TaskEither or plain javascript parameterless functions", 4 | "version": "4.0.2", 5 | "license": "MIT", 6 | "maintainers": [ 7 | { 8 | "name": "Igor Savin", 9 | "email": "kibertoad@gmail.com" 10 | } 11 | ], 12 | "contributors": [ 13 | { 14 | "name": "Matteo Collina", 15 | "email": "hello@matteocollina.com" 16 | }, 17 | { 18 | "name": "Manuel Spigolon", 19 | "email": "behemoth89@gmail.com" 20 | }, 21 | { 22 | "name": "Aras Abbasi", 23 | "email": "aras.abbasi@gmail.com" 24 | }, 25 | { 26 | "name": "Frazer Smith", 27 | "email": "frazer.dev@icloud.com", 28 | "url": "https://github.com/fdawgs" 29 | } 30 | ], 31 | "main": "index.js", 32 | "type": "commonjs", 33 | "types": "types/index.d.ts", 34 | "scripts": { 35 | "lint": "eslint", 36 | "lint:fix": "eslint --fix", 37 | "test": "npm run test:unit && npm run test:typescript", 38 | "test:unit": "c8 --100 node --test", 39 | "test:typescript": "tsd" 40 | }, 41 | "dependencies": { 42 | "fastify-plugin": "^5.0.0" 43 | }, 44 | "devDependencies": { 45 | "@fastify/pre-commit": "^2.1.0", 46 | "@types/node": "^22.7.4", 47 | "c8": "^10.1.2", 48 | "eslint": "^9.17.0", 49 | "fastify": "^5.0.0", 50 | "fp-ts": "^2.11.2", 51 | "neostandard": "^0.12.0", 52 | "tsd": "^0.32.0" 53 | }, 54 | "homepage": "https://github.com/fastify/fastify-funky", 55 | "funding": [ 56 | { 57 | "type": "github", 58 | "url": "https://github.com/sponsors/fastify" 59 | }, 60 | { 61 | "type": "opencollective", 62 | "url": "https://opencollective.com/fastify" 63 | } 64 | ], 65 | "repository": { 66 | "type": "git", 67 | "url": "git://github.com/fastify/fastify-funky.git" 68 | }, 69 | "bugs": { 70 | "url": "https://github.com/fastify/fastify-funky/issues" 71 | }, 72 | "keywords": [ 73 | "fastify", 74 | "plugin", 75 | "typescript", 76 | "algebraic-data-types", 77 | "functional-programming", 78 | "functional", 79 | "fp-ts", 80 | "either", 81 | "task" 82 | ], 83 | "files": [ 84 | "README.md", 85 | "LICENSE", 86 | "lib/*", 87 | "index.js", 88 | "types/index.d.ts" 89 | ], 90 | "publishConfig": { 91 | "access": "public" 92 | }, 93 | "pre-commit": [ 94 | "lint", 95 | "test" 96 | ] 97 | } 98 | -------------------------------------------------------------------------------- /test/funkyPlugin.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify') 4 | const { either, task, taskEither } = require('fp-ts') 5 | const { test } = require('node:test') 6 | const { 7 | initAppGet, 8 | assertResponseTypeAndBody, 9 | assertCorrectResponse, 10 | assertCorrectResponseBody, 11 | assertErrorResponse 12 | } = require('./helpers') 13 | const { fastifyFunky } = require('../index') 14 | 15 | const DUMMY_USER = { 16 | user: { 17 | id: 1 18 | } 19 | } 20 | 21 | test('Promise: Correctly handles top-level promise', async (t) => { 22 | t.plan(2) 23 | 24 | const route = (_req, _reply) => { 25 | return Promise.resolve(either.right(DUMMY_USER)) 26 | } 27 | 28 | const app = await initAppGet(t, route).ready() 29 | 30 | t.after(() => { 31 | app.close() 32 | }) 33 | 34 | await assertCorrectResponse(t, app) 35 | }) 36 | 37 | test('either: correctly parses right part of Either (sync)', async (t) => { 38 | t.plan(2) 39 | 40 | const route = (_req, _reply) => { 41 | return either.right(DUMMY_USER) 42 | } 43 | 44 | const app = await initAppGet(t, route).ready() 45 | 46 | t.after(() => { 47 | app.close() 48 | }) 49 | 50 | await assertCorrectResponse(t, app) 51 | }) 52 | 53 | test('either: correctly parses right part of Either (async)', async (t) => { 54 | t.plan(2) 55 | 56 | const route = (_req, _reply) => { 57 | const payload = either.right(DUMMY_USER) 58 | return Promise.resolve(payload) 59 | } 60 | 61 | const app = await initAppGet(t, route).ready() 62 | 63 | t.after(() => { 64 | app.close() 65 | }) 66 | 67 | await assertCorrectResponse(t, app) 68 | }) 69 | 70 | test('either: correctly parses left part of Either when resolved (async)', async (t) => { 71 | t.plan(3) 72 | 73 | const route = (_req, _reply) => { 74 | const payload = either.left(new Error('Invalid state')) 75 | return Promise.resolve(payload) 76 | } 77 | 78 | const app = await initAppGet(t, route).ready() 79 | 80 | t.after(() => { 81 | app.close() 82 | }) 83 | 84 | await assertErrorResponse(t, app) 85 | }) 86 | 87 | test('either: correctly parses left part of Either when resolved (sync)', async (t) => { 88 | t.plan(3) 89 | 90 | const route = (_req, _reply) => { 91 | return either.left(new Error('Invalid state')) 92 | } 93 | 94 | const app = await initAppGet(t, route).ready() 95 | 96 | t.after(() => { 97 | app.close() 98 | }) 99 | 100 | await assertErrorResponse(t, app) 101 | }) 102 | 103 | test('either: supports reply callback with the right part of Either', async (t) => { 104 | t.plan(2) 105 | 106 | const route = (_req, reply) => { 107 | const payload = either.right(DUMMY_USER) 108 | Promise.resolve(payload).then((result) => { 109 | reply.send(result) 110 | }) 111 | return reply 112 | } 113 | 114 | const app = await initAppGet(t, route).ready() 115 | 116 | t.after(() => { 117 | app.close() 118 | }) 119 | 120 | await assertCorrectResponse(t, app) 121 | }) 122 | 123 | test('task: correctly parses Task result (sync)', async (t) => { 124 | t.plan(2) 125 | 126 | const route = (_req, _reply) => { 127 | return task.of(DUMMY_USER) 128 | } 129 | 130 | const app = await initAppGet(t, route).ready() 131 | 132 | t.after(() => { 133 | app.close() 134 | }) 135 | 136 | await assertCorrectResponse(t, app) 137 | }) 138 | 139 | test('task: correctly parses Task result (promise)', async (t) => { 140 | t.plan(2) 141 | 142 | const route = (_req, _reply) => { 143 | return task.of(Promise.resolve(DUMMY_USER)) 144 | } 145 | 146 | const app = await initAppGet(t, route).ready() 147 | 148 | t.after(() => { 149 | app.close() 150 | }) 151 | 152 | await assertCorrectResponse(t, app) 153 | }) 154 | 155 | test('task: correctly handles Task throwing', async (t) => { 156 | t.plan(3) 157 | 158 | const route = (_req, _reply) => { 159 | return () => { 160 | return Promise.resolve().then(() => { 161 | throw new Error('Invalid state') 162 | }) 163 | } 164 | } 165 | 166 | const app = await initAppGet(t, route).ready() 167 | 168 | t.after(() => { 169 | app.close() 170 | }) 171 | 172 | await assertErrorResponse(t, app) 173 | }) 174 | 175 | test('task: correctly parses result of a plain parameterless function', async (t) => { 176 | t.plan(2) 177 | 178 | const route = (_req, _reply) => { 179 | const payload = () => { 180 | return DUMMY_USER 181 | } 182 | 183 | return payload 184 | } 185 | 186 | const app = await initAppGet(t, route).ready() 187 | 188 | t.after(() => { 189 | app.close() 190 | }) 191 | 192 | await assertCorrectResponse(t, app) 193 | }) 194 | 195 | test('task: correctly parses result of a plain parameterless function that returns Either', async (t) => { 196 | t.plan(2) 197 | 198 | const route = (_req, _reply) => { 199 | const payload = () => { 200 | return { right: DUMMY_USER } 201 | } 202 | 203 | return payload 204 | } 205 | 206 | const app = await initAppGet(t, route).ready() 207 | 208 | t.after(() => { 209 | app.close() 210 | }) 211 | 212 | await assertCorrectResponse(t, app) 213 | }) 214 | 215 | test('task: ignores parameterless function with parameters', async (t) => { 216 | t.plan(2) 217 | 218 | const route = (_req, _reply) => { 219 | const payload = (user) => { 220 | return user 221 | } 222 | 223 | return payload 224 | } 225 | 226 | const app = await initAppGet(t, route).ready() 227 | 228 | t.after(() => { 229 | app.close() 230 | }) 231 | 232 | await assertCorrectResponseBody(t, app, '') 233 | }) 234 | 235 | test('task: handles empty body correctly', async (t) => { 236 | t.plan(2) 237 | 238 | const route = (_req, reply) => { 239 | reply.code(204).send() 240 | } 241 | 242 | const app = await initAppGet(t, route).ready() 243 | 244 | t.after(() => { 245 | app.close() 246 | }) 247 | 248 | await assertCorrectResponseBody(t, app, '', 204) 249 | }) 250 | 251 | test('text content: correctly handles text response', async (t) => { 252 | t.plan(16) 253 | 254 | const text = 'text' 255 | const obj = { json: true } 256 | 257 | const app = fastify().register(fastifyFunky) 258 | 259 | t.after(() => { 260 | app.close() 261 | }) 262 | 263 | app.get('/simple-json', async () => obj) 264 | app.get('/simple-text', async () => text) 265 | app.get('/task-json', async () => task.of(obj)) 266 | app.get('/task-text', async () => task.of(text)) 267 | app.get('/either-json', async () => either.of(obj)) 268 | app.get('/either-text', async () => either.of(text)) 269 | app.get('/taskeither-json', async () => taskEither.of(obj)) 270 | app.get('/taskeither-text', async () => taskEither.of(text)) 271 | 272 | await app.listen({ port: 3000 }) 273 | 274 | const objStr = JSON.stringify(obj) 275 | for (const endpoint of ['/simple-json', '/task-json', '/either-json', '/taskeither-json']) { 276 | await assertResponseTypeAndBody(t, app, endpoint, 'application/json; charset=utf-8', objStr) 277 | } 278 | 279 | for (const endpoint of ['/simple-text', '/task-text', '/either-text', '/taskeither-text']) { 280 | await assertResponseTypeAndBody(t, app, endpoint, 'text/plain; charset=utf-8', text) 281 | } 282 | }) 283 | 284 | test('taskEither: correctly parses TaskEither result (Either right)', async (t) => { 285 | t.plan(2) 286 | 287 | const route = (_req, _reply) => { 288 | return taskEither.fromEither(either.right(DUMMY_USER)) 289 | } 290 | 291 | const app = await initAppGet(t, route).ready() 292 | 293 | t.after(() => { 294 | app.close() 295 | }) 296 | 297 | await assertCorrectResponse(t, app) 298 | }) 299 | 300 | test('taskEither: correctly parses TaskEither result (Task)', async (t) => { 301 | t.plan(2) 302 | 303 | const route = (_req, _reply) => { 304 | return taskEither.fromEither(either.right(DUMMY_USER)) 305 | } 306 | 307 | const app = await initAppGet(t, route).ready() 308 | 309 | t.after(() => { 310 | app.close() 311 | }) 312 | 313 | await assertCorrectResponse(t, app) 314 | }) 315 | 316 | test('taskEither: correctly parses TaskEither result (Either left)', async (t) => { 317 | t.plan(3) 318 | 319 | const route = (_req, _reply) => { 320 | return taskEither.fromEither(either.left(new Error('Invalid state'))) 321 | } 322 | 323 | const app = await initAppGet(t, route).ready() 324 | 325 | t.after(() => { 326 | app.close() 327 | }) 328 | 329 | await assertErrorResponse(t, app) 330 | }) 331 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify') 4 | const { fastifyFunky } = require('../index') 5 | 6 | function initAppGet (t, endpoint) { 7 | const app = fastify() 8 | app.register(fastifyFunky) 9 | 10 | app.get('/', endpoint) 11 | 12 | app.setErrorHandler((error, _request, reply) => { 13 | app.log.error(error) 14 | t.assert.deepStrictEqual(error.message, 'Invalid state') 15 | reply.status(500).send({ ok: false }) 16 | }) 17 | 18 | return app 19 | } 20 | 21 | async function assertResponseTypeAndBody (t, app, endpoint, expectedType, expectedBody) { 22 | const response = await app.inject().get(endpoint).end() 23 | t.assert.deepStrictEqual(response.headers['content-type'], expectedType) 24 | t.assert.deepStrictEqual(response.body, expectedBody) 25 | } 26 | 27 | function assertCorrectResponse (t, app) { 28 | return app 29 | .inject() 30 | .get('/') 31 | .end() 32 | .then((response) => { 33 | t.assert.deepStrictEqual(response.statusCode, 200) 34 | t.assert.deepStrictEqual(response.json().user, { id: 1 }) 35 | }) 36 | } 37 | 38 | function assertCorrectResponseBody (t, app, expectedBody, expectedCode = 200) { 39 | return app 40 | .inject() 41 | .get('/') 42 | .end() 43 | .then((response) => { 44 | t.assert.deepStrictEqual(response.statusCode, expectedCode) 45 | t.assert.deepStrictEqual(response.body, expectedBody) 46 | }) 47 | } 48 | 49 | function assertErrorResponse (t, app) { 50 | return app 51 | .inject() 52 | .get('/') 53 | .end() 54 | .then((response) => { 55 | t.assert.deepStrictEqual(response.statusCode, 500) 56 | t.assert.deepStrictEqual(response.json(), { 57 | ok: false 58 | }) 59 | }) 60 | } 61 | 62 | module.exports = { 63 | initAppGet, 64 | assertResponseTypeAndBody, 65 | assertCorrectResponse, 66 | assertCorrectResponseBody, 67 | assertErrorResponse 68 | } 69 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginCallback } from 'fastify' 2 | 3 | type FastifyFunky = FastifyPluginCallback 4 | 5 | declare namespace fastifyFunky { 6 | 7 | export interface Left { 8 | readonly left: E 9 | } 10 | 11 | export interface Right { 12 | readonly right: A 13 | } 14 | 15 | export type Either = Left | Right 16 | 17 | export interface Task { 18 | (): Promise 19 | } 20 | 21 | export type FunkyReply = 22 | | TaskEither 23 | | Either 24 | | Right 25 | | Left 26 | | Task 27 | | (() => FunkyReply) 28 | 29 | interface TaskEither extends Task> { } 30 | 31 | export const fastifyFunky: FastifyFunky 32 | export { fastifyFunky as default } 33 | } 34 | 35 | declare function fastifyFunky (...params: Parameters): ReturnType 36 | export = fastifyFunky 37 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import fastify, { FastifyInstance, FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify' 2 | import { either, task, taskEither } from 'fp-ts' 3 | import { expectType, expectAssignable } from 'tsd' 4 | 5 | import fastifyFunkyDefault, { fastifyFunky as fastifyFunkyNamed } from '..' 6 | import * as fastifyFunkyStar from '..' 7 | import fastifyFunkyCjsImport = require('..') 8 | const fastifyFunkyCjs = require('./') 9 | 10 | const app: FastifyInstance = fastify() 11 | app.register(fastifyFunkyNamed) 12 | app.register(fastifyFunkyDefault) 13 | app.register(fastifyFunkyCjs) 14 | app.register(fastifyFunkyCjsImport.default) 15 | app.register(fastifyFunkyCjsImport.fastifyFunky) 16 | app.register(fastifyFunkyStar.default) 17 | app.register(fastifyFunkyStar.fastifyFunky) 18 | 19 | expectType(fastifyFunkyNamed) 20 | expectType(fastifyFunkyDefault) 21 | expectType(fastifyFunkyCjsImport.default) 22 | expectType(fastifyFunkyCjsImport.fastifyFunky) 23 | expectType(fastifyFunkyStar.default) 24 | expectType(fastifyFunkyStar.fastifyFunky) 25 | expectType(fastifyFunkyCjs) 26 | 27 | app.register(fastifyFunkyDefault) 28 | // this gives a type error: 29 | app.get('/', (_req: FastifyRequest, _reply: FastifyReply) => { 30 | return { right: { id: 1 } } 31 | }) 32 | 33 | app.get('/func', (req, reply) => { 34 | expectAssignable(req) 35 | expectAssignable(reply) 36 | return () => { 37 | return { right: { id: 1 } } 38 | } 39 | }) 40 | 41 | app.get('/func', (req, reply) => { 42 | expectAssignable(req) 43 | expectAssignable(reply) 44 | return Promise.resolve({}) 45 | }) 46 | 47 | app.get('/func', (req, reply) => { 48 | expectAssignable(req) 49 | expectAssignable(reply) 50 | reply.status(200).send({}) 51 | }) 52 | 53 | // this is allowed 54 | app.get('/', (req, reply) => { 55 | expectAssignable(req) 56 | expectAssignable(reply) 57 | return { right: { id: 1 } } 58 | }) 59 | app.get('/', (req, reply) => { 60 | expectAssignable(req) 61 | expectAssignable(reply) 62 | return { left: new Error('error') } 63 | }) 64 | app.get('/', (req, reply) => { 65 | expectAssignable(req) 66 | expectAssignable(reply) 67 | return taskEither.fromEither(either.left(new Error('Invalid state'))) 68 | }) 69 | app.get('/', (req, reply) => { 70 | expectAssignable(req) 71 | expectAssignable(reply) 72 | return taskEither.fromTask(task.of(Promise.resolve({}))) 73 | }) 74 | app.get('/', (req, reply) => { 75 | expectAssignable(req) 76 | expectAssignable(reply) 77 | return either.of(Promise.resolve({})) 78 | }) 79 | app.get('/', (req, reply) => { 80 | expectAssignable(req) 81 | expectAssignable(reply) 82 | return task.of(Promise.resolve({})) 83 | }) 84 | app.get('/', (req, reply) => { 85 | expectAssignable(req) 86 | expectAssignable(reply) 87 | return taskEither.of(Promise.resolve({})) 88 | }) 89 | --------------------------------------------------------------------------------