├── .husky ├── .gitignore ├── pre-commit └── commit-msg ├── .dockerignore ├── .eslintignore ├── src ├── routes │ ├── index.ts │ ├── post.router.ts │ └── user.router.ts ├── controllers │ ├── index.ts │ ├── post.controller.ts │ └── user.controller.ts ├── types │ └── fastify.d.ts ├── schemas │ ├── Post.ts │ └── User.ts ├── helpers │ ├── validation.helper.ts │ ├── errors.helper.ts │ └── auth.helper.ts ├── constants │ └── request.ts ├── config │ └── env.config.ts ├── utils.ts └── main.ts ├── .prettierrc ├── prisma ├── migrations │ ├── migration_lock.toml │ ├── 20220420131626_ │ │ └── migration.sql │ └── 20240714121700_ │ │ └── migration.sql └── schema.prisma ├── .env.docker ├── Dockerfile ├── ci └── Dockerfile ├── tsconfig.json ├── test └── jest.json ├── .eslintrc.js ├── docker-compose.yml ├── .gitignore ├── commitlint.config.js ├── LICENSE ├── README.md └── package.json /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | *.d.ts 5 | ci 6 | test 7 | jest.json -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './post.router'; 2 | export * from './user.router'; 3 | -------------------------------------------------------------------------------- /src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.controller'; 2 | export * from './post.controller'; 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 80, 5 | "tabWidth": 2, 6 | "semi": true 7 | } -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /src/types/fastify.d.ts: -------------------------------------------------------------------------------- 1 | import 'fastify'; 2 | 3 | declare module 'fastify' { 4 | interface FastifyRequest { 5 | authUser?: { 6 | id: number; 7 | email: string; 8 | }; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/schemas/Post.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | export interface IPostCreateDto { 4 | content: string; 5 | } 6 | 7 | export const postCreateSchema = Joi.object({ 8 | content: Joi.string().required(), 9 | }); 10 | -------------------------------------------------------------------------------- /.env.docker: -------------------------------------------------------------------------------- 1 | NODE_ENV="development" 2 | LOG_LEVEL="debug" 3 | 4 | API_HOST=0.0.0.0 5 | API_PORT=5000 6 | 7 | APP_JWT_SECRET="y4kXCSAHfluj3iOfd8ua3atrCLeYOFTu" 8 | 9 | DATABASE_URL="postgresql://admin:master123@postgres:5432/fastify_db?schema=public" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json package-lock.json ./ 6 | COPY prisma ./prisma/ 7 | 8 | RUN npm install --legacy-peer-deps 9 | 10 | RUN npm run db:gen 11 | 12 | COPY . . 13 | 14 | EXPOSE 5001 15 | 16 | CMD [ "npm", "run", "dev" ] -------------------------------------------------------------------------------- /src/helpers/validation.helper.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | import { FastifyReply, FastifyRequest } from 'fastify'; 3 | 4 | export const validateSchema = (schema: Joi.ObjectSchema) => { 5 | return ( 6 | request: FastifyRequest, 7 | reply: FastifyReply, 8 | done: (err?: Error) => void, 9 | ) => { 10 | try { 11 | const { error } = schema.validate(request.body); 12 | if (error) { 13 | throw error; 14 | } 15 | done(); 16 | } catch (error) { 17 | done(error); 18 | } 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /ci/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json package-lock.json ./ 6 | 7 | RUN npm install --legacy-peer-deps 8 | 9 | COPY prisma ./prisma 10 | 11 | RUN npm run db:gen 12 | 13 | COPY . . 14 | 15 | RUN npm run build 16 | 17 | FROM node:20-alpine 18 | 19 | WORKDIR /app 20 | 21 | COPY --from=builder /app/node_modules ./node_modules 22 | COPY --from=builder /app/package.json ./ 23 | COPY --from=builder /app/package-lock.json ./ 24 | COPY --from=builder /app/dist ./dist 25 | 26 | EXPOSE 5001 27 | 28 | CMD ["npm", "start"] 29 | -------------------------------------------------------------------------------- /src/constants/request.ts: -------------------------------------------------------------------------------- 1 | export const STANDARD = { 2 | OK: { 3 | message: 'OK', 4 | statusCode: 200, 5 | }, 6 | CREATED: { 7 | message: 'Created', 8 | statusCode: 201, 9 | }, 10 | ACCEPTED: { 11 | message: 'Accepted', 12 | statusCode: 202, 13 | }, 14 | NO_CONTENT: { 15 | message: 'No Content', 16 | statusCode: 204, 17 | }, 18 | RESET_CONTENT: { 19 | message: 'Reset Content', 20 | statusCode: 205, 21 | }, 22 | PARTIAL_CONTENT: { 23 | message: 'Partial Content', 24 | statusCode: 206, 25 | }, 26 | } as const; 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2020"], 4 | "module": "commonjs", 5 | "target": "es2020", 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "incremental": true, 9 | "noImplicitAny": false, 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "paths": { 15 | "config/*": ["config/*"] 16 | }, 17 | "types": ["node", "jest"] 18 | }, 19 | "include": ["src", "test"], 20 | "exclude": ["node_modules", "dist", "coverage"] 21 | } 22 | -------------------------------------------------------------------------------- /src/schemas/User.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | export interface IUserLoginDto { 4 | email: string; 5 | password: string; 6 | } 7 | 8 | export interface IUserSignupDto { 9 | email: string; 10 | password: string; 11 | firstName: string; 12 | lastName: string; 13 | } 14 | 15 | export const loginSchema = Joi.object({ 16 | email: Joi.string().email().required(), 17 | password: Joi.string().min(8).required(), 18 | }); 19 | 20 | export const signupSchema = Joi.object({ 21 | email: Joi.string().email().required(), 22 | password: Joi.string().min(8).required(), 23 | firstName: Joi.string().optional(), 24 | lastName: Joi.string().optional(), 25 | }); 26 | -------------------------------------------------------------------------------- /test/jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "testTimeout": 5000, 3 | "rootDir": "../", 4 | "modulePaths": ["."], 5 | "testEnvironment": "node", 6 | "testMatch": ["/test/**/*.spec.ts"], 7 | "collectCoverage": true, 8 | "coverageDirectory": "coverage", 9 | "coverageReporters": ["lcov", "json-summary"], 10 | "collectCoverageFrom": [], 11 | "coverageThreshold": { 12 | "global": { 13 | "branches": 100, 14 | "functions": 100, 15 | "lines": 100, 16 | "statements": 100 17 | } 18 | }, 19 | "moduleFileExtensions": ["js", "ts", "json"], 20 | "transform": { 21 | "^.+\\.(t|j)s$": "ts-jest" 22 | }, 23 | "modulePathIgnorePatterns": ["/dist"] 24 | } 25 | -------------------------------------------------------------------------------- /src/controllers/post.controller.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify'; 2 | import { handleServerError } from '../helpers/errors.helper'; 3 | import { prisma } from '../utils'; 4 | import { STANDARD } from '../constants/request'; 5 | import { IPostCreateDto } from '../schemas/Post'; 6 | 7 | export const createPost = async ( 8 | request: FastifyRequest<{ 9 | Body: IPostCreateDto; 10 | }>, 11 | reply: FastifyReply, 12 | ) => { 13 | try { 14 | const { id } = request['authUser']; 15 | const { content } = request.body; 16 | const post = await prisma.post.create({ 17 | data: { 18 | content, 19 | created_by: { 20 | connect: { 21 | id: id, 22 | }, 23 | }, 24 | view_count: 0, 25 | }, 26 | }); 27 | reply.status(STANDARD.OK.statusCode).send({ data: post }); 28 | } catch (e) { 29 | handleServerError(reply, e); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/routes/post.router.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify'; 2 | import * as controllers from '../controllers'; 3 | import { checkValidRequest, checkValidUser } from '../helpers/auth.helper'; 4 | import { postCreateSchema } from '../schemas/Post'; 5 | import { utils } from '../utils'; 6 | 7 | async function postRouter(fastify: FastifyInstance) { 8 | fastify.decorateRequest('authUser', null); 9 | 10 | fastify.post( 11 | '/create', 12 | { 13 | schema: { 14 | body: { 15 | type: 'object', 16 | properties: { 17 | content: { type: 'string' }, 18 | }, 19 | required: ['content'], 20 | }, 21 | }, 22 | config: { 23 | description: 'Create a new post', 24 | }, 25 | preValidation: utils.preValidation(postCreateSchema), 26 | preHandler: [checkValidRequest, checkValidUser], 27 | }, 28 | controllers.createPost, 29 | ); 30 | } 31 | 32 | export default postRouter; 33 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | ecmaVersion: 2020, 6 | sourceType: 'module', 7 | }, 8 | env: { 9 | node: true, 10 | es6: true, 11 | }, 12 | extends: [ 13 | 'eslint:recommended', 14 | 'plugin:@typescript-eslint/recommended', 15 | 'plugin:prettier/recommended', 16 | 'prettier', 17 | ], 18 | plugins: ['@typescript-eslint', 'prettier'], 19 | rules: { 20 | 'prettier/prettier': 'error', 21 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 22 | 'no-console': 'warn', 23 | 'no-debugger': 'warn', 24 | 'import/order': [ 25 | 'error', 26 | { 27 | groups: [['builtin', 'external', 'internal']], 28 | 'newlines-between': 'always', 29 | }, 30 | ], 31 | 'node/no-unsupported-features/es-syntax': [ 32 | 'error', 33 | { 34 | ignores: ['modules'], 35 | }, 36 | ], 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:latest 4 | ports: 5 | - '5432:5432' 6 | environment: 7 | - POSTGRES_USER=admin 8 | - POSTGRES_PASSWORD=master123 9 | - POSTGRES_DB=postgres 10 | volumes: 11 | - pg_data:/var/lib/postgresql 12 | networks: 13 | - backend 14 | 15 | redis: 16 | image: redis:latest 17 | restart: always 18 | ports: 19 | - '6379:6379' 20 | command: redis-server --save 20 1 --loglevel warning 21 | volumes: 22 | - redis_data:/data 23 | networks: 24 | - backend 25 | 26 | api: 27 | build: 28 | dockerfile: Dockerfile 29 | context: . 30 | ports: 31 | - 5001:5001 32 | env_file: 33 | - .env.docker 34 | volumes: 35 | - ./:/app 36 | - /app/node_modules 37 | networks: 38 | - backend 39 | 40 | networks: 41 | backend: 42 | driver: bridge 43 | 44 | volumes: 45 | pg_data: 46 | driver: local 47 | redis_data: 48 | driver: local 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # 0x 40 | profile-* 41 | 42 | # mac files 43 | .DS_Store 44 | 45 | # vim swap files 46 | *.swp 47 | 48 | # webstorm 49 | .idea 50 | 51 | # vscode 52 | .vscode 53 | *code-workspace 54 | 55 | # clinic 56 | profile* 57 | *clinic* 58 | *flamegraph* 59 | 60 | build/ 61 | 62 | .env 63 | dist -------------------------------------------------------------------------------- /prisma/migrations/20220420131626_/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" SERIAL NOT NULL, 4 | "email" TEXT NOT NULL, 5 | "firstName" TEXT, 6 | "lastName" TEXT, 7 | "password" TEXT NOT NULL, 8 | "profile" TEXT, 9 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | "updatedAt" TIMESTAMP(3) NOT NULL, 11 | 12 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 13 | ); 14 | 15 | -- CreateTable 16 | CREATE TABLE "Post" ( 17 | "id" SERIAL NOT NULL, 18 | "content" TEXT NOT NULL, 19 | "viewCount" INTEGER NOT NULL, 20 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 21 | "updatedAt" TIMESTAMP(3) NOT NULL, 22 | "userId" INTEGER NOT NULL, 23 | 24 | CONSTRAINT "Post_pkey" PRIMARY KEY ("id") 25 | ); 26 | 27 | -- CreateIndex 28 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 29 | 30 | -- AddForeignKey 31 | ALTER TABLE "Post" ADD CONSTRAINT "Post_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 32 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "postgresql" 6 | url = env("DATABASE_URL") 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | binaryTargets = ["native", "linux-musl"] 12 | } 13 | 14 | model User { 15 | id Int @id @default(autoincrement()) 16 | email String @unique 17 | first_name String? 18 | last_name String? 19 | password String 20 | profile String? 21 | created_at DateTime @default(now()) 22 | updated_at DateTime @updatedAt 23 | posts Post[] 24 | 25 | @@map("users") 26 | } 27 | 28 | model Post { 29 | id Int @id @default(autoincrement()) 30 | content String 31 | view_count Int 32 | created_at DateTime @default(now()) 33 | updated_at DateTime @updatedAt 34 | created_by User @relation(fields: [author_id], references: [id]) 35 | author_id Int 36 | } 37 | 38 | enum Role { 39 | USER 40 | ADMIN 41 | DEV 42 | } 43 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [ 5 | 2, 6 | 'always', 7 | [ 8 | 'feat', // New feature 9 | 'fix', // Bug fix 10 | 'docs', // Documentation 11 | 'style', // Formatting, missing semi colons, etc; no code change 12 | 'refactor', // Refactoring production code 13 | 'test', // Adding tests, refactoring tests; no production code change 14 | 'chore', // Updating build tasks, package manager configs, etc; no production code change 15 | 'perf', // A code change that improves performance 16 | 'revert', // Reverts a previous commit 17 | 'ci', // Changes to our CI configuration files and scripts 18 | ], 19 | ], 20 | 'scope-enum': [ 21 | 2, 22 | 'always', 23 | [ 24 | 'api', 25 | 'frontend', 26 | 'backend', 27 | 'docs', 28 | 'tests', 29 | 'config', 30 | 'chore', 31 | 'build', 32 | 'deps', 33 | ], 34 | ], 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /src/config/env.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import Joi from 'joi'; 3 | import dotenv from 'dotenv'; 4 | 5 | export default function loadConfig(): void { 6 | const envPath = path.join(__dirname, '..', '..', '.env'); 7 | 8 | const result = dotenv.config({ path: envPath }); 9 | 10 | if (result.error) { 11 | throw new Error( 12 | `Failed to load .env file from path ${envPath}: ${result.error.message}`, 13 | ); 14 | } 15 | 16 | const schema = Joi.object({ 17 | NODE_ENV: Joi.string() 18 | .valid('development', 'testing', 'production') 19 | .required(), 20 | LOG_LEVEL: Joi.string() 21 | .valid('debug', 'info', 'warn', 'error', 'fatal') 22 | .required(), 23 | API_HOST: Joi.string().required(), 24 | API_PORT: Joi.string().required(), 25 | DATABASE_URL: Joi.string().required(), 26 | APP_JWT_SECRET: Joi.string().required(), 27 | }).unknown(true); 28 | 29 | const { error } = schema.validate(process.env, { abortEarly: false }); 30 | 31 | if (error) { 32 | throw new Error(`Config validation error: ${error.message}`); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Harsh Makwana 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-Typescript 2 | Typescript based rest-API boilerplate with prisma and fastify framework. 3 | 4 | ## How to use 5 | 6 | ### 1. Clone this repo & install dependencies 7 | 8 | Install Node dependencies: 9 | 10 | `npm install` 11 | 12 | ### 2. Set up the database 13 | 14 | This uses [Postgres database](https://www.postgresql.org/). 15 | 16 | To set up your database, run: 17 | 18 | ```sh 19 | npm run migrate 20 | ``` 21 | 22 | for production 23 | 24 | ```sh 25 | npm run migrate:prod 26 | ``` 27 | 28 | ### 3. Generate Prisma Client (type-safe database client) 29 | 30 | Run the following command to generate [Prisma Client](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/generating-prisma-client): 31 | 32 | ```sh 33 | npm run db:gen 34 | ``` 35 | 36 | ### 4. Start the Fastify server 37 | 38 | Launch your Fastify server with this command: 39 | 40 | ```sh 41 | npm run dev 42 | ``` 43 | 44 | ## For Build Generation 45 | 46 | Build server with command: 47 | 48 | ```sh 49 | npm run build 50 | ``` 51 | 52 | ## Prisma documentation 53 | - Check out the [Prisma docs](https://www.prisma.io/docs/) 54 | - Check out the [Fastify docs](https://www.fastify.io/docs/latest/) 55 | -------------------------------------------------------------------------------- /src/helpers/errors.helper.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply } from 'fastify'; 2 | 3 | export class AppError extends Error { 4 | statusCode: number; 5 | 6 | constructor(message: string, statusCode: number) { 7 | super(message); 8 | this.statusCode = statusCode; 9 | Object.setPrototypeOf(this, AppError.prototype); 10 | } 11 | } 12 | 13 | export const ERRORS = { 14 | invalidToken: new AppError('Token is invalid.', 401), 15 | userExists: new AppError('User already exists', 409), 16 | userNotExists: new AppError('User not exists', 404), 17 | userCredError: new AppError('Invalid credential', 401), 18 | tokenError: new AppError('Invalid Token', 401), 19 | invalidRequest: new AppError('Invalid Token', 400), 20 | internalServerError: new AppError('Internal Server Error', 500), 21 | unauthorizedAccess: new AppError('Unauthorized access', 401), 22 | }; 23 | 24 | export function handleServerError(reply: FastifyReply, error: any) { 25 | if (error instanceof AppError) { 26 | return reply.status(error.statusCode).send({ message: error.message }); 27 | } 28 | 29 | return reply 30 | .status(ERRORS.internalServerError.statusCode) 31 | .send(ERRORS.internalServerError.message); 32 | } 33 | -------------------------------------------------------------------------------- /src/routes/user.router.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify'; 2 | import * as controllers from '../controllers'; 3 | import { utils } from '../utils'; 4 | import { loginSchema, signupSchema } from '../schemas/User'; 5 | 6 | async function userRouter(fastify: FastifyInstance) { 7 | fastify.post( 8 | '/login', 9 | { 10 | schema: { 11 | body: { 12 | type: 'object', 13 | required: ['email', 'password'], 14 | properties: { 15 | email: { type: 'string', format: 'email' }, 16 | password: { type: 'string', minLength: 8 }, 17 | }, 18 | }, 19 | }, 20 | config: { 21 | description: 'User login endpoint', 22 | }, 23 | preValidation: utils.preValidation(loginSchema), 24 | }, 25 | controllers.login, 26 | ); 27 | 28 | fastify.post( 29 | '/signup', 30 | { 31 | schema: { 32 | body: { 33 | type: 'object', 34 | required: ['email', 'password'], 35 | properties: { 36 | email: { type: 'string', format: 'email' }, 37 | password: { type: 'string', minLength: 8 }, 38 | firstName: { type: 'string' }, 39 | lastName: { type: 'string' }, 40 | }, 41 | }, 42 | }, 43 | config: { 44 | description: 'User signup endpoint', 45 | }, 46 | preValidation: utils.preValidation(signupSchema), 47 | }, 48 | controllers.signUp, 49 | ); 50 | } 51 | 52 | export default userRouter; 53 | -------------------------------------------------------------------------------- /src/helpers/auth.helper.ts: -------------------------------------------------------------------------------- 1 | import { utils } from '../utils'; 2 | import { FastifyRequest, FastifyReply } from 'fastify'; 3 | import { prisma } from '../utils'; 4 | import { ERRORS } from './errors.helper'; 5 | 6 | export const checkValidRequest = ( 7 | request: FastifyRequest, 8 | reply: FastifyReply, 9 | ) => { 10 | const token = utils.getTokenFromHeader(request.headers.authorization); 11 | if (!token) { 12 | return reply 13 | .code(ERRORS.unauthorizedAccess.statusCode) 14 | .send(ERRORS.unauthorizedAccess.message); 15 | } 16 | 17 | const decoded = utils.verifyToken(token); 18 | if (!decoded) { 19 | return reply 20 | .code(ERRORS.unauthorizedAccess.statusCode) 21 | .send(ERRORS.unauthorizedAccess.message); 22 | } 23 | }; 24 | 25 | export const checkValidUser = async ( 26 | request: FastifyRequest, 27 | reply: FastifyReply, 28 | ) => { 29 | const token = utils.getTokenFromHeader(request.headers.authorization); 30 | if (!token) { 31 | return reply 32 | .code(ERRORS.unauthorizedAccess.statusCode) 33 | .send(ERRORS.unauthorizedAccess.message); 34 | } 35 | 36 | const decoded = utils.verifyToken(token); 37 | if (!decoded || !decoded.id) { 38 | return reply 39 | .code(ERRORS.unauthorizedAccess.statusCode) 40 | .send(ERRORS.unauthorizedAccess.message); 41 | } 42 | 43 | try { 44 | const userData = await prisma.user.findUnique({ 45 | where: { id: decoded.id }, 46 | }); 47 | if (!userData) { 48 | return reply 49 | .code(ERRORS.unauthorizedAccess.statusCode) 50 | .send(ERRORS.unauthorizedAccess.message); 51 | } 52 | 53 | request['authUser'] = userData; 54 | } catch (e) { 55 | return reply 56 | .code(ERRORS.unauthorizedAccess.statusCode) 57 | .send(ERRORS.unauthorizedAccess.message); 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /prisma/migrations/20240714121700_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `createdAt` on the `Post` table. All the data in the column will be lost. 5 | - You are about to drop the column `updatedAt` on the `Post` table. All the data in the column will be lost. 6 | - You are about to drop the column `userId` on the `Post` table. All the data in the column will be lost. 7 | - You are about to drop the column `viewCount` on the `Post` table. All the data in the column will be lost. 8 | - You are about to drop the `User` table. If the table is not empty, all the data it contains will be lost. 9 | - Added the required column `author_id` to the `Post` table without a default value. This is not possible if the table is not empty. 10 | - Added the required column `updated_at` to the `Post` table without a default value. This is not possible if the table is not empty. 11 | - Added the required column `view_count` to the `Post` table without a default value. This is not possible if the table is not empty. 12 | 13 | */ 14 | -- CreateEnum 15 | CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN', 'DEV'); 16 | 17 | -- DropForeignKey 18 | ALTER TABLE "Post" DROP CONSTRAINT "Post_userId_fkey"; 19 | 20 | -- AlterTable 21 | ALTER TABLE "Post" DROP COLUMN "createdAt", 22 | DROP COLUMN "updatedAt", 23 | DROP COLUMN "userId", 24 | DROP COLUMN "viewCount", 25 | ADD COLUMN "author_id" INTEGER NOT NULL, 26 | ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 27 | ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL, 28 | ADD COLUMN "view_count" INTEGER NOT NULL; 29 | 30 | -- DropTable 31 | DROP TABLE "User"; 32 | 33 | -- CreateTable 34 | CREATE TABLE "users" ( 35 | "id" SERIAL NOT NULL, 36 | "email" TEXT NOT NULL, 37 | "first_name" TEXT, 38 | "last_name" TEXT, 39 | "password" TEXT NOT NULL, 40 | "profile" TEXT, 41 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 42 | "updated_at" TIMESTAMP(3) NOT NULL, 43 | 44 | CONSTRAINT "users_pkey" PRIMARY KEY ("id") 45 | ); 46 | 47 | -- CreateIndex 48 | CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); 49 | 50 | -- AddForeignKey 51 | ALTER TABLE "Post" ADD CONSTRAINT "Post_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 52 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcryptjs'; 2 | import { PrismaClient } from '@prisma/client'; 3 | import * as JWT from 'jsonwebtoken'; 4 | import Joi from 'joi'; 5 | import { FastifyReply, FastifyRequest } from 'fastify'; 6 | 7 | export const prisma = new PrismaClient(); 8 | 9 | export const utils = { 10 | isJSON: (data: string) => { 11 | try { 12 | JSON.parse(data); 13 | } catch (e) { 14 | return false; 15 | } 16 | return true; 17 | }, 18 | 19 | getTime: (): number => { 20 | return new Date().getTime(); 21 | }, 22 | 23 | genSalt: (saltRounds: number, value: string): Promise => { 24 | return new Promise((resolve, reject) => { 25 | bcrypt.genSalt(saltRounds, (err, salt) => { 26 | if (err) return reject(err); 27 | bcrypt.hash(value, salt, (err, hash) => { 28 | if (err) return reject(err); 29 | resolve(hash); 30 | }); 31 | }); 32 | }); 33 | }, 34 | 35 | compareHash: (hash: string, value: string): Promise => { 36 | return new Promise((resolve, reject) => { 37 | bcrypt.compare(value, hash, (err, result) => { 38 | if (err) return reject(err); 39 | resolve(result); 40 | }); 41 | }); 42 | }, 43 | 44 | healthCheck: async (): Promise => { 45 | try { 46 | await prisma.$queryRaw`SELECT 1`; 47 | } catch (e) { 48 | throw new Error(`Health check failed: ${e.message}`); 49 | } 50 | }, 51 | 52 | getTokenFromHeader: ( 53 | authorizationHeader: string | undefined, 54 | ): string | null => { 55 | if (!authorizationHeader) return null; 56 | const token = authorizationHeader.replace('Bearer ', ''); 57 | return token || null; 58 | }, 59 | 60 | verifyToken: (token: string): any => { 61 | try { 62 | return JWT.verify(token, process.env.APP_JWT_SECRET as string); 63 | } catch (err) { 64 | return null; 65 | } 66 | }, 67 | 68 | validateSchema: (schema: Joi.ObjectSchema) => { 69 | return (data: any) => { 70 | const { error } = schema.validate(data); 71 | if (error) { 72 | throw new Error(error.details[0].message); 73 | } 74 | }; 75 | }, 76 | 77 | preValidation: (schema: Joi.ObjectSchema) => { 78 | return ( 79 | request: FastifyRequest, 80 | reply: FastifyReply, 81 | done: (err?: Error) => void, 82 | ) => { 83 | const { error } = schema.validate(request.body); 84 | if (error) { 85 | return done(error); 86 | } 87 | done(); 88 | }; 89 | }, 90 | }; 91 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import fastify from 'fastify'; 2 | import pino from 'pino'; 3 | import userRouter from './routes/user.router'; 4 | import postRouter from './routes/post.router'; 5 | import loadConfig from './config/env.config'; 6 | import { utils } from './utils'; 7 | import formbody from '@fastify/formbody'; 8 | import cors from '@fastify/cors'; 9 | import helmet from '@fastify/helmet'; 10 | 11 | loadConfig(); 12 | 13 | const port = Number(process.env.API_PORT) || 5001; 14 | const host = String(process.env.API_HOST); 15 | 16 | const startServer = async () => { 17 | const server = fastify({ 18 | logger: pino({ level: process.env.LOG_LEVEL }), 19 | }); 20 | 21 | // Register middlewares 22 | server.register(formbody); 23 | server.register(cors); 24 | server.register(helmet); 25 | 26 | // Register routes 27 | server.register(userRouter, { prefix: '/api/user' }); 28 | server.register(postRouter, { prefix: '/api/post' }); 29 | 30 | // Set error handler 31 | server.setErrorHandler((error, _request, reply) => { 32 | server.log.error(error); 33 | reply.status(500).send({ error: 'Something went wrong' }); 34 | }); 35 | 36 | // Health check route 37 | server.get('/health', async (_request, reply) => { 38 | try { 39 | await utils.healthCheck(); 40 | reply.status(200).send({ 41 | message: 'Health check endpoint success.', 42 | }); 43 | } catch (e) { 44 | reply.status(500).send({ 45 | message: 'Health check endpoint failed.', 46 | }); 47 | } 48 | }); 49 | 50 | // Root route 51 | server.get('/', (request, reply) => { 52 | reply.status(200).send({ message: 'Hello from fastify boilerplate!' }); 53 | }); 54 | 55 | // Graceful shutdown 56 | const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']; 57 | signals.forEach((signal) => { 58 | process.on(signal, async () => { 59 | try { 60 | await server.close(); 61 | server.log.error(`Closed application on ${signal}`); 62 | process.exit(0); 63 | } catch (err) { 64 | server.log.error(`Error closing application on ${signal}`, err); 65 | process.exit(1); 66 | } 67 | }); 68 | }); 69 | 70 | // Start server 71 | try { 72 | await server.listen({ 73 | port, 74 | host, 75 | }); 76 | } catch (err) { 77 | server.log.error(err); 78 | process.exit(1); 79 | } 80 | }; 81 | 82 | // Handle unhandled rejections 83 | process.on('unhandledRejection', (err) => { 84 | console.error('Unhandled Rejection:', err); 85 | process.exit(1); 86 | }); 87 | 88 | startServer(); 89 | -------------------------------------------------------------------------------- /src/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify'; 2 | import { prisma } from '../utils'; 3 | import { ERRORS, handleServerError } from '../helpers/errors.helper'; 4 | import * as JWT from 'jsonwebtoken'; 5 | import { utils } from '../utils'; 6 | import { STANDARD } from '../constants/request'; 7 | import { IUserLoginDto, IUserSignupDto } from '../schemas/User'; 8 | 9 | export const login = async ( 10 | request: FastifyRequest<{ 11 | Body: IUserLoginDto; 12 | }>, 13 | reply: FastifyReply, 14 | ) => { 15 | try { 16 | const { email, password } = request.body; 17 | const user = await prisma.user.findUnique({ where: { email } }); 18 | if (!user) { 19 | return reply 20 | .code(ERRORS.userNotExists.statusCode) 21 | .send(ERRORS.userNotExists.message); 22 | } 23 | 24 | const checkPass = await utils.compareHash(password, user.password); 25 | if (!checkPass) { 26 | return reply 27 | .code(ERRORS.userCredError.statusCode) 28 | .send(ERRORS.userCredError.message); 29 | } 30 | 31 | const token = JWT.sign( 32 | { 33 | id: user.id, 34 | email: user.email, 35 | }, 36 | process.env.APP_JWT_SECRET as string, 37 | ); 38 | 39 | return reply.code(STANDARD.OK.statusCode).send({ 40 | token, 41 | user, 42 | }); 43 | } catch (err) { 44 | return handleServerError(reply, err); 45 | } 46 | }; 47 | 48 | export const signUp = async ( 49 | request: FastifyRequest<{ 50 | Body: IUserSignupDto; 51 | }>, 52 | reply: FastifyReply, 53 | ) => { 54 | try { 55 | const { email, password, firstName, lastName } = request.body; 56 | const user = await prisma.user.findUnique({ where: { email } }); 57 | if (user) { 58 | return reply.code(ERRORS.userExists.statusCode).send(ERRORS.userExists); 59 | } 60 | 61 | const hashPass = await utils.genSalt(10, password); 62 | const createUser = await prisma.user.create({ 63 | data: { 64 | email, 65 | first_name: firstName.trim(), 66 | last_name: lastName.trim(), 67 | password: String(hashPass), 68 | }, 69 | }); 70 | 71 | const token = JWT.sign( 72 | { 73 | id: createUser.id, 74 | email: createUser.email, 75 | }, 76 | process.env.APP_JWT_SECRET as string, 77 | ); 78 | 79 | delete createUser.password; 80 | 81 | return reply.code(STANDARD.OK.statusCode).send({ 82 | token, 83 | user: createUser, 84 | }); 85 | } catch (err) { 86 | return handleServerError(reply, err); 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-typescript", 3 | "version": "1.0.0", 4 | "author": "hmake98", 5 | "license": "MIT", 6 | "description": "Fastify typescript starter boilerplate", 7 | "main": "main.js", 8 | "scripts": { 9 | "build": "tsc -p tsconfig.json", 10 | "start": "node dist/main.js", 11 | "dev": "ts-node-dev ./src/main.ts | pino-pretty --colorize", 12 | "postinstall": "prisma generate", 13 | "update:packages": "npx npm-check-updates -u", 14 | "db:gen": "prisma generate", 15 | "db:studio": "prisma studio", 16 | "migrate": "prisma migrate dev", 17 | "migrate:prod": "prisma migrate deploy", 18 | "lint": "eslint '{src,test}/**/*.ts'", 19 | "lint:fix": "eslint --fix '{src,test}/**/*.ts'", 20 | "format": "prettier --write 'src/**/*.ts'", 21 | "test": "jest --config test/jest.json --runInBand --passWithNoTests --forceExit" 22 | }, 23 | "dependencies": { 24 | "@fastify/cors": "^9.0.1", 25 | "@fastify/formbody": "^7.4.0", 26 | "@fastify/helmet": "^11.1.1", 27 | "@prisma/client": "^5.16.2", 28 | "bcryptjs": "^2.4.3", 29 | "dotenv": "^16.4.5", 30 | "dotenv-cli": "^7.4.2", 31 | "fastify": "^4.28.1", 32 | "joi": "^17.13.3", 33 | "jsonwebtoken": "^9.0.2", 34 | "pino": "^9.2.0" 35 | }, 36 | "devDependencies": { 37 | "@types/bcryptjs": "~2.4.6", 38 | "@types/eslint": "8.56.10", 39 | "@types/eslint-config-prettier": "~6.11.3", 40 | "@types/eslint-plugin-prettier": "3.1.3", 41 | "@types/http-errors": "2.0.4", 42 | "@types/jest": "29.5.12", 43 | "@types/jsonwebtoken": "9.0.6", 44 | "@types/lint-staged": "~13.3.0", 45 | "@types/node": "20.14.10", 46 | "@types/nodemon": "1.19.6", 47 | "@types/pino": "7.0.4", 48 | "@types/prettier": "2.7.3", 49 | "@types/tap": "~15.0.11", 50 | "@typescript-eslint/eslint-plugin": "7.16.0", 51 | "@typescript-eslint/parser": "7.16.0", 52 | "eslint": "^8.7.0", 53 | "eslint-config-airbnb": "19.0.4", 54 | "eslint-config-prettier": "9.1.0", 55 | "eslint-plugin-import": "^2.29.1", 56 | "eslint-plugin-jest": "^28.6.0", 57 | "eslint-plugin-prettier": "5.1.3", 58 | "husky": "9.0.11", 59 | "jest": "29.7.0", 60 | "lint-staged": "15.2.7", 61 | "nodemon": "3.1.4", 62 | "nyc": "17.0.0", 63 | "pino-pretty": "11.2.1", 64 | "prettier": "3.3.3", 65 | "prisma": "^5.16.2", 66 | "stop-only": "^3.3.2", 67 | "tap": "21.0.0", 68 | "ts-node": "10.9.2", 69 | "ts-node-dev": "^2.0.0", 70 | "typescript": "5.5.3" 71 | }, 72 | "lint-staged": { 73 | "{src,test}/**/*.{ts,js,json}": [ 74 | "prettier --write '{src,test}/**/*.ts'", 75 | "eslint --fix", 76 | "stop-only --file" 77 | ] 78 | } 79 | } 80 | --------------------------------------------------------------------------------