├── .dockerignore ├── .editorconfig ├── .env.ci ├── .env.example ├── .env.test ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .tool-versions ├── .vscode └── settings.json ├── Dockerfile ├── Dockerfile.migrations ├── LICENSE.md ├── README.md ├── buildspec.yml ├── commitlint.config.ts ├── docker-compose.test.yml ├── docker-compose.yml ├── jest.config.ts ├── package.d.ts ├── package.json ├── src ├── app.ts ├── index.ts ├── infrastructure │ ├── builders │ │ └── user.builder.ts │ ├── database │ │ ├── migrations │ │ │ ├── 20221113153438_ │ │ │ │ └── migration.sql │ │ │ └── migration_lock.toml │ │ ├── prisma.client.ts │ │ └── schema.prisma │ ├── dotenv.load.ts │ ├── dotenv.ts │ ├── error │ │ ├── error.hook.ts │ │ └── error.schema.ts │ ├── handler.ts │ ├── logger │ │ └── logger.opts.ts │ ├── messages.ts │ ├── response-validation.opts.ts │ ├── result.service.ts │ ├── swagger │ │ └── swagger.opts.ts │ └── test.setup.ts ├── modules │ ├── base │ │ └── base.types.ts │ └── user │ │ ├── user.opts.ts │ │ ├── user.repository.ts │ │ ├── user.routes.test.ts │ │ ├── user.routes.ts │ │ ├── user.service.ts │ │ └── user.types.ts ├── routes.ts └── server.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | .env* 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 -------------------------------------------------------------------------------- /.env.ci: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | DATABASE_URL=mysql://root:pass1234@database:3306/node_template_test 3 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | DATABASE_URL=mysql://:@:/ 3 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | DATABASE_URL=mysql://root:pass1234@localhost:3306/node_template_test 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | es2022: true, 5 | jest: true, 6 | }, 7 | extends: [ 8 | 'airbnb-base' 9 | ], 10 | parser: '@typescript-eslint/parser', 11 | parserOptions: { 12 | ecmaVersion: 'latest', 13 | sourceType: 'module', 14 | }, 15 | plugins: [ 16 | '@typescript-eslint' 17 | ], 18 | rules: { 19 | indent: ['error', 2, { MemberExpression: 1 }], 20 | 'linebreak-style': ['error', 'unix'], 21 | quotes: ['error', 'single'], 22 | semi: ['error', 'never'], 23 | '@typescript-eslint/no-var-requires': 'off', 24 | 'comma-dangle': ['error', { 25 | arrays: 'never', 26 | objects: 'always-multiline', 27 | imports: 'never', 28 | exports: 'never', 29 | functions: 'never', 30 | }], 31 | 'eol-last': ['error', 'always'], 32 | 'max-len': [2, { code: 140 }], 33 | 'no-shadow': ['error'], 34 | 'import/no-unresolved': 'off', 35 | 'global-require': 'off', 36 | 'import/extensions': 'off', 37 | 'consistent-return': 'off', 38 | 'import/no-extraneous-dependencies': 'off', 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | - develop 11 | pull_request: 12 | branches: 13 | - main 14 | - develop 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Get yarn cache directory path 21 | id: yarn-cache-dir-path 22 | run: echo "::set-output name=dir::$(yarn cache dir)" 23 | 24 | - uses: actions/cache@v2 25 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 26 | with: 27 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 28 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-yarn- 31 | 32 | - uses: actions/checkout@v2 33 | - name: Running tests 34 | run: docker-compose -f docker-compose.test.yml --env-file=.env.ci run --rm test 35 | 36 | - name: Coveralls GitHub Action 37 | uses: coverallsapp/github-action@1.1.3 38 | with: 39 | github-token: ${{ secrets.GITHUB_TOKEN }} 40 | git-branch: "main" 41 | 42 | 43 | -------------------------------------------------------------------------------- /.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.* -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- eslint src --ext .ts 5 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 16.15.0 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": true 5 | }, 6 | "[prisma]": { 7 | "editor.defaultFormatter": "Prisma.prisma" 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/node:16-alpine AS builder 2 | WORKDIR /app 3 | COPY . . 4 | RUN yarn 5 | RUN yarn build 6 | 7 | FROM public.ecr.aws/docker/library/node:16-alpine 8 | WORKDIR /app 9 | COPY package.json yarn.lock ./ 10 | 11 | RUN yarn install --production=true 12 | COPY --from=builder /app/dist ./dist 13 | 14 | ENV NODE_ENV=production 15 | ENV PORT=3000 16 | EXPOSE 3000 17 | 18 | CMD ["yarn", "server"] 19 | -------------------------------------------------------------------------------- /Dockerfile.migrations: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/node:16-alpine 2 | WORKDIR /app 3 | 4 | RUN yarn init -y 5 | # TODO: trocar para prisma 6 | RUN yarn add knex 7 | RUN yarn add -D typescript @types/node 8 | 9 | COPY ./knexfile.ts . 10 | COPY ./migrations ./ 11 | 12 | CMD ["yarn", "knex", "migrate:latest"] 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Rodrigo Oliveira 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 | # Template de Node com Fastify 2 | 3 | ![Node.js CI](https://github.com/rodrigocode4/node-fastify-template/actions/workflows/ci.yml/badge.svg) 4 | [![Coverage Status](https://coveralls.io/repos/github/rodrigocode4/node-fastify-template/badge.svg?branch=main)](https://coveralls.io/github/rodrigocode4/node-fastify-template?branch=main) 5 | [![Licença](https://img.shields.io/badge/license-MIT-green/)](./LICENSE.md) 6 | ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?logo=typescript&logoColor=white) 7 | ![NodeJS](https://img.shields.io/badge/node.js-6DA55F?logo=node.js&logoColor=white) 8 | ![Yarn](https://img.shields.io/badge/yarn-%232C8EBB.svg?logo=yarn&logoColor=white) 9 | ![Jest](https://img.shields.io/badge/-jest-%23C21325?logo=jest&logoColor=white) 10 | ![ESLint](https://img.shields.io/badge/ESLint-4B3263?logo=eslint&logoColor=white) 11 | ![MySQL](https://img.shields.io/badge/mysql-%2300f.svg?logo=mysql&logoColor=white) 12 | ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?logo=docker&logoColor=white) 13 | ![AWS](https://img.shields.io/badge/AWS-%23FF9900.svg?logo=amazon-aws&logoColor=white) 14 | 15 | > Este projeto tem como proposta, ser um template "completo" para iniciar projetos para produção, com: Swagger, Banco de Dados, Lint, ORM entre outras coisas já configuradas para você apenas colocar a mão nas regras de negócio definidas nas suas tasks. 16 | 17 | 18 | ## 💻 Pré-requisitos 19 | 20 | Antes de começar, verifique se você atendeu aos seguintes requisitos: 21 | * Node.js >= 16.15.0 22 | * Yarn >= 1.22.18 23 | * Docker >= 20.10.16 24 | * Docker Compose >= 2.7.0 25 | 26 | 27 | ## ☕ Configurando variáveis de ambiente 28 | Crie a variável de ambiente `.env`, usando o modelo `.env.example`, com o seguinte comando (se vc user unix) no terminal: 29 | 30 | ``` 31 | cat .env.example >> .env 32 | ``` 33 | 34 | ## 🐳 Subindo banco de dados com docker compose 35 | Para criar as tabelas no banco de dados, execute no terminal: 36 | ``` 37 | docker compose up -d 38 | ``` 39 | 40 | ## 🚀 Instalando as depedências 41 | Para instalar os pacotes de depedências, execute no terminal: 42 | ``` 43 | yarn install 44 | ``` 45 | 46 | ## 🎲 Migrations de banco de dados 47 | Para criar as tabelas no banco de dados, execute no terminal: 48 | ``` 49 | yarn migrate:run 50 | ``` 51 | 52 | Para deletar as tabelas no banco de dados, execute no terminal: 53 | ``` 54 | yarn migrate:reset 55 | ``` 56 | 57 | ## 🏗 Iniciando o projeto para dev 58 | ``` 59 | yarn start 60 | ``` 61 | 62 | ## 🃏 Rodando testes do projeto 63 | ``` 64 | yarn test 65 | ``` 66 | 67 | ## 📫 Contribuindo o projeto 68 | Para contribuir com o projeto, siga estas etapas: 69 | 70 | 1. Faça o fork deste repositório. 71 | 2. Crie um branch a partir da `develop`: `git checkout -b `. 72 | 3. Faça suas alterações e confirme-as usando [conventional commits](https://www.conventionalcommits.org/pt-br/v1.0.0/) : `git commit -m feat: ''` 73 | 4. Envie para o branch original: `git push origin / ` 74 | 5. Crie o pull request. 75 | 76 | Como alternativa, consulte a documentação do GitHub em [como criar uma solicitação pull](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request). 77 | 78 | ## 😄 Toda contriuição é bem-vinda 79 | 80 | ## 📝 Licença 81 | 82 | Esse projeto está sob licença [MIT](LICENSE.md). 83 | 84 | [⬆ Voltar ao topo](#)
85 | -------------------------------------------------------------------------------- /buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | commands: 6 | - echo "[+] Logging in to Amazon ECR..." 7 | - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com 8 | - export IMAGE_TAG=$IMAGE_REPO_NAME:latest 9 | - export IMAGE_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_TAG 10 | - curl -L --no-progress-meter --output /usr/local/bin/docker-compose https://github.com/docker/compose/releases/download/v2.5.0/docker-compose-linux-x86_64 11 | - sudo chmod +x /usr/local/bin/docker-compose 12 | pre_build: 13 | commands: 14 | - echo "[+] Running tests..." 15 | - docker-compose -f docker-compose.test.yml --env-file=.env.test run --rm test 16 | build: 17 | commands: 18 | - echo "[+] Build started on `date`" 19 | - echo "[+] Building the Docker image..." 20 | - docker build -t $IMAGE_TAG . 21 | - docker tag $IMAGE_TAG $IMAGE_URI 22 | post_build: 23 | commands: 24 | - echo "[+] Build completed on `date`" 25 | - echo "[+] Pushing the Docker image..." 26 | - docker push $IMAGE_URI 27 | - printf '[{"name":"node-fastify-template-container","imageUri":"%s"}]' $IMAGE_URI > imagedefinitions.json 28 | 29 | artifacts: 30 | files: imagedefinitions.json 31 | -------------------------------------------------------------------------------- /commitlint.config.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from '@commitlint/types' 2 | 3 | export default { 4 | extends: ['@commitlint/config-conventional'], 5 | } as UserConfig 6 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | database: 5 | image: public.ecr.aws/docker/library/mysql:latest 6 | platform: linux/x86_64 7 | environment: 8 | MYSQL_ROOT_PASSWORD: "pass1234" 9 | MYSQL_PASSWORD: "pass1234" 10 | 11 | test: 12 | image: node:16-bullseye-slim 13 | working_dir: /app 14 | volumes: 15 | - ${PWD}:/app 16 | environment: 17 | - database=database 18 | command: sh -c "yarn install && yarn prisma generate && yarn test:ci" 19 | depends_on: 20 | - database 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | db: 5 | container_name: node_template 6 | image: public.ecr.aws/docker/library/mysql:latest 7 | platform: linux/x86_64 8 | restart: always 9 | ports: 10 | - "3306:3306" 11 | environment: 12 | MYSQL_ROOT_PASSWORD: "pass1234" 13 | MYSQL_PASSWORD: "pass1234" 14 | MYSQL_DATABASE: "node_template" 15 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | clearMocks: true, 3 | collectCoverage: true, 4 | collectCoverageFrom: ['/src/**/*.ts', '!/src/**/*types.ts'], 5 | coverageDirectory: 'coverage', 6 | coveragePathIgnorePatterns: [ 7 | '/src/infrastructure/*', 8 | '/src/modules/base/*', 9 | '/src/server.ts', 10 | '/src/index.ts', 11 | '/src/app.ts' 12 | ], 13 | coverageProvider: 'v8', 14 | moduleNameMapper: { '~/(.*)': '/src/$1' }, 15 | roots: ['/src'], 16 | setupFilesAfterEnv: ['/src/infrastructure/test.setup.ts'], 17 | transform: { '.+\\.ts$': '@swc/jest' }, 18 | } 19 | -------------------------------------------------------------------------------- /package.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'knex-stringcase' 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-fastfy-template", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "NODE_ENV=dev ts-node-dev --respawn --transpile-only -r tsconfig-paths/register ./src/index.ts", 8 | "build": "rm -rf ./dist && tsc --project tsconfig.build.json && tscpaths -p tsconfig.build.json -s ./src -o ./dist", 9 | "server": "node ./dist/index.js", 10 | "migrate:run": "prisma migrate dev", 11 | "migrate:reset": "prisma migrate reset", 12 | "test": "NODE_ENV=test dotenv -e .env.test jest --no-chache", 13 | "test:ci": "NODE_ENV=test dotenv -e .env.ci jest --no-chache", 14 | "lint": "eslint src --fix --ext .ts" 15 | }, 16 | "dependencies": { 17 | "@fastify/cors": "^8.2.0", 18 | "@fastify/helmet": "^10.0.2", 19 | "@fastify/response-validation": "^2.1.0", 20 | "@fastify/swagger": "^8.1.0", 21 | "@fastify/swagger-ui": "^1.2.0", 22 | "@prisma/client": "^4.6.1", 23 | "ajv-formats": "2.1.1", 24 | "fastify": "^4.9.2", 25 | "http-status-codes": "^2.2.0", 26 | "pino-pretty": "^9.1.1" 27 | }, 28 | "devDependencies": { 29 | "@commitlint/cli": "^17.0.3", 30 | "@commitlint/config-conventional": "^17.0.3", 31 | "@commitlint/types": "^17.0.0", 32 | "@faker-js/faker": "^7.3.0", 33 | "@swc/core": "^1.3.15", 34 | "@swc/jest": "^0.2.23", 35 | "@types/jest": "^29.2.2", 36 | "@types/node": "^18.0.0", 37 | "@typescript-eslint/eslint-plugin": "^5.30.7", 38 | "@typescript-eslint/parser": "^5.30.7", 39 | "dotenv": "^16.0.1", 40 | "dotenv-cli": "^6.0.0", 41 | "eslint": "^7.32.0 || ^8.2.0", 42 | "eslint-config-airbnb-base": "^15.0.0", 43 | "eslint-plugin-import": "^2.25.2", 44 | "husky": "^8.0.1", 45 | "jest": "^29.3.1", 46 | "prisma": "^4.6.1", 47 | "ts-node-dev": "^2.0.0", 48 | "tsconfig-paths": "^4.0.0", 49 | "tscpaths": "^0.0.9", 50 | "typescript": "^4.7.4" 51 | }, 52 | "prisma": { 53 | "schema": "src/infrastructure/database/schema.prisma" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import Fastify from 'fastify' 2 | import swagger from './infrastructure/swagger/swagger.opts' 3 | import handler from './infrastructure/handler' 4 | import errorHook from './infrastructure/error/error.hook' 5 | import loggerOpts from './infrastructure/logger/logger.opts' 6 | import responseValidation from './infrastructure/response-validation.opts' 7 | 8 | const app = Fastify({ 9 | logger: process.env.NODE_ENV !== 'test' ? loggerOpts : false, 10 | disableRequestLogging: true, 11 | }) 12 | 13 | export default app 14 | .addHook('onResponse', errorHook) 15 | .setErrorHandler(handler.errorHandler) 16 | .setNotFoundHandler(handler.notFoundHandler) 17 | .register(require('@fastify/response-validation'), responseValidation.ajvConfigOpts) 18 | .register(require('@fastify/cors')) 19 | .register(require('@fastify/helmet')) 20 | .register(require('@fastify/swagger'), swagger.swaggerOpts) 21 | .register(require('@fastify/swagger-ui'), swagger.swaggerUiOpts) 22 | .register(require('./infrastructure/dotenv')) 23 | .register(require('./routes')) 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import server from './server' 2 | 3 | server.start() 4 | -------------------------------------------------------------------------------- /src/infrastructure/builders/user.builder.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { User } from '~/modules/user/user.types' 3 | import db from '~/infrastructure/database/prisma.client' 4 | 5 | export default () => ({ 6 | user: { 7 | name: faker.name.firstName(), 8 | age: faker.datatype.number(55), 9 | } as (User & { id?: number }), 10 | 11 | withId(id: number) { 12 | this.user.id = id 13 | return this 14 | }, 15 | 16 | withName(name: string) { 17 | this.user.name = name 18 | return this 19 | }, 20 | 21 | withAge(age: number) { 22 | this.user.age = age 23 | return this 24 | }, 25 | 26 | create() { 27 | return this.user 28 | }, 29 | 30 | async insert() { 31 | return db.user.create({ data: { ...this.user } }) 32 | }, 33 | }) 34 | -------------------------------------------------------------------------------- /src/infrastructure/database/migrations/20221113153438_/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `users` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `name` VARCHAR(191) NOT NULL, 5 | `age` INTEGER NOT NULL, 6 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 7 | `updated_at` DATETIME(3) NOT NULL, 8 | 9 | PRIMARY KEY (`id`) 10 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 11 | -------------------------------------------------------------------------------- /src/infrastructure/database/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 = "mysql" -------------------------------------------------------------------------------- /src/infrastructure/database/prisma.client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | const prisma = new PrismaClient({ 4 | log: [{ emit: 'event', level: 'query' }], 5 | }) 6 | 7 | prisma.$use(async (params, next) => { 8 | const result = await next(params) 9 | 10 | if (result === null) return result 11 | 12 | const notHasBeAction = !['create', 'find', 'update'].find((e) => params.action.includes(e)) 13 | 14 | if (notHasBeAction) return result 15 | 16 | if (Array.isArray(result)) { 17 | return result.map((model) => ({ 18 | ...model, 19 | createdAt: (model.createdAt as Date).toISOString(), 20 | updatedAt: (model.updatedAt as Date).toISOString(), 21 | })) 22 | } 23 | 24 | return { 25 | ...result, 26 | createdAt: (result.createdAt as Date).toISOString(), 27 | updatedAt: (result.updatedAt as Date).toISOString(), 28 | } 29 | }) 30 | 31 | export default prisma 32 | -------------------------------------------------------------------------------- /src/infrastructure/database/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "mysql" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | model User { 11 | id Int @id @default(autoincrement()) 12 | name String 13 | age Int 14 | createdAt DateTime @default(now()) @map("created_at") 15 | updatedAt DateTime @updatedAt @map("updated_at") 16 | 17 | @@map("users") 18 | } 19 | -------------------------------------------------------------------------------- /src/infrastructure/dotenv.load.ts: -------------------------------------------------------------------------------- 1 | import app from '../app' 2 | import dotenv from './dotenv' 3 | 4 | export default () => dotenv(app) 5 | -------------------------------------------------------------------------------- /src/infrastructure/dotenv.ts: -------------------------------------------------------------------------------- 1 | import { App } from '~/modules/base/base.types' 2 | 3 | export default async (app: App) => { 4 | if (process.env.NODE_ENV !== 'production') { 5 | const path = process.env.NODE_ENV === 'test' ? '.env.test' : '.env' 6 | app.log.info(`Loading ${path} file from directory`) 7 | 8 | const dotenv = require('dotenv') 9 | const result = await dotenv.config({ path }) 10 | if (result.error) { 11 | app.log.error(`Error loading ${path} file from directory`, result.error) 12 | throw result.error 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/infrastructure/error/error.hook.ts: -------------------------------------------------------------------------------- 1 | import { Req, Reply } from '~/modules/base/base.types' 2 | 3 | const getLogLevel = (status: number) => { 4 | if (status < 400) return 'info' 5 | if (status < 500) return 'warn' 6 | return 'error' 7 | } 8 | 9 | export default async (request: Req, reply: Reply) => { 10 | const message = `${request.method} ${reply.statusCode} ${request.url} ${reply.getResponseTime().toPrecision(2)}ms` 11 | request.log[getLogLevel(reply.statusCode)](message) 12 | } 13 | -------------------------------------------------------------------------------- /src/infrastructure/error/error.schema.ts: -------------------------------------------------------------------------------- 1 | const schema = { 2 | description: 'Error Handler', 3 | type: 'object', 4 | properties: { 5 | data: { 6 | type: 'null', 7 | default: null, 8 | }, 9 | errors: { 10 | type: 'array', 11 | items: { 12 | type: 'string', 13 | }, 14 | }, 15 | }, 16 | } 17 | 18 | export default (...codes: number[]) => [400, 500].concat(codes).reduce((acc, crr) => ({ ...acc, [crr]: schema }), {}) 19 | -------------------------------------------------------------------------------- /src/infrastructure/handler.ts: -------------------------------------------------------------------------------- 1 | import { Error, Req, Reply } from '~/modules/base/base.types' 2 | 3 | const errorHandler = async (error: Error & { sqlMessage?: string }, request: Req, reply: Reply) => { 4 | if (error.validation) { 5 | return reply.status(400).send({ data: null, errors: [error.message] }) 6 | } 7 | 8 | request.log.error(`An internal server error occured ${error}`) 9 | 10 | return reply.status(500).send({ data: null, errors: [error?.sqlMessage || error?.message || 'Unknown error'] }) 11 | } 12 | 13 | const notFoundHandler = async (req: Req, reply: Reply) => reply.status(404).send({ 14 | data: null, 15 | errors: [ 16 | `Route ${req.method}:${req.url} not found` 17 | ], 18 | }) 19 | 20 | export default { 21 | notFoundHandler, 22 | errorHandler, 23 | } 24 | -------------------------------------------------------------------------------- /src/infrastructure/logger/logger.opts.ts: -------------------------------------------------------------------------------- 1 | import { LoggerOptions } from 'pino' 2 | import { PrettyOptions } from 'pino-pretty' 3 | 4 | const loggerOpts: LoggerOptions = { 5 | transport: { 6 | target: 'pino-pretty', 7 | options: { 8 | translateTime: 'HH:MM:ss Z', 9 | ignore: 'pid,hostname,reqId', 10 | colorize: true, 11 | } as PrettyOptions, 12 | }, 13 | level: process.env.NODE_ENV === 'dev' ? 'debug' : 'info', 14 | } 15 | 16 | export default loggerOpts 17 | -------------------------------------------------------------------------------- /src/infrastructure/messages.ts: -------------------------------------------------------------------------------- 1 | const noDataFound = 'No data found' 2 | 3 | export default { 4 | listHasNoDataFound: (data?: unknown[]) => (data?.length === 0 ? noDataFound : undefined), 5 | successfullyCreated: (entityName: string) => `${entityName} successfully created`, 6 | successfullyDeleted: (entityName: string) => `${entityName} successfully deleted`, 7 | successfullyUpdated: (entityName: string) => `${entityName} successfully updated`, 8 | notFindById: (id: number) => `Entity not found with id ${id}`, 9 | invalidRequest: 'Request data is invalid', 10 | noDataFound, 11 | } 12 | -------------------------------------------------------------------------------- /src/infrastructure/response-validation.opts.ts: -------------------------------------------------------------------------------- 1 | const ajvConfigOpts = { 2 | ajv: { 3 | plugins: [ 4 | [require('ajv-formats')] 5 | ], 6 | }, 7 | } 8 | 9 | export default { 10 | ajvConfigOpts, 11 | } 12 | -------------------------------------------------------------------------------- /src/infrastructure/result.service.ts: -------------------------------------------------------------------------------- 1 | export interface ServiceResult { 2 | errors?: string[], 3 | data?: T, 4 | } 5 | 6 | export const createServiceResult = (errors?: string[], data?: T): ServiceResult => ({ 7 | errors, 8 | data, 9 | }) 10 | 11 | export const createErrorServiceResult = ( 12 | ...message: string[]): ServiceResult => createServiceResult([...message], null!) 13 | 14 | export const createSuccessServiceResult = ( 15 | data: T): ServiceResult => createServiceResult(null!, data) 16 | -------------------------------------------------------------------------------- /src/infrastructure/swagger/swagger.opts.ts: -------------------------------------------------------------------------------- 1 | import { FastifyDynamicSwaggerOptions } from '@fastify/swagger' 2 | import { FastifySwaggerUiOptions } from '@fastify/swagger-ui' 3 | 4 | const swaggerOpts: FastifyDynamicSwaggerOptions = { 5 | mode: 'dynamic', 6 | openapi: { 7 | info: { 8 | title: 'Node Fatify API', 9 | description: 'Documentação da api com Fastify', 10 | version: '0.1.0', 11 | license: { 12 | name: 'MIT', 13 | }, 14 | }, 15 | components: { 16 | securitySchemes: { 17 | bearerAuth: { 18 | type: 'http', 19 | scheme: 'bearer', 20 | }, 21 | }, 22 | }, 23 | 24 | }, 25 | hideUntagged: true, 26 | } 27 | 28 | const swaggerUiOpts: FastifySwaggerUiOptions = { 29 | routePrefix: '/docs', 30 | uiConfig: { 31 | deepLinking: false, 32 | }, 33 | } 34 | 35 | export default { swaggerOpts, swaggerUiOpts } 36 | -------------------------------------------------------------------------------- /src/infrastructure/test.setup.ts: -------------------------------------------------------------------------------- 1 | import util from 'util' 2 | import childProcess from 'child_process' 3 | import app from '../app' 4 | import db from '~/infrastructure/database/prisma.client' 5 | 6 | const exec = util.promisify(childProcess.exec) 7 | 8 | const setup = async () => { 9 | await exec('./node_modules/.bin/dotenv -e .env.test ./node_modules/.bin/prisma migrate dev --force') 10 | } 11 | 12 | const teardown = async () => { 13 | await exec(`echo 'drop database ${process.env.DATABASE_URL?.split('/').at(-1)};'` 14 | + ` | ./node_modules/.bin/prisma db execute --stdin --url=${process.env.DATABASE_URL}`) 15 | } 16 | 17 | const truncateTables = async () => { 18 | await db.$transaction([db.user.deleteMany()]) 19 | } 20 | 21 | beforeEach(async () => { 22 | jest.clearAllMocks() 23 | await truncateTables() 24 | }) 25 | 26 | beforeAll(async () => { 27 | await setup() 28 | }, 30_000) 29 | 30 | afterAll(async () => { 31 | await teardown() 32 | await app.close() 33 | }) 34 | -------------------------------------------------------------------------------- /src/modules/base/base.types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FastifyInstance, RouteShorthandOptions, DoneFuncWithErrOrRes, FastifyError, FastifyRequest, FastifyReply 3 | } from 'fastify' 4 | 5 | export type Opts = RouteShorthandOptions 6 | export type App = FastifyInstance 7 | export type Done = DoneFuncWithErrOrRes 8 | export type Error = FastifyError 9 | export type Req = FastifyRequest 10 | export type Reply = FastifyReply 11 | // eslint-disable-next-line no-unused-vars 12 | export type Handle = (request: Req, reply: Reply) => Promise 13 | -------------------------------------------------------------------------------- /src/modules/user/user.opts.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes' 2 | import errorSchema from '~/infrastructure/error/error.schema' 3 | import messages from '~/infrastructure/messages' 4 | import { Opts } from '../base/base.types' 5 | import userService from './user.service' 6 | 7 | export const userGetOpts: Opts = { 8 | schema: { 9 | tags: ['User'], 10 | summary: 'Get user by name or all users', 11 | security: [{ bearerAuth: [] }], 12 | querystring: { 13 | type: 'object', 14 | properties: { 15 | name: { 16 | type: 'string', 17 | pattern: '^((?!d)[a-zA-Z\\s]+)*$', 18 | }, 19 | }, 20 | }, 21 | response: { 22 | 200: { 23 | description: 'Successful response', 24 | type: 'object', 25 | properties: { 26 | data: { 27 | type: 'object', 28 | properties: { 29 | users: { 30 | type: 'array', 31 | items: { 32 | type: 'object', 33 | properties: { 34 | id: { type: 'integer' }, 35 | name: { type: 'string' }, 36 | age: { type: 'number' }, 37 | createdAt: { type: 'string', format: 'date-time' }, 38 | updatedAt: { type: 'string', format: 'date-time' }, 39 | }, 40 | }, 41 | }, 42 | }, 43 | }, 44 | errors: { 45 | type: 'null', 46 | default: null, 47 | }, 48 | }, 49 | }, 50 | ...errorSchema(404), 51 | }, 52 | }, 53 | } 54 | 55 | export const userGetByIdOpts: Opts = { 56 | schema: { 57 | tags: ['User'], 58 | summary: 'Get user by Id', 59 | security: [{ bearerAuth: [] }], 60 | params: { 61 | type: 'object', 62 | properties: { 63 | id: { 64 | type: 'integer', 65 | }, 66 | }, 67 | }, 68 | response: { 69 | 200: { 70 | description: 'Successful response', 71 | type: 'object', 72 | properties: { 73 | data: { 74 | type: 'object', 75 | properties: { 76 | user: { 77 | type: 'object', 78 | properties: { 79 | id: { type: 'integer' }, 80 | name: { type: 'string' }, 81 | age: { type: 'number' }, 82 | createdAt: { type: 'string', format: 'date-time' }, 83 | updatedAt: { type: 'string', format: 'date-time' }, 84 | }, 85 | }, 86 | }, 87 | }, 88 | errors: { 89 | type: 'null', 90 | default: null, 91 | }, 92 | }, 93 | }, 94 | ...errorSchema(404), 95 | }, 96 | }, 97 | } 98 | 99 | export const userPostOpts: Opts = { 100 | schema: { 101 | tags: ['User'], 102 | summary: 'Create new user', 103 | body: { 104 | type: 'object', 105 | properties: { 106 | name: { 107 | type: 'string', 108 | pattern: '^((?!d)[a-zA-Z\\s]+)*$', 109 | }, 110 | age: { 111 | type: 'integer', 112 | minimum: 0, 113 | }, 114 | }, 115 | required: ['name', 'age'], 116 | }, 117 | response: { 118 | 201: { 119 | description: 'Successful response', 120 | type: 'object', 121 | properties: { 122 | data: { 123 | type: 'object', 124 | properties: { 125 | user: { 126 | type: 'object', 127 | properties: { 128 | id: { type: 'integer' }, 129 | name: { type: 'string' }, 130 | age: { type: 'number' }, 131 | }, 132 | }, 133 | }, 134 | }, 135 | errors: { 136 | type: 'null', 137 | default: null, 138 | }, 139 | }, 140 | }, 141 | ...errorSchema(), 142 | }, 143 | }, 144 | } 145 | 146 | export const userPutOpts: Opts = { 147 | preHandler: async (req, reply, done) => { 148 | const { id } = <{ id: number }>req.query 149 | const hasId = await userService.existById(id) 150 | if (!hasId) { 151 | return reply.status(StatusCodes.BAD_REQUEST).send({ data: null, errors: [messages.notFindById(id)] }) 152 | } 153 | return done() 154 | }, 155 | schema: { 156 | tags: ['User'], 157 | summary: 'Update user', 158 | querystring: { 159 | type: 'object', 160 | properties: { 161 | id: { 162 | type: 'integer', 163 | }, 164 | }, 165 | required: ['id'], 166 | }, 167 | body: { 168 | type: 'object', 169 | properties: { 170 | name: { 171 | type: 'string', 172 | pattern: '^((?!d)[a-zA-Z\\s]+)*$', 173 | }, 174 | age: { 175 | type: 'integer', 176 | minimum: 0, 177 | }, 178 | }, 179 | required: ['name', 'age'], 180 | }, 181 | response: { 182 | 201: { 183 | description: 'Successful response', 184 | type: 'object', 185 | properties: { 186 | data: { 187 | type: 'object', 188 | properties: { 189 | user: { 190 | type: 'object', 191 | properties: { 192 | id: { type: 'integer' }, 193 | name: { type: 'string' }, 194 | age: { type: 'number' }, 195 | }, 196 | }, 197 | }, 198 | }, 199 | errors: { 200 | type: 'null', 201 | default: null, 202 | }, 203 | }, 204 | }, 205 | ...errorSchema(), 206 | }, 207 | }, 208 | } 209 | 210 | export const userDeleteByIdOpts: Opts = { 211 | schema: { 212 | tags: ['User'], 213 | summary: 'Delete user by Id', 214 | security: [{ bearerAuth: [] }], 215 | params: { 216 | type: 'object', 217 | properties: { 218 | id: { 219 | type: 'integer', 220 | }, 221 | }, 222 | }, 223 | response: { 224 | 200: { 225 | description: 'Successful response', 226 | type: 'object', 227 | properties: { 228 | data: { 229 | type: 'object', 230 | properties: { 231 | id: { 232 | type: 'integer', 233 | }, 234 | }, 235 | }, 236 | errors: { 237 | type: 'null', 238 | default: null, 239 | }, 240 | }, 241 | }, 242 | ...errorSchema(404), 243 | }, 244 | }, 245 | } 246 | -------------------------------------------------------------------------------- /src/modules/user/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user.types' 2 | import db from '~/infrastructure/database/prisma.client' 3 | 4 | const existById = async (id: number) => !!(await db.user.count({ where: { id } })) 5 | 6 | const get = async (name?: string) => { 7 | let usersDb = null 8 | 9 | if (!name) { 10 | usersDb = db.user.findMany() 11 | } 12 | 13 | usersDb = db.user.findMany({ 14 | where: { 15 | name: { 16 | contains: name, 17 | }, 18 | }, 19 | }) 20 | 21 | return usersDb 22 | } 23 | 24 | const getById = async (id: number) => db.user.findFirst({ where: { id } }) 25 | 26 | const insert = async (user: User) => db.user.create({ data: { ...user } }) 27 | 28 | const deleteById = async (id: number) => db.user.delete({ where: { id } }) 29 | 30 | const update = async ( 31 | user: User & { id: number } 32 | ) => db.user.update({ 33 | data: { 34 | name: user.name, 35 | age: user.age, 36 | }, 37 | where: { 38 | id: user.id, 39 | }, 40 | }) 41 | 42 | export default { 43 | get, 44 | getById, 45 | insert, 46 | deleteById, 47 | update, 48 | existById, 49 | } 50 | -------------------------------------------------------------------------------- /src/modules/user/user.routes.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { StatusCodes } from 'http-status-codes' 3 | import app from '~/app' 4 | import userBuilder from '~/infrastructure/builders/user.builder' 5 | import { User } from './user.types' 6 | 7 | const BASE_URL = '/api/v1/user/' 8 | 9 | type ExpectType = { 10 | data: { user: User } | 11 | { user: User & { id: number } } | 12 | { users: User[] } | 13 | { id: number } | 14 | null, 15 | errors: string[] | null 16 | } 17 | 18 | describe('User', () => { 19 | describe('GET', () => { 20 | test('Deve retornar status NOT_FOUND e sem nenhum dado', async () => { 21 | const expected: ExpectType = { 22 | data: null, 23 | errors: ['No data found'], 24 | } 25 | 26 | const resp = await app.inject({ 27 | method: 'GET', 28 | url: BASE_URL, 29 | }) 30 | 31 | expect(resp.statusCode).toBe(StatusCodes.NOT_FOUND) 32 | expect(resp.json()).toStrictEqual(expected) 33 | }) 34 | 35 | test('Deve retornar status OK e com lista de usuários', async () => { 36 | const user1 = await userBuilder().insert() 37 | const user2 = await userBuilder().insert() 38 | const expected: ExpectType = { 39 | data: { 40 | users: [user1, user2], 41 | }, 42 | errors: null, 43 | } 44 | 45 | const resp = await app.inject({ 46 | method: 'GET', 47 | url: BASE_URL, 48 | }) 49 | 50 | expect(resp.statusCode).toBe(StatusCodes.OK) 51 | expect(resp.json()).toStrictEqual(expected) 52 | }) 53 | 54 | test('Deve retornar status OK e com lista de apenas o usuário buscado', async () => { 55 | await userBuilder().insert() 56 | const user = await userBuilder().insert() 57 | 58 | const expected: ExpectType = { 59 | data: { 60 | users: [user], 61 | }, 62 | errors: null, 63 | } 64 | 65 | const resp = await app.inject({ 66 | method: 'GET', 67 | url: BASE_URL, 68 | query: { name: user.name }, 69 | }) 70 | 71 | expect(resp.statusCode).toBe(StatusCodes.OK) 72 | expect(resp.json()).toStrictEqual(expected) 73 | }) 74 | 75 | test.each([ 76 | 'asd4', '4asd', 77 | 'as4d', '@asd', 78 | '*', '123', 79 | '(asd)', '[asd]', 80 | 'asrr d2d', '3aasd]' 81 | ])('Deve retornar status BAD_REQUEST ao passar query inválida: %s', async (queryParam) => { 82 | const expected: ExpectType = { 83 | data: null, 84 | errors: ['querystring/name must match pattern "^((?!d)[a-zA-Z\\s]+)*$"'], 85 | } 86 | 87 | const resp = await app.inject({ 88 | method: 'GET', 89 | url: BASE_URL, 90 | query: { name: queryParam }, 91 | }) 92 | 93 | expect(resp.statusCode).toBe(StatusCodes.BAD_REQUEST) 94 | expect(resp.json()).toStrictEqual(expected) 95 | }) 96 | 97 | test('Deve retornar status OK e apenas o usuário buscado por Id', async () => { 98 | await userBuilder().insert() 99 | const user = await userBuilder().insert() 100 | 101 | const expected: ExpectType = { 102 | data: { 103 | user, 104 | }, 105 | errors: null, 106 | } 107 | 108 | const resp = await app.inject({ 109 | method: 'GET', 110 | url: `${BASE_URL}${user.id}`, 111 | }) 112 | 113 | expect(resp.statusCode).toBe(StatusCodes.OK) 114 | expect(resp.json()).toStrictEqual(expected) 115 | }) 116 | 117 | test('Deve retornar status NOT_FOUND e erro de entidade não encontrada pelo Id passado', async () => { 118 | const Id = faker.datatype.number(55) 119 | 120 | const expected: ExpectType = { 121 | data: null, 122 | errors: [`Entity not found with id ${Id}`], 123 | } 124 | 125 | const resp = await app.inject({ 126 | method: 'GET', 127 | url: `${BASE_URL}${Id}`, 128 | }) 129 | 130 | expect(resp.statusCode).toBe(StatusCodes.NOT_FOUND) 131 | expect(resp.json()).toStrictEqual(expected) 132 | }) 133 | }) 134 | 135 | describe('POST', () => { 136 | test('Deve criar um usuário e retornar CREATED', async () => { 137 | const user = userBuilder().create() 138 | 139 | const expected: ExpectType = { 140 | data: { 141 | user, 142 | }, 143 | errors: null, 144 | } 145 | 146 | const resp = await app.inject({ 147 | method: 'POST', 148 | url: `${BASE_URL}new`, 149 | payload: { 150 | ...user, 151 | } as User, 152 | }) 153 | 154 | expect(resp.statusCode).toEqual(StatusCodes.CREATED) 155 | expect(resp.json()).toEqual(expect.objectContaining({ 156 | ...expected, 157 | data: { 158 | user: expect.objectContaining(user), 159 | }, 160 | })) 161 | }) 162 | 163 | test.each` 164 | payload | message 165 | ${{ age: 26 }} | ${'body must have required property \'name\''} 166 | ${{ name: 'Rodrigo' }} | ${'body must have required property \'age\''} 167 | ${{ name: 'Edson', age: -1 }}| ${'body/age must be >= 0'} 168 | `('Não deve criar usuário e retornar BAD_REQUEST dado o payload: $payload', async ({ payload, message }) => { 169 | const expected: ExpectType = { 170 | data: null, 171 | errors: [message], 172 | } 173 | 174 | const resp = await app.inject({ 175 | method: 'POST', 176 | url: `${BASE_URL}new`, 177 | payload, 178 | }) 179 | 180 | expect(resp.json()).toStrictEqual(expected) 181 | }) 182 | }) 183 | 184 | describe('PUT', () => { 185 | test.each` 186 | payload | message 187 | ${{ age: 26 }} | ${'body must have required property \'name\''} 188 | ${{ name: 'Rodrigo' }} | ${'body must have required property \'age\''} 189 | ${{ name: 'Edson', age: -1 }}| ${'body/age must be >= 0'} 190 | `('Não deve atualizar usuário e retornar BAD_REQUEST dado o payload: $payload', async ({ payload, message }) => { 191 | const expected: ExpectType = { 192 | data: null, 193 | errors: [message], 194 | } 195 | 196 | const resp = await app.inject({ 197 | method: 'PUT', 198 | url: `${BASE_URL}update`, 199 | query: { id: '1000' }, 200 | payload, 201 | }) 202 | 203 | expect(resp.json()).toStrictEqual(expected) 204 | }) 205 | 206 | test('Deve atualizar usuário, retornar CREATED e retornar os dados atualizados', async () => { 207 | const user = await userBuilder().insert() 208 | 209 | const expected: ExpectType = { 210 | data: { 211 | user: { 212 | id: user.id, 213 | name: user.name, 214 | age: user.age, 215 | }, 216 | }, 217 | errors: null, 218 | } 219 | 220 | const resp = await app.inject({ 221 | method: 'PUT', 222 | url: `${BASE_URL}update`, 223 | query: { id: user?.id as unknown as string }, 224 | payload: { 225 | name: user.name, 226 | age: user.age, 227 | } as User, 228 | }) 229 | 230 | expect(resp.json()).toStrictEqual(expected) 231 | }) 232 | 233 | test('Não deve atualizar usuário e retornar BAD_REQUEST', async () => { 234 | const user = userBuilder().withId(100).create() 235 | 236 | const expected: ExpectType = { 237 | data: null, 238 | errors: [`Entity not found with id ${user.id}`], 239 | } 240 | 241 | const resp = await app.inject({ 242 | method: 'PUT', 243 | url: `${BASE_URL}update`, 244 | query: { id: `${user?.id}` }, 245 | payload: { 246 | name: user.name, 247 | age: user.age, 248 | } as User, 249 | }) 250 | 251 | expect(resp.json()).toStrictEqual(expected) 252 | }) 253 | }) 254 | 255 | describe('DELETE', () => { 256 | test('Deve retornar status OK e apenas o usuário buscado por Id', async () => { 257 | await userBuilder().insert() 258 | const user = await userBuilder().insert() 259 | 260 | const expected: ExpectType = { 261 | data: { 262 | id: Number(user.id), 263 | }, 264 | errors: null, 265 | } 266 | 267 | const resp = await app.inject({ 268 | method: 'DELETE', 269 | url: `${BASE_URL}delete/${user.id}`, 270 | }) 271 | 272 | expect(resp.statusCode).toBe(StatusCodes.OK) 273 | expect(resp.json()).toStrictEqual(expected) 274 | }) 275 | 276 | test('Deve retornar status NOT_FOUND e erro de entidade não encontrada pelo Id passado', async () => { 277 | const Id = faker.datatype.number(55) 278 | 279 | const expected: ExpectType = { 280 | data: null, 281 | errors: [`Entity not found with id ${Id}`], 282 | } 283 | 284 | const resp = await app.inject({ 285 | method: 'DELETE', 286 | url: `${BASE_URL}delete/${Id}`, 287 | }) 288 | 289 | expect(resp.statusCode).toBe(StatusCodes.NOT_FOUND) 290 | expect(resp.json()).toStrictEqual(expected) 291 | }) 292 | }) 293 | }) 294 | -------------------------------------------------------------------------------- /src/modules/user/user.routes.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes' 2 | import { App } from '../base/base.types' 3 | import { User } from './user.types' 4 | import { 5 | userDeleteByIdOpts, 6 | userGetByIdOpts, userGetOpts, userPostOpts, userPutOpts 7 | } from './user.opts' 8 | import service from './user.service' 9 | 10 | export default async (app: App) => { 11 | app.get('/', userGetOpts, async (req, reply) => { 12 | const { name } = <{ name: string }>req.query 13 | const { data, errors } = await service.get(name) 14 | 15 | let status = StatusCodes.OK 16 | if (errors) { 17 | status = StatusCodes.NOT_FOUND 18 | } 19 | 20 | return reply.status(status).send({ data, errors }) 21 | }) 22 | 23 | app.get('/:id', userGetByIdOpts, async (req, reply) => { 24 | const { id } = <{ id: number }>req.params 25 | const { data, errors } = await service.getById(id) 26 | 27 | let status = StatusCodes.OK 28 | if (errors) { 29 | status = StatusCodes.NOT_FOUND 30 | } 31 | 32 | return reply.status(status).send({ data, errors }) 33 | }) 34 | 35 | app.post('/new', userPostOpts, async (req, reply) => { 36 | const userPayload = req.body 37 | const { data, errors } = await service.insert(userPayload) 38 | 39 | return reply.status(StatusCodes.CREATED).send({ data, errors }) 40 | }) 41 | 42 | app.put('/update', userPutOpts, async (req, reply) => { 43 | const userProps = req.body 44 | const { id } = <{ id: number }>req.query 45 | 46 | const { data, errors } = await service.update({ id, ...userProps }) 47 | return reply.status(StatusCodes.CREATED).send({ data, errors }) 48 | }) 49 | 50 | app.delete('/delete/:id', userDeleteByIdOpts, async (req, reply) => { 51 | const { id } = <{ id: number }>req.params 52 | const { data, errors } = await service.deleteById(id) 53 | 54 | let status = StatusCodes.OK 55 | if (errors) { 56 | status = StatusCodes.NOT_FOUND 57 | } 58 | 59 | return reply.status(status).send({ data, errors }) 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /src/modules/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ServiceResult, 3 | createErrorServiceResult, 4 | createSuccessServiceResult 5 | } from '~/infrastructure/result.service' 6 | import messages from '~/infrastructure/messages' 7 | import repository from './user.repository' 8 | import { User } from './user.types' 9 | 10 | const existById = async (id: number) => repository.existById(id) 11 | 12 | const get = async (name?: string): Promise> => { 13 | const users = await repository.get(name) 14 | if (users.length) return createSuccessServiceResult({ users }) 15 | return createErrorServiceResult(messages.noDataFound) 16 | } 17 | 18 | const getById = async (id: number): Promise> => { 19 | const user = await repository.getById(id) 20 | if (!user) return createErrorServiceResult(messages.notFindById(id)) 21 | return createSuccessServiceResult<{ user: User }>({ user }) 22 | } 23 | 24 | const insert = async (user: User): Promise> => { 25 | const createdUser = await repository.insert(user) 26 | return createSuccessServiceResult({ user: createdUser }) 27 | } 28 | 29 | const deleteById = async (id: number): Promise> => { 30 | const userExists = await existById(id) 31 | if (!userExists) return createErrorServiceResult(messages.notFindById(id)) 32 | await repository.deleteById(id) 33 | return createSuccessServiceResult<{ id: number }>({ id }) 34 | } 35 | 36 | const update = async (user: User & { id: number }): Promise> => { 37 | const userExists = await existById(user.id) 38 | if (!userExists) return createErrorServiceResult(messages.notFindById(user.id)) 39 | const updatedUser = await repository.update(user) 40 | return createSuccessServiceResult({ user: updatedUser }) 41 | } 42 | 43 | export default { 44 | get, 45 | getById, 46 | insert, 47 | deleteById, 48 | update, 49 | existById, 50 | } 51 | -------------------------------------------------------------------------------- /src/modules/user/user.types.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | name: string 3 | age: number 4 | } 5 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | import { App } from './modules/base/base.types' 2 | 3 | export default async (app: App) => { 4 | app.register(require('./modules/user/user.routes'), { prefix: '/api/v1/user' }) 5 | } 6 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import app from './app' 2 | import db from '~/infrastructure/database/prisma.client' 3 | 4 | const PORT = process.env.PORT || 3000 5 | 6 | const start = async () => { 7 | try { 8 | await app.listen({ port: PORT as number, host: '0.0.0.0' }) 9 | 10 | if (process.env.NODE_ENV === 'dev') { 11 | await db.$on('query', ({ query, params, duration }) => { 12 | app.log.debug(`Query: ${query}`) 13 | app.log.debug(`Params: ${params}`) 14 | app.log.debug(`Duration: ${duration}ms`) 15 | }) 16 | } 17 | 18 | app.log.info(`Docs listening at http://localhost:${PORT}/docs`) 19 | app.log.info('Server has started! 🚀') 20 | } catch (err) { 21 | // eslint-disable-next-line no-console 22 | console.log(err) 23 | app.log.error(err) 24 | process.exit(1) 25 | } 26 | } 27 | 28 | export default { start } 29 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "src/**/*.test.ts", 5 | "src/**/test*.ts", 6 | "./jest*.ts", 7 | "src/**/**/builders/*" 8 | ] 9 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "node", 6 | "baseUrl": "src", 7 | "paths": { 8 | "~/*": ["*"] 9 | }, 10 | "outDir": "dist", 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "strict": true, 14 | "skipLibCheck": true 15 | }, 16 | "include": ["src", "package.d.ts"] 17 | } 18 | --------------------------------------------------------------------------------