├── .gitignore ├── README.md ├── app.ts ├── bin ├── japa_types.ts └── test.ts ├── package-lock.json ├── package.json ├── server.ts ├── src ├── routes.ts └── validators │ └── checkout_validator.ts ├── tests └── checkout │ └── validations.spec.ts ├── tsconfig.json └── vine_fastify_banner.png /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VineJS ❤️ Fastify 2 | > An example application using VineJS to validate the Fastify request body along with functional tests. 3 | 4 | ![](./vine_fastify_banner.png) 5 | 6 | ## What's in the box? 7 | 8 | - ESM application 😍 9 | - TypeScript setup with TS Node and SWC. 10 | - A basic fastify application 11 | - VineJS for validating request body 12 | - Japa for writing HTTP requests ( real requests are sent to the server ). 13 | 14 | ## What is VineJS? 15 | VineJS is a highly performant library to validate the HTTP request body in your backend applications. It is type-safe and has a vast collection of validation rules and schema types. 16 | 17 | You can learn more about it on the [official documentation website](https://vinejs.dev/docs/introduction). 18 | 19 | ## Setup 20 | - Clone this repo directly using Git or using `npx degit vinejs/vinejs-example-fastify`. 21 | - Run `npm install` to install dependencies. 22 | - Start the development server using the `npm run dev` script. 23 | - Run tests using the `npm test` script. 24 | 25 | ## Code organization 26 | - The functional code is kept inside the `./src/` directory. It includes routes and validators. 27 | - The fastify application is created inside the `app.ts` file. The same instance is used for testing and running the server. 28 | - The `server.ts` file starts the server. 29 | - The `bin/test.ts` file is used to run the tests. We use the Japa setup hooks to start the server before running any tests. 30 | 31 | -------------------------------------------------------------------------------- /app.ts: -------------------------------------------------------------------------------- 1 | import fastify from 'fastify' 2 | import { routes } from './src/routes.js' 3 | import { errors } from '@vinejs/vine' 4 | const server = fastify({ logger: true }) 5 | 6 | server.setErrorHandler(function (error, _, reply) { 7 | if (error instanceof errors.E_VALIDATION_ERROR) { 8 | reply.status(error.status).send(error.messages) 9 | } else { 10 | reply.send(error) 11 | } 12 | }) 13 | 14 | routes(server) 15 | export { server } 16 | -------------------------------------------------------------------------------- /bin/japa_types.ts: -------------------------------------------------------------------------------- 1 | import '@japa/runner' 2 | 3 | declare module '@japa/runner' { 4 | interface TestContext { 5 | // notify TypeScript about custom context properties 6 | } 7 | 8 | interface Test { 9 | // notify TypeScript about custom test properties 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@japa/assert' 2 | import { pathToFileURL } from 'node:url' 3 | import { apiClient } from '@japa/api-client' 4 | import { specReporter } from '@japa/spec-reporter' 5 | import { processCliArgs, configure, run } from '@japa/runner' 6 | import { server } from '../app.js' 7 | 8 | /* 9 | |-------------------------------------------------------------------------- 10 | | Configure tests 11 | |-------------------------------------------------------------------------- 12 | | 13 | | The configure method accepts the configuration to configure the Japa 14 | | tests runner. 15 | | 16 | | The first method call "processCliArgs" process the command line arguments 17 | | and turns them into a config object. Using this method is not mandatory. 18 | | 19 | | Please consult japa.dev/runner-config for the config docs. 20 | */ 21 | configure({ 22 | ...processCliArgs(process.argv.slice(2)), 23 | ...{ 24 | files: ['tests/**/*.spec.ts'], 25 | plugins: [ 26 | assert(), 27 | apiClient('http://localhost:3333'), 28 | ], 29 | setup: [ 30 | async () => { 31 | await server.listen() 32 | return () => server.close() 33 | }, 34 | ], 35 | reporters: [specReporter()], 36 | importer: (filePath) => import(pathToFileURL(filePath).href), 37 | }, 38 | }) 39 | 40 | /* 41 | |-------------------------------------------------------------------------- 42 | | Run tests 43 | |-------------------------------------------------------------------------- 44 | | 45 | | The following "run" method is required to execute all the tests. 46 | | 47 | */ 48 | run() 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vinejs-fastify", 3 | "version": "1.0.0", 4 | "description": "", 5 | "type": "module", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "node --loader=ts-node/esm server.js", 9 | "test": "node --loader=ts-node/esm bin/test.js" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "@japa/api-client": "^1.4.4", 16 | "@japa/assert": "^1.4.1", 17 | "@japa/runner": "^2.5.1", 18 | "@japa/spec-reporter": "^1.3.3", 19 | "@swc/core": "^1.3.64", 20 | "@types/node": "^20.3.1", 21 | "ts-node": "^10.9.1", 22 | "typescript": "^5.1.3" 23 | }, 24 | "dependencies": { 25 | "@vinejs/vine": "^1.4.1", 26 | "fastify": "^4.18.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import { server } from './app.js' 2 | 3 | const start = async () => { 4 | try { 5 | await server.listen({ port: 3000 }) 6 | } catch (err) { 7 | server.log.error(err) 8 | process.exit(1) 9 | } 10 | } 11 | 12 | start() 13 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from 'fastify' 2 | import { checkoutValidator } from './validators/checkout_validator.js' 3 | 4 | export function routes(server: FastifyInstance) { 5 | server.post('/checkout_as_guest', async (request) => { 6 | const payload = await checkoutValidator.validate(request.body) 7 | return { 8 | validated: payload, 9 | original: request.body, 10 | } 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/validators/checkout_validator.ts: -------------------------------------------------------------------------------- 1 | import vine from '@vinejs/vine' 2 | 3 | export const checkoutValidator = vine.compile( 4 | vine 5 | .object({ 6 | name: vine.string(), 7 | email: vine.string().email(), 8 | location: vine.object({ 9 | type: vine.enum(['home', 'office', 'other']), 10 | address: vine.string(), 11 | pincode: vine.string().postalCode({ countryCode: ['US'] }), 12 | phone: vine.string().mobile({ locale: ['en-US'] }), 13 | }), 14 | payment: vine.object({ 15 | card_number: vine.string().creditCard(), 16 | }), 17 | }) 18 | .toCamelCase() 19 | ) 20 | -------------------------------------------------------------------------------- /tests/checkout/validations.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@japa/runner' 2 | import { server } from '../../app.js' 3 | 4 | function getBaseUrl() { 5 | const address = server.addresses().find((address) => address.family === 'IPv4') 6 | if (!address) { 7 | throw new Error('Cannot find server address') 8 | } 9 | 10 | return `http://${address.address}:${address.port}` 11 | } 12 | 13 | test.group('Checkout | Validate personal details', () => { 14 | test('fail when personal details are missing', async ({ client }) => { 15 | const response = await client.post(`${getBaseUrl()}/checkout_as_guest`).json({}) 16 | 17 | response.assertStatus(422) 18 | response.assertBodyContains([ 19 | { 20 | field: 'name', 21 | message: 'The name field must be defined', 22 | rule: 'required', 23 | }, 24 | { 25 | field: 'email', 26 | message: 'The email field must be defined', 27 | rule: 'required', 28 | }, 29 | ]) 30 | }) 31 | 32 | test('fail when email is invalid', async ({ client }) => { 33 | const response = await client.post(`${getBaseUrl()}/checkout_as_guest`).json({ 34 | name: 'Mr. Foo', 35 | email: 'foo', 36 | }) 37 | 38 | response.assertStatus(422) 39 | response.assertBodyContains([ 40 | { 41 | field: 'email', 42 | message: 'The email field must be a valid email address', 43 | rule: 'email', 44 | }, 45 | ]) 46 | }) 47 | }) 48 | 49 | test.group('Checkout | Validate address', () => { 50 | test('fail when delivery location is missing', async ({ client }) => { 51 | const response = await client.post(`${getBaseUrl()}/checkout_as_guest`).json({ 52 | name: 'Mr. Foo', 53 | email: 'foo@bar.com', 54 | }) 55 | 56 | response.assertStatus(422) 57 | response.assertBodyContains([ 58 | { 59 | field: 'location', 60 | message: 'The location field must be defined', 61 | rule: 'required', 62 | }, 63 | ]) 64 | }) 65 | 66 | test('fail when location type is invalid', async ({ client }) => { 67 | const response = await client.post(`${getBaseUrl()}/checkout_as_guest`).json({ 68 | name: 'Mr. Foo', 69 | email: 'foo@bar.com', 70 | location: { 71 | type: 'building', 72 | }, 73 | }) 74 | 75 | response.assertStatus(422) 76 | response.assertBodyContains([ 77 | { 78 | field: 'location.type', 79 | message: 'The selected type is invalid', 80 | rule: 'enum', 81 | meta: { 82 | choices: ['home', 'office', 'other'], 83 | }, 84 | }, 85 | ]) 86 | }) 87 | 88 | test('fail when location pincode is invalid', async ({ client }) => { 89 | const response = await client.post(`${getBaseUrl()}/checkout_as_guest`).json({ 90 | name: 'Mr. Foo', 91 | email: 'foo@bar.com', 92 | location: { 93 | type: 'home', 94 | address: '418, 11th Street, East coast', 95 | pincode: 'foobar', 96 | }, 97 | }) 98 | 99 | response.assertStatus(422) 100 | response.assertBodyContains([ 101 | { 102 | field: 'location.pincode', 103 | message: 'The pincode field must be a valid postal code', 104 | rule: 'postalCode', 105 | meta: { 106 | countryCodes: ['US'], 107 | }, 108 | }, 109 | ]) 110 | }) 111 | }) 112 | 113 | test.group('Checkout | Validate payment', () => { 114 | test('fail when payment information is missing', async ({ client }) => { 115 | const response = await client.post(`${getBaseUrl()}/checkout_as_guest`).json({ 116 | name: 'Mr. Foo', 117 | email: 'foo@bar.com', 118 | location: { 119 | type: 'home', 120 | address: '418, 11th Street, East coast', 121 | pincode: '51411', 122 | phone: '8903345656', 123 | }, 124 | }) 125 | 126 | response.assertStatus(422) 127 | response.assertBodyContains([ 128 | { 129 | field: 'payment', 130 | message: 'The payment field must be defined', 131 | rule: 'required', 132 | }, 133 | ]) 134 | }) 135 | 136 | test('fail when credit card number is invalid', async ({ client }) => { 137 | const response = await client.post(`${getBaseUrl()}/checkout_as_guest`).json({ 138 | name: 'Mr. Foo', 139 | email: 'foo@bar.com', 140 | location: { 141 | type: 'home', 142 | address: '418, 11th Street, East coast', 143 | pincode: '51411', 144 | phone: '8903345656', 145 | }, 146 | payment: { 147 | card_number: '1291939292', 148 | }, 149 | }) 150 | 151 | response.assertStatus(422) 152 | response.assertBodyContains([ 153 | { 154 | field: 'payment.card_number', 155 | message: 'The card_number field must be a valid credit card number', 156 | rule: 'creditCard', 157 | }, 158 | ]) 159 | }) 160 | }) 161 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "lib": ["ESNext"], 6 | "noUnusedLocals": true, 7 | "noUnusedParameters": true, 8 | "isolatedModules": true, 9 | "removeComments": true, 10 | "declaration": false, 11 | "sourceMap": true, 12 | "rootDir": "./", 13 | "outDir": "./build", 14 | "esModuleInterop": true, 15 | "experimentalDecorators": true, 16 | "emitDecoratorMetadata": true, 17 | "strictNullChecks": true, 18 | "allowSyntheticDefaultImports": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "strictPropertyInitialization": true, 21 | "noImplicitAny": true, 22 | "strictBindCallApply": true, 23 | "strictFunctionTypes": true, 24 | "noImplicitThis": true, 25 | "skipLibCheck": true, 26 | "types": ["@types/node"] 27 | }, 28 | "include": ["./**/*"], 29 | "exclude": ["./node_modules", "./build"], 30 | "ts-node": { 31 | "swc": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /vine_fastify_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinejs/vinejs-example-fastify/44cc67c7634cd6bd4e3fbbdff16dfa5a63c7d6c5/vine_fastify_banner.png --------------------------------------------------------------------------------