├── .github
└── workflows
│ └── backend.yml
├── .gitignore
├── README.md
├── api
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── Procfile
├── README.md
├── nest-cli.json
├── package-lock.json
├── package.json
├── src
│ ├── app.module.ts
│ ├── controller
│ │ ├── auth.controller.spec.ts
│ │ ├── auth.controller.ts
│ │ ├── dto
│ │ │ ├── authenticate-user-request.dto.ts
│ │ │ ├── authenticate-user-response.dto.ts
│ │ │ ├── create-user-request.dto.ts
│ │ │ ├── create-user-response.dto.ts
│ │ │ └── get-user-by-id-response.dto.ts
│ │ ├── users.controller.spec.ts
│ │ └── users.controller.ts
│ ├── domain
│ │ └── user.domain.ts
│ ├── main.ts
│ ├── repository
│ │ ├── user.repository.spec.ts
│ │ └── users.repository.ts
│ ├── service
│ │ ├── auth.service.spec.ts
│ │ ├── auth.service.ts
│ │ ├── user.service.spec.ts
│ │ └── users.service.ts
│ └── shared
│ │ ├── auth.guard.ts
│ │ ├── constants.ts
│ │ ├── public.decorator.ts
│ │ └── types.ts
├── test
│ ├── app.e2e-spec.ts
│ └── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json
├── archictecture.drawio
├── collections
├── authentication
│ └── Login.bru
├── bruno.json
├── environments
│ ├── AWS DEV.bru
│ └── Local.bru
└── users
│ ├── Create user.bru
│ ├── Get authenticated user.bru
│ └── Get user by ID.bru
└── infra
├── .gitignore
├── .prettierrc
├── cdktf.json
├── main.ts
├── package-lock.json
├── package.json
├── stacks
└── project.stack.ts
└── tsconfig.json
/.github/workflows/backend.yml:
--------------------------------------------------------------------------------
1 | name: Deploy Backend to Elastic Beanstalk
2 | on:
3 | workflow_dispatch:
4 |
5 | jobs:
6 | build:
7 | name: Build Application
8 | runs-on: ubuntu-latest
9 | defaults:
10 | run:
11 | working-directory: api
12 | steps:
13 | - name: Checkout source code
14 | uses: actions/checkout@v4
15 |
16 | - name: Setup Node.js
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: '20'
20 |
21 | - name: Install dependencies
22 | run: npm ci
23 |
24 | - name: Run tests
25 | run: npm test
26 |
27 | - name: Build
28 | run: npm run build
29 |
30 | - name: Upload build artifact
31 | uses: actions/upload-artifact@v4
32 | with:
33 | name: artifact
34 | path: |
35 | api/*
36 | !api/src
37 | !api/test
38 | !api/node_modules
39 | deploy-dev:
40 | name: Deploy DEV Environment
41 | runs-on: ubuntu-latest
42 | needs: build
43 | steps:
44 | - name: Download build artifact
45 | uses: actions/download-artifact@v4
46 | with:
47 | name: artifact
48 | path: artifact
49 | - name: Zip Artifact for Deployment
50 | run: |
51 | cd artifact
52 | zip -r ../service.zip ./*
53 | - name: Deploy to Elastic Beanstalk
54 | uses: einaregilsson/beanstalk-deploy@v22
55 | with:
56 | aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }}
57 | aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
58 | application_name: dev-api-rest-do-zero-a-aws-com-terraform
59 | environment_name: dev-api-rest-do-zero-a-aws-com-terraform
60 | region: us-east-1
61 | version_label: ${{ github.run_id }}
62 | deployment_package: service.zip
63 | use_existing_version_if_available: true
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | .DS_Store
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # API REST com NestJS do Zero à AWS com Terraform e GitHub Actions
2 |
3 | Obrigado por aparecer!
4 |
5 | **Este conteúdo não é para iniciantes!** 😊
6 |
7 | Neste vídeo, vou te ensinar a desenvolver uma API REST com autenticação, utilizando **NestJS** e **TypeScript**. Além disso, vamos provisionar a infraestrutura na **AWS** utilizando **Terraform** e **GitHub Actions**. E o melhor: tudo do absoluto zero, instalando todas as ferramentas necessárias. **#VAMOSCODAR**
8 |
9 | ## Descrição
10 |
11 | Este é o repositório do vídeo **API REST com NestJS do Zero à AWS com Terraform e GitHub Actions**.
12 |
13 | ✅ [ASSISTA O VÍDEO](https://youtu.be/csWHIujcbKI) 🚀🚀🚀🚀
14 |
15 | Se gostou, não se esqueça de **se inscrever no canal, deixar like e compartilhar com outros devs**.
16 |
17 | ## Sumário
18 |
19 | - [Ferramentas Necessárias](#ferramentas-necessárias)
20 | - [Clonar o Projeto](#clonar-o-projeto)
21 | - [Iniciar o Projeto](#iniciar-o-projeto)
22 | - [Executar os Testes](#executar-os-testes)
23 | - [Deploy da Infraestrutura](#deploy-da-infraestrutura)
24 | - [Dúvidas](#dúvidas)
25 |
26 | ## Ferramentas Necessárias
27 |
28 | Certifique-se de ter as seguintes ferramentas instaladas:
29 |
30 | - **Node Version Manager (NVM)**
31 | ```bash
32 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
33 | ```
34 |
35 | - **Node.js versão 20**
36 | ```bash
37 | nvm install 20
38 | nvm use 20
39 | ```
40 |
41 | - **NestJS CLI**
42 | ```bash
43 | npm install -g @nestjs/cli
44 | ```
45 |
46 | - **AWS CLI**
47 | [Documentação de Instalação](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html)
48 |
49 | - **DynamoDB Local**
50 | ```bash
51 | docker run -d -p 8000:8000 amazon/dynamodb-local
52 | ```
53 |
54 | - **DynamoDB Admin**
55 | ```bash
56 | npm install -g dynamodb-admin
57 | ```
58 |
59 | - **Terraform**
60 | [Documentação de Instalação](https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli)
61 |
62 | - **CDKTF**
63 | ```bash
64 | npm install --global cdktf-cli@latest
65 | ```
66 |
67 | ## Clonar o Projeto
68 | Clone o repositório para a sua máquina local:
69 |
70 | ```bash
71 | git clone git@github.com:petrusdemelodev/001-api-rest-com-nestjs-do-zero-a-aws-com-terraform.git
72 | cd 001-api-rest-com-nestjs-do-zero-a-aws-com-terraform
73 | ```
74 |
75 | ## Iniciar o Projeto
76 | Navegue até a pasta da API, instale as dependências e inicie o servidor de desenvolvimento:
77 |
78 | ```bash
79 | cd api
80 | npm install
81 | npm run start
82 | ```
83 |
84 | ## Executar os Testes
85 | Para rodar os testes, execute:
86 |
87 | ```bash
88 | npm run test
89 | ```
90 |
91 | ## Deploy da Infraestrutura
92 | Para provisionar a infraestrutura na AWS, execute:
93 |
94 | ```bash
95 | cd infra
96 | cdktf apply dev-project-stack
97 | ```
98 |
99 | # Dúvidas
100 |
101 | Deixe seu comentário no vídeo! 😊
102 |
103 | Se este repositório foi útil para você, por favor, deixe uma estrela ⭐ nele no GitHub. Isso ajuda a divulgar o projeto e motiva a criação de mais conteúdos como este.
104 |
105 | # Redes Sociais
106 |
107 | Me segue nas redes sociais
108 |
109 | [INSTAGRAM](https://instagram.com/petrusdemelodev) | [LINKEDIN](https://linkedin.com/in/petrusdemelo) | [TWITTER](https://x.com/petrusdemelodev) | [MEDIUM](https://medium.com/@petrusdemelodev)
--------------------------------------------------------------------------------
/api/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | parserOptions: {
4 | project: 'tsconfig.json',
5 | tsconfigRootDir: __dirname,
6 | sourceType: 'module',
7 | },
8 | plugins: ['@typescript-eslint/eslint-plugin'],
9 | extends: [
10 | 'plugin:@typescript-eslint/recommended',
11 | 'plugin:prettier/recommended',
12 | ],
13 | root: true,
14 | env: {
15 | node: true,
16 | jest: true,
17 | },
18 | ignorePatterns: ['.eslintrc.js'],
19 | rules: {
20 | '@typescript-eslint/interface-name-prefix': 'off',
21 | '@typescript-eslint/explicit-function-return-type': 'error',
22 | '@typescript-eslint/explicit-module-boundary-types': 'off',
23 | '@typescript-eslint/no-explicit-any': 'off'
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/api/.gitignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | /dist
3 | /node_modules
4 | /build
5 |
6 | # Logs
7 | logs
8 | *.log
9 | npm-debug.log*
10 | pnpm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | lerna-debug.log*
14 |
15 | # OS
16 | .DS_Store
17 |
18 | # Tests
19 | /coverage
20 | /.nyc_output
21 |
22 | # IDEs and editors
23 | /.idea
24 | .project
25 | .classpath
26 | .c9/
27 | *.launch
28 | .settings/
29 | *.sublime-workspace
30 |
31 | # IDE - VSCode
32 | .vscode/*
33 | !.vscode/settings.json
34 | !.vscode/tasks.json
35 | !.vscode/launch.json
36 | !.vscode/extensions.json
37 |
38 | # dotenv environment variable files
39 | .env
40 | .env.development.local
41 | .env.test.local
42 | .env.production.local
43 | .env.local
44 |
45 | # temp directory
46 | .temp
47 | .tmp
48 |
49 | # Runtime data
50 | pids
51 | *.pid
52 | *.seed
53 | *.pid.lock
54 |
55 | # Diagnostic reports (https://nodejs.org/api/report.html)
56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
57 |
--------------------------------------------------------------------------------
/api/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all"
4 | }
--------------------------------------------------------------------------------
/api/Procfile:
--------------------------------------------------------------------------------
1 | web: npm ci --production && npm run start:prod
--------------------------------------------------------------------------------
/api/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
6 | [circleci-url]: https://circleci.com/gh/nestjs/nest
7 |
8 | A progressive Node.js framework for building efficient and scalable server-side applications.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
24 |
25 | ## Description
26 |
27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
28 |
29 | ## Installation
30 |
31 | ```bash
32 | $ npm install
33 | ```
34 |
35 | ## Running the app
36 |
37 | ```bash
38 | # development
39 | $ npm run start
40 |
41 | # watch mode
42 | $ npm run start:dev
43 |
44 | # production mode
45 | $ npm run start:prod
46 | ```
47 |
48 | ## Test
49 |
50 | ```bash
51 | # unit tests
52 | $ npm run test
53 |
54 | # e2e tests
55 | $ npm run test:e2e
56 |
57 | # test coverage
58 | $ npm run test:cov
59 | ```
60 |
61 | ## Support
62 |
63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
64 |
65 | ## Stay in touch
66 |
67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
68 | - Website - [https://nestjs.com](https://nestjs.com/)
69 | - Twitter - [@nestframework](https://twitter.com/nestframework)
70 |
71 | ## License
72 |
73 | Nest is [MIT licensed](LICENSE).
74 |
--------------------------------------------------------------------------------
/api/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src",
5 | "compilerOptions": {
6 | "deleteOutDir": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "api",
3 | "version": "0.0.1",
4 | "description": "",
5 | "author": "",
6 | "private": true,
7 | "license": "UNLICENSED",
8 | "scripts": {
9 | "build": "nest build",
10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
11 | "start": "nest start",
12 | "start:dev": "nest start --debug --watch",
13 | "start:debug": "nest start --debug --watch",
14 | "start:prod": "node dist/main",
15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
16 | "test": "jest",
17 | "test:watch": "jest --watch",
18 | "test:cov": "jest --coverage",
19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
20 | "test:e2e": "jest --config ./test/jest-e2e.json"
21 | },
22 | "dependencies": {
23 | "@aws-sdk/client-dynamodb": "^3.637.0",
24 | "@aws-sdk/util-dynamodb": "^3.637.0",
25 | "@fastify/static": "^7.0.4",
26 | "@nestjs/common": "^10.0.0",
27 | "@nestjs/core": "^10.0.0",
28 | "@nestjs/jwt": "^10.2.0",
29 | "@nestjs/platform-express": "^10.0.0",
30 | "@nestjs/platform-fastify": "^10.4.1",
31 | "@nestjs/swagger": "^7.4.0",
32 | "bcrypt": "^5.1.1",
33 | "class-transformer": "^0.5.1",
34 | "class-validator": "^0.14.1",
35 | "fastify": "^4.28.1",
36 | "reflect-metadata": "^0.2.0",
37 | "rxjs": "^7.8.1",
38 | "uuid": "^10.0.0"
39 | },
40 | "devDependencies": {
41 | "@nestjs/cli": "^10.0.0",
42 | "@nestjs/schematics": "^10.0.0",
43 | "@nestjs/testing": "^10.0.0",
44 | "@types/bcrypt": "^5.0.2",
45 | "@types/express": "^4.17.17",
46 | "@types/jest": "^29.5.2",
47 | "@types/node": "^20.3.1",
48 | "@types/supertest": "^6.0.0",
49 | "@types/uuid": "^10.0.0",
50 | "@typescript-eslint/eslint-plugin": "^7.0.0",
51 | "@typescript-eslint/parser": "^7.0.0",
52 | "aws-sdk-client-mock": "^4.0.1",
53 | "aws-sdk-client-mock-jest": "^4.0.1",
54 | "eslint": "^8.42.0",
55 | "eslint-config-prettier": "^9.0.0",
56 | "eslint-plugin-prettier": "^5.0.0",
57 | "jest": "^29.5.0",
58 | "prettier": "^3.0.0",
59 | "source-map-support": "^0.5.21",
60 | "supertest": "^7.0.0",
61 | "ts-jest": "^29.1.0",
62 | "ts-loader": "^9.4.3",
63 | "ts-node": "^10.9.1",
64 | "tsconfig-paths": "^4.2.0",
65 | "typescript": "^5.1.3"
66 | },
67 | "jest": {
68 | "moduleFileExtensions": [
69 | "js",
70 | "json",
71 | "ts"
72 | ],
73 | "rootDir": ".",
74 | "testRegex": ".*\\.spec\\.ts$",
75 | "transform": {
76 | "^.+\\.(t|j)s$": "ts-jest"
77 | },
78 | "collectCoverageFrom": [
79 | "**/*.(t|j)s"
80 | ],
81 | "coverageDirectory": "../coverage",
82 | "testEnvironment": "node",
83 | "moduleNameMapper": {
84 | "^@root(|/.*)$": "/src/$1"
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/api/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { UsersController } from './controller/users.controller';
3 | import { UsersService } from './service/users.service';
4 | import { UsersRepository } from './repository/users.repository';
5 | import { AuthController } from './controller/auth.controller';
6 | import { AuthService } from './service/auth.service';
7 | import { JwtModule } from '@nestjs/jwt';
8 | import { AuthGuard } from './shared/auth.guard';
9 | import { APP_GUARD } from '@nestjs/core';
10 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
11 |
12 | const { LOCAL_DEVELOPMENT = false, AWS_REGION = 'us-east-1' } = process.env;
13 |
14 | const client = new DynamoDBClient({
15 | region: LOCAL_DEVELOPMENT ? undefined : AWS_REGION,
16 | endpoint: LOCAL_DEVELOPMENT ? 'http://localhost:8000' : undefined,
17 | });
18 |
19 | @Module({
20 | imports: [
21 | JwtModule.register({
22 | global: true,
23 | }),
24 | ],
25 | controllers: [UsersController, AuthController],
26 | providers: [
27 | UsersService,
28 | UsersRepository,
29 | AuthService,
30 | {
31 | provide: APP_GUARD,
32 | useClass: AuthGuard,
33 | },
34 | {
35 | provide: DynamoDBClient,
36 | useValue: client,
37 | },
38 | ],
39 | })
40 | export class AppModule {}
41 |
--------------------------------------------------------------------------------
/api/src/controller/auth.controller.spec.ts:
--------------------------------------------------------------------------------
1 | import { AuthService } from '@root/service/auth.service';
2 | import { AuthController } from './auth.controller';
3 | import { UnauthorizedException } from '@nestjs/common';
4 |
5 | describe('AuthController', () => {
6 | let authController: AuthController;
7 |
8 | const login: jest.Mock = jest.fn();
9 | const authServiceMock = {
10 | login,
11 | } as any;
12 |
13 | beforeAll(() => {
14 | authController = new AuthController(authServiceMock as AuthService);
15 | });
16 |
17 | beforeEach(() => {
18 | jest.clearAllMocks();
19 | });
20 |
21 | describe('login', () => {
22 | it('should return an access token', async () => {
23 | // given
24 | const email = 'john.doe@gmail.com';
25 | const password = 'password';
26 | login.mockReturnValue('access-token');
27 |
28 | // when
29 | const authResponse = await authController.login({ email, password });
30 |
31 | // then
32 | expect(login).toHaveBeenCalledTimes(1);
33 | expect(login).toHaveBeenCalledWith({ email, password });
34 | expect(authResponse).toEqual({ accessToken: 'access-token' });
35 | });
36 |
37 | it('should return an exception if we send wrong credentials', async () => {
38 | // given
39 | const email = 'john.doe@gmail.com';
40 | const password = 'wrong-password';
41 | login.mockRejectedValue(new UnauthorizedException('Invalid credentials'));
42 |
43 | // then
44 | await expect(authController.login({ email, password })).rejects.toThrow(
45 | UnauthorizedException,
46 | );
47 | expect(login).toHaveBeenCalledTimes(1);
48 | expect(login).toHaveBeenCalledWith({ email, password });
49 | });
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/api/src/controller/auth.controller.ts:
--------------------------------------------------------------------------------
1 | import { Body, Controller, Post } from '@nestjs/common';
2 | import { AuthenticateUserRequestDTO } from './dto/authenticate-user-request.dto';
3 | import { ApiTags } from '@nestjs/swagger';
4 | import { AuthService } from '@root/service/auth.service';
5 | import { AuthenticateUserResponseDTO } from './dto/authenticate-user-response.dto';
6 | import { Public } from '@root/shared/public.decorator';
7 |
8 | @Controller('auth')
9 | @ApiTags('auth')
10 | export class AuthController {
11 | constructor(private readonly authService: AuthService) {}
12 |
13 | @Public()
14 | @Post('login')
15 | public async login(
16 | @Body() credentials: AuthenticateUserRequestDTO,
17 | ): Promise {
18 | const token = await this.authService.login({
19 | email: credentials.email,
20 | password: credentials.password,
21 | });
22 |
23 | return new AuthenticateUserResponseDTO(token);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/api/src/controller/dto/authenticate-user-request.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 | import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
3 |
4 | export class AuthenticateUserRequestDTO {
5 | @ApiProperty({
6 | name: 'email',
7 | description: 'email of the user',
8 | })
9 | @IsEmail()
10 | @IsNotEmpty()
11 | public email: string;
12 |
13 | @ApiProperty({
14 | name: 'password',
15 | description: 'password of the user',
16 | })
17 | @IsNotEmpty()
18 | @IsString()
19 | public password: string;
20 | }
21 |
--------------------------------------------------------------------------------
/api/src/controller/dto/authenticate-user-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 |
3 | export class AuthenticateUserResponseDTO {
4 | @ApiProperty({
5 | name: 'accessToken',
6 | description: 'JWT access token',
7 | })
8 | public accessToken: string;
9 |
10 | constructor(accessToken: string) {
11 | this.accessToken = accessToken;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/api/src/controller/dto/create-user-request.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 | import { IsEmail, IsNotEmpty } from 'class-validator';
3 |
4 | export class CreateUserRequestDTO {
5 | @ApiProperty({
6 | description: 'The name of the user',
7 | example: 'John Doe',
8 | })
9 | @IsNotEmpty()
10 | public name: string;
11 |
12 | @ApiProperty({
13 | description: 'The email of the user',
14 | example: 'john.doe@gmail.com',
15 | })
16 | @IsNotEmpty()
17 | @IsEmail()
18 | public email: string;
19 |
20 | @ApiProperty({
21 | description: 'Password of the user',
22 | example: '123456',
23 | })
24 | @IsNotEmpty()
25 | public password: string;
26 |
27 | constructor(partial: Partial) {
28 | Object.assign(this, partial);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/api/src/controller/dto/create-user-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 |
3 | export class CreateUserResponseDTO {
4 | @ApiProperty({
5 | description: 'The id of the created user',
6 | example: '209c4620-8031-4f05-9c07-dbd095c1d407',
7 | })
8 | public id: string;
9 |
10 | constructor(id: string) {
11 | this.id = id;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/api/src/controller/dto/get-user-by-id-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 |
3 | export class GetUserByIDResponseDTO {
4 | @ApiProperty({
5 | name: 'id',
6 | description: 'id of the user',
7 | example: '123e4567-e89b-12d3-a456-426614174000',
8 | })
9 | public id: string;
10 |
11 | @ApiProperty({
12 | name: 'name',
13 | description: 'name of the user',
14 | example: 'John Doe',
15 | })
16 | public name: string;
17 |
18 | @ApiProperty({
19 | name: 'email',
20 | description: 'email of the user',
21 | example: 'john.doe@gmail.com',
22 | })
23 | public email: string;
24 |
25 | @ApiProperty({
26 | name: 'createdAt',
27 | description: 'when the user was created',
28 | example: '2021-01-01T00:00:00.000Z',
29 | })
30 | public createdAt: string;
31 |
32 | @ApiProperty({
33 | name: 'updatedAt',
34 | description: 'when the user was last updated',
35 | example: '2021-01-01T00:00:00.000Z',
36 | })
37 | public updatedAt: string;
38 |
39 | constructor(params: GetUserByIDResponseDTO) {
40 | this.id = params.id;
41 | this.name = params.name;
42 | this.email = params.email;
43 | this.createdAt = params.createdAt;
44 | this.updatedAt = params.updatedAt;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/api/src/controller/users.controller.spec.ts:
--------------------------------------------------------------------------------
1 | import { UsersService } from '@root/service/users.service';
2 | import { CreateUserRequestDTO } from './dto/create-user-request.dto';
3 | import { UsersController } from './users.controller';
4 | import { User } from '@root/domain/user.domain';
5 | import { AuthenticatedRequest } from '@root/shared/types';
6 |
7 | describe('UsersController', () => {
8 | let usersController: UsersController;
9 |
10 | const createUser: jest.Mock = jest.fn();
11 | const getUserByID: jest.Mock = jest.fn();
12 | const userServiceMock = {
13 | createUser,
14 | getUserByID,
15 | } as any;
16 |
17 | beforeAll(() => {
18 | usersController = new UsersController(userServiceMock as UsersService);
19 | });
20 |
21 | beforeEach(() => {
22 | jest.clearAllMocks();
23 | });
24 |
25 | describe('createUser', () => {
26 | it('should create an user', async () => {
27 | // given
28 | const body = new CreateUserRequestDTO({
29 | email: 'john.doe@gmail.com',
30 | name: 'John Doe',
31 | password: 'password',
32 | });
33 | createUser.mockReturnValue('random-user-id');
34 |
35 | // when
36 | const createUserResponse = await usersController.createUser(body);
37 |
38 | // then
39 | expect(createUser).toHaveBeenCalledTimes(1);
40 | expect(createUser).toHaveBeenCalledWith({
41 | email: body.email,
42 | name: body.name,
43 | password: body.password,
44 | });
45 | expect(createUserResponse).toEqual({ id: 'random-user-id' });
46 | });
47 | });
48 |
49 | describe('getUser', () => {
50 | it('should get an user by ID', async () => {
51 | // given
52 | const user = new User({
53 | id: 'random-user-id',
54 | name: 'John Doe',
55 | email: 'john.doe@gmail.com',
56 | password: '123456',
57 | });
58 |
59 | getUserByID.mockReturnValue(user);
60 |
61 | // when
62 | const getUserResponse = await usersController.getUserByID(user.id);
63 |
64 | // then
65 | expect(getUserByID).toHaveBeenCalledWith(user.id);
66 | expect(getUserResponse).toEqual({
67 | id: user.id,
68 | name: user.name,
69 | email: user.email,
70 | createdAt: user.createdAt.toISOString(),
71 | updatedAt: user.updatedAt.toISOString(),
72 | });
73 | });
74 | });
75 |
76 | describe('getMe', () => {
77 | it('should return a user from the authenticated request', async () => {
78 | // given
79 | const user = new User({
80 | id: 'random-user-id',
81 | name: 'John Doe',
82 | email: 'john.doe@gmail.com',
83 | password: '123456',
84 | });
85 |
86 | getUserByID.mockReturnValue(user);
87 |
88 | // when
89 | const getUserResponse = await usersController.getMe({
90 | userID: user.id,
91 | } as any as AuthenticatedRequest);
92 |
93 | // then
94 | expect(getUserByID).toHaveBeenCalledWith(user.id);
95 | expect(getUserResponse).toEqual({
96 | id: user.id,
97 | name: user.name,
98 | email: user.email,
99 | createdAt: user.createdAt.toISOString(),
100 | updatedAt: user.updatedAt.toISOString(),
101 | });
102 | });
103 | });
104 | });
105 |
--------------------------------------------------------------------------------
/api/src/controller/users.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Controller,
4 | Get,
5 | HttpStatus,
6 | Param,
7 | Post,
8 | Req,
9 | } from '@nestjs/common';
10 | import { ApiResponse, ApiTags } from '@nestjs/swagger';
11 | import { UsersService } from '@root/service/users.service';
12 | import { CreateUserRequestDTO } from './dto/create-user-request.dto';
13 | import { CreateUserResponseDTO } from './dto/create-user-response.dto';
14 | import { GetUserByIDResponseDTO } from './dto/get-user-by-id-response.dto';
15 | import { AuthenticatedRequest } from '@root/shared/types';
16 | import { Public } from '@root/shared/public.decorator';
17 |
18 | @ApiTags('users')
19 | @Controller('users')
20 | export class UsersController {
21 | constructor(private readonly usersService: UsersService) {}
22 |
23 | @Public()
24 | @Post()
25 | @ApiResponse({
26 | status: HttpStatus.CREATED,
27 | description: 'User created',
28 | type: CreateUserResponseDTO,
29 | })
30 | public async createUser(
31 | @Body() createUserBody: CreateUserRequestDTO,
32 | ): Promise {
33 | const createdUserID = await this.usersService.createUser({
34 | name: createUserBody.name,
35 | email: createUserBody.email,
36 | password: createUserBody.password,
37 | });
38 |
39 | return new CreateUserResponseDTO(createdUserID);
40 | }
41 |
42 | @Get(':userID')
43 | @Public()
44 | @ApiResponse({
45 | status: HttpStatus.OK,
46 | description: 'User found',
47 | type: GetUserByIDResponseDTO,
48 | })
49 | public async getUserByID(
50 | @Param('userID') userID: string,
51 | ): Promise {
52 | const userResult = await this.usersService.getUserByID(userID);
53 |
54 | return new GetUserByIDResponseDTO({
55 | id: userResult.id,
56 | name: userResult.name,
57 | email: userResult.email,
58 | createdAt: userResult.createdAt.toISOString(),
59 | updatedAt: userResult.updatedAt.toISOString(),
60 | });
61 | }
62 |
63 | @Get('me')
64 | @ApiResponse({
65 | status: HttpStatus.OK,
66 | description: 'User found',
67 | type: GetUserByIDResponseDTO,
68 | })
69 | public async getMe(
70 | @Req() request: AuthenticatedRequest,
71 | ): Promise {
72 | return this.getUserByID(request.userID);
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/api/src/domain/user.domain.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuid } from 'uuid';
2 |
3 | interface UserProps {
4 | name: string;
5 | email: string;
6 | password: string;
7 | }
8 |
9 | type UserUpdate = Partial & UserProps;
10 |
11 | export class User {
12 | public readonly id: string;
13 | public readonly name: string;
14 | public readonly email: string;
15 | public readonly createdAt: Date;
16 | public password: string;
17 | public updatedAt: Date;
18 |
19 | constructor(init: UserUpdate) {
20 | Object.assign(
21 | this,
22 | {
23 | id: uuid(),
24 | createdAt: new Date(),
25 | updatedAt: new Date(),
26 | },
27 | init,
28 | );
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/api/src/main.ts:
--------------------------------------------------------------------------------
1 | import { NestFactory } from '@nestjs/core';
2 | import { AppModule } from './app.module';
3 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
4 | import { ValidationPipe } from '@nestjs/common';
5 | import { FastifyAdapter } from '@nestjs/platform-fastify';
6 |
7 | const { PORT = 3000 } = process.env;
8 |
9 | async function bootstrap(): Promise {
10 | const app = await NestFactory.create(AppModule, new FastifyAdapter());
11 | app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
12 |
13 | const config = new DocumentBuilder()
14 | .setTitle('API Rest NestJS do Zero a AWS com Terraform')
15 | .addBearerAuth()
16 | .setVersion('1.0')
17 | .build();
18 |
19 | const swaggerCDN = 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.7.2';
20 | const document = SwaggerModule.createDocument(app, config);
21 |
22 | SwaggerModule.setup('api', app, document, {
23 | customCssUrl: [`${swaggerCDN}/swagger-ui.css`],
24 | customJs: [
25 | `${swaggerCDN}/swagger-ui-bundle.js`,
26 | `${swaggerCDN}/swagger-ui-standalone-preset.js`,
27 | ],
28 | });
29 |
30 | await app.listen(PORT);
31 | console.log(`Server is running on: http://localhost:${PORT}`);
32 | console.log(`📄 Swagger is on: http://localhost:${PORT}/api`);
33 | }
34 |
35 | bootstrap();
36 |
--------------------------------------------------------------------------------
/api/src/repository/user.repository.spec.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DynamoDBClient,
3 | GetItemCommand,
4 | PutItemCommand,
5 | QueryCommand,
6 | } from '@aws-sdk/client-dynamodb';
7 | import { mockClient } from 'aws-sdk-client-mock';
8 | import 'aws-sdk-client-mock-jest';
9 | import { marshall } from '@aws-sdk/util-dynamodb';
10 | import { UsersRepository } from './users.repository';
11 | import { User } from '@root/domain/user.domain';
12 |
13 | describe('UserRepository', () => {
14 | let repository: UsersRepository;
15 | const dynamoDBClient = new DynamoDBClient({});
16 | const dynamodbClientMock = mockClient(dynamoDBClient);
17 |
18 | beforeAll(() => {
19 | repository = new UsersRepository(dynamoDBClient);
20 | });
21 |
22 | beforeEach(() => {
23 | jest.clearAllMocks();
24 | dynamodbClientMock.reset();
25 | });
26 |
27 | describe('create', () => {
28 | it('should create a user', async () => {
29 | // given
30 | const user = new User({
31 | name: 'John Doe',
32 | email: 'john.doe@gmail.com',
33 | password: '123456',
34 | });
35 |
36 | // when
37 | await repository.createUser(user);
38 |
39 | // then
40 | expect(dynamodbClientMock).toHaveReceivedCommandWith(PutItemCommand, {
41 | TableName: `dev-users`,
42 | Item: marshall({
43 | id: user.id,
44 | name: user.name,
45 | email: user.email,
46 | password: user.password,
47 | createdAt: user.createdAt.toISOString(),
48 | updatedAt: user.updatedAt.toISOString(),
49 | }),
50 | });
51 | });
52 | });
53 |
54 | describe('getByID', () => {
55 | it('should get a user by ID if user exists', async () => {
56 | // given
57 | const id = '27190ff5-e5bb-4800-b23a-191b34670904';
58 |
59 | dynamodbClientMock.on(GetItemCommand).resolves({
60 | Item: marshall({
61 | id,
62 | name: 'John Doe',
63 | email: 'john.doe@gmail.com',
64 | password: '123456',
65 | createdAt: new Date('2024-06-01').toISOString(),
66 | updatedAt: new Date('2024-06-01').toISOString(),
67 | }),
68 | });
69 |
70 | // when
71 | const user = await repository.getUserByID(id);
72 |
73 | // then
74 | expect(user).toBeInstanceOf(User);
75 | expect(user.id).toBe(id);
76 | expect(user.name).toBe('John Doe');
77 | expect(user.email).toBe('john.doe@gmail.com');
78 | expect(user.password).toBe('123456');
79 | expect(user.createdAt).toEqual(new Date('2024-06-01'));
80 | expect(user.updatedAt).toEqual(new Date('2024-06-01'));
81 | expect(dynamodbClientMock).toHaveReceivedCommandWith(GetItemCommand, {
82 | TableName: `dev-users`,
83 | Key: marshall({ id }),
84 | });
85 | });
86 |
87 | it('should return undefined if user does not exist', async () => {
88 | // given
89 | const id = '27190ff5-e5bb-4800-b23a-191b34670904';
90 |
91 | dynamodbClientMock.on(GetItemCommand).resolves({
92 | Item: null,
93 | });
94 |
95 | // when
96 | const user = await repository.getUserByID(id);
97 |
98 | // then
99 | expect(user).toBeUndefined();
100 | });
101 | });
102 |
103 | describe('getByEmail', () => {
104 | it('should get a user by email if user exists', async () => {
105 | // given
106 | const email = 'john.doe@gmail.com';
107 |
108 | dynamodbClientMock.on(QueryCommand).resolves({
109 | Items: [
110 | marshall({
111 | id: '27190ff5-e5bb-4800-b23a-191b34670904',
112 | name: 'John Doe',
113 | email: 'john.doe@gmail.com',
114 | password: '123456',
115 | createdAt: new Date('2024-06-01').toISOString(),
116 | updatedAt: new Date('2024-06-01').toISOString(),
117 | }),
118 | ],
119 | });
120 |
121 | // when
122 | const user = await repository.getUserByEmail(email);
123 |
124 | // then
125 | expect(user).toBeInstanceOf(User);
126 | expect(user.id).toBe('27190ff5-e5bb-4800-b23a-191b34670904');
127 | expect(user.name).toBe('John Doe');
128 | expect(user.email).toBe('john.doe@gmail.com');
129 | expect(user.password).toBe('123456');
130 | expect(user.createdAt).toEqual(new Date('2024-06-01'));
131 | expect(user.updatedAt).toEqual(new Date('2024-06-01'));
132 | expect(dynamodbClientMock).toHaveReceivedCommandWith(QueryCommand, {
133 | TableName: `dev-users`,
134 | IndexName: 'email_index',
135 | KeyConditionExpression: 'email = :email',
136 | ExpressionAttributeValues: {
137 | ':email': { S: email },
138 | },
139 | });
140 | });
141 |
142 | it('should return undefined when email does not exist in any user', async () => {
143 | // given
144 | const email = 'john.doe@gmail.com';
145 |
146 | dynamodbClientMock.on(QueryCommand).resolves({
147 | Items: [],
148 | });
149 |
150 | // when
151 | const user = await repository.getUserByEmail(email);
152 |
153 | // then
154 | expect(user).toBeUndefined();
155 | expect(dynamodbClientMock).toHaveReceivedCommandWith(QueryCommand, {
156 | TableName: `dev-users`,
157 | IndexName: 'email_index',
158 | KeyConditionExpression: 'email = :email',
159 | ExpressionAttributeValues: {
160 | ':email': { S: email },
161 | },
162 | });
163 | });
164 | });
165 | });
166 |
--------------------------------------------------------------------------------
/api/src/repository/users.repository.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DynamoDBClient,
3 | GetItemCommand,
4 | PutItemCommand,
5 | QueryCommand,
6 | } from '@aws-sdk/client-dynamodb';
7 | import { Injectable } from '@nestjs/common';
8 | import { User } from '@root/domain/user.domain';
9 | import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
10 |
11 | const { ENVIRONMENT = 'dev' } = process.env;
12 |
13 | interface UserRecord {
14 | id: string;
15 | email: string;
16 | password: string;
17 | name: string;
18 | createdAt: string;
19 | updatedAt: string;
20 | }
21 |
22 | @Injectable()
23 | export class UsersRepository {
24 | constructor(private readonly client: DynamoDBClient) {}
25 |
26 | public async createUser(user: User): Promise {
27 | const command = new PutItemCommand({
28 | TableName: `${ENVIRONMENT}-users`,
29 | Item: marshall({
30 | id: user.id,
31 | name: user.name,
32 | email: user.email,
33 | password: user.password,
34 | createdAt: user.createdAt.toISOString(),
35 | updatedAt: user.updatedAt.toISOString(),
36 | }),
37 | ConditionExpression: 'attribute_not_exists(id)',
38 | });
39 |
40 | await this.client.send(command);
41 | }
42 |
43 | public async getUserByEmail(email: string): Promise {
44 | const command = new QueryCommand({
45 | TableName: `${ENVIRONMENT}-users`,
46 | IndexName: 'email_index',
47 | KeyConditionExpression: 'email = :email',
48 | ExpressionAttributeValues: marshall({ ':email': email }),
49 | });
50 |
51 | const response = await this.client.send(command);
52 |
53 | if (response.Items?.length === 0) {
54 | return undefined;
55 | }
56 |
57 | const userRecord = unmarshall(response.Items[0]) as UserRecord;
58 | return this.mapUserFromUserRecord(userRecord);
59 | }
60 |
61 | public async getUserByID(userID: string): Promise {
62 | const command = new GetItemCommand({
63 | TableName: `${ENVIRONMENT}-users`,
64 | Key: marshall({ id: userID }),
65 | });
66 |
67 | const response = await this.client.send(command);
68 |
69 | if (!response.Item) {
70 | return undefined;
71 | }
72 |
73 | const userRecord = unmarshall(response.Item) as UserRecord;
74 | return this.mapUserFromUserRecord(userRecord);
75 | }
76 |
77 | private mapUserFromUserRecord(record: UserRecord): User {
78 | return new User({
79 | id: record.id,
80 | email: record.email,
81 | password: record.password,
82 | name: record.name,
83 | createdAt: new Date(record.createdAt),
84 | updatedAt: new Date(record.updatedAt),
85 | });
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/api/src/service/auth.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { UnauthorizedException } from '@nestjs/common';
2 | import { AuthService } from './auth.service';
3 | import { JwtService } from '@nestjs/jwt';
4 | import { UsersRepository } from '@root/repository/users.repository';
5 | import { hash } from 'bcrypt';
6 |
7 | describe('AuthService', () => {
8 | let authService: AuthService;
9 |
10 | const jwtServiceMock = { signAsync: jest.fn() };
11 | const userRepositoryMock = { getUserByEmail: jest.fn() };
12 |
13 | beforeAll(() => {
14 | authService = new AuthService(
15 | userRepositoryMock as any as UsersRepository,
16 | jwtServiceMock as any as JwtService,
17 | );
18 | });
19 |
20 | beforeEach(() => {
21 | jest.clearAllMocks();
22 | });
23 |
24 | describe('authenticateUser', () => {
25 | it('should create an access token for a correct user credentials', async () => {
26 | // given
27 | const { email, password } = {
28 | email: 'john.doe@gmail.com',
29 | password: 'password',
30 | };
31 |
32 | const user = {
33 | id: 'random-user-id',
34 | email,
35 | password: await hash(password, 10),
36 | };
37 |
38 | userRepositoryMock.getUserByEmail.mockReturnValue(user);
39 | jwtServiceMock.signAsync.mockReturnValue('access-token');
40 |
41 | // when
42 | const token = await authService.login({ email, password });
43 |
44 | // then
45 | expect(userRepositoryMock.getUserByEmail).toHaveBeenCalledTimes(1);
46 | expect(userRepositoryMock.getUserByEmail).toHaveBeenCalledWith(email);
47 | expect(jwtServiceMock.signAsync).toHaveBeenCalledTimes(1);
48 | expect(token).toEqual('access-token');
49 | });
50 |
51 | it('should return an exception for wrong credentials', async () => {
52 | // given
53 | const { email, password } = {
54 | email: 'john.doe@gmail.com',
55 | password: 'password',
56 | };
57 |
58 | const user = {
59 | id: 'random-user-id',
60 | email,
61 | password: await hash(password, 10),
62 | };
63 |
64 | userRepositoryMock.getUserByEmail.mockReturnValue(user);
65 |
66 | // when
67 | const token = authService.login({
68 | email,
69 | password: 'wrong-password',
70 | });
71 |
72 | // then
73 | expect(userRepositoryMock.getUserByEmail).toHaveBeenCalledTimes(1);
74 | expect(userRepositoryMock.getUserByEmail).toHaveBeenCalledWith(email);
75 | expect(jwtServiceMock.signAsync).toHaveBeenCalledTimes(0);
76 | await expect(token).rejects.toThrow(UnauthorizedException);
77 | });
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/api/src/service/auth.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, UnauthorizedException } from '@nestjs/common';
2 | import { JwtService } from '@nestjs/jwt';
3 | import { UsersRepository } from '@root/repository/users.repository';
4 | import { JWT_SECRET } from '@root/shared/constants';
5 | import { TokenPayload } from '@root/shared/types';
6 | import { compare } from 'bcrypt';
7 |
8 | interface Credentials {
9 | email: string;
10 | password: string;
11 | }
12 |
13 | @Injectable()
14 | export class AuthService {
15 | constructor(
16 | private readonly userRepository: UsersRepository,
17 | private readonly jwtService: JwtService,
18 | ) {}
19 |
20 | public async login(credentials: Credentials): Promise {
21 | const { email, password } = credentials;
22 |
23 | const user = await this.userRepository.getUserByEmail(email);
24 | const passwordMatch = await compare(password, user?.password ?? '');
25 |
26 | if (!user || !passwordMatch) {
27 | throw new UnauthorizedException('Invalid credentials');
28 | }
29 |
30 | const payload: TokenPayload = {
31 | sub: user.id,
32 | name: user.name,
33 | email: user.email,
34 | exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1 hour
35 | iat: Math.floor(Date.now() / 1000),
36 | aud: 'PetrusDeMeloDEV - Se inscreva no canal e ative o sininho',
37 | };
38 |
39 | const token = await this.jwtService.signAsync(payload, {
40 | secret: JWT_SECRET,
41 | });
42 |
43 | return token;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/api/src/service/user.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { NotFoundException } from '@nestjs/common';
2 | import { compare } from 'bcrypt';
3 | import { UsersService } from './users.service';
4 | import { UsersRepository } from '@root/repository/users.repository';
5 | import { User } from '@root/domain/user.domain';
6 |
7 | describe('UserService', () => {
8 | let service: UsersService;
9 | let userRepository: UsersRepository;
10 | const createUser: jest.Mock = jest.fn();
11 | const getUserByEmail: jest.Mock = jest.fn();
12 | const getUserByID: jest.Mock = jest.fn();
13 |
14 | beforeAll(() => {
15 | userRepository = {
16 | createUser,
17 | getUserByEmail,
18 | getUserByID,
19 | } as any as UsersRepository;
20 | service = new UsersService(userRepository);
21 | });
22 |
23 | beforeEach(() => {
24 | jest.clearAllMocks();
25 | });
26 |
27 | describe('createUser', () => {
28 | it('should create a user', async () => {
29 | // given
30 | const params = {
31 | name: 'John Doe',
32 | email: 'john.doe@gmail.com',
33 | password: 'password',
34 | };
35 |
36 | // when
37 | await service.createUser(params);
38 |
39 | // then
40 | expect(userRepository.createUser).toHaveBeenCalledWith(
41 | expect.objectContaining({
42 | name: params.name,
43 | email: params.email,
44 | password: expect.any(String),
45 | }),
46 | );
47 | expect(userRepository.createUser).toHaveBeenCalledTimes(1);
48 | expect(createUser.mock.calls[0][0]).toBeInstanceOf(User);
49 | expect(
50 | compare(params.password, createUser.mock.calls[0][0].password),
51 | ).resolves.toBeTruthy();
52 | });
53 | });
54 |
55 | describe('getUserByID', () => {
56 | it('should return a set of data from user', async () => {
57 | // given
58 | const user = new User({
59 | name: 'John Doe',
60 | email: 'john.doe@gmail.com',
61 | password: 'password',
62 | });
63 | getUserByID.mockResolvedValue(user);
64 |
65 | // when
66 | const result = await service.getUserByID(user.id);
67 |
68 | // then
69 | expect(userRepository.getUserByID).toHaveBeenCalledTimes(1);
70 | expect(userRepository.getUserByID).toHaveBeenCalledWith(user.id);
71 | expect(result).toEqual({
72 | id: user.id,
73 | name: user.name,
74 | email: user.email,
75 | createdAt: user.createdAt,
76 | updatedAt: user.updatedAt,
77 | });
78 | });
79 |
80 | it('should throw an exception if user does not exist', async () => {
81 | // given
82 | getUserByID.mockResolvedValue(undefined);
83 |
84 | // then
85 | await expect(service.getUserByID('invalid-id')).rejects.toThrow(
86 | NotFoundException,
87 | );
88 | });
89 | });
90 | });
91 |
--------------------------------------------------------------------------------
/api/src/service/users.service.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BadRequestException,
3 | Injectable,
4 | NotFoundException,
5 | } from '@nestjs/common';
6 | import { User } from '@root/domain/user.domain';
7 | import { UsersRepository } from '@root/repository/users.repository';
8 | import { hash } from 'bcrypt';
9 |
10 | interface CreateUserParams {
11 | name: string;
12 | email: string;
13 | password: string;
14 | }
15 |
16 | interface IUser {
17 | id: string;
18 | name: string;
19 | email: string;
20 | createdAt: Date;
21 | updatedAt: Date;
22 | }
23 |
24 | const SALT_ROUNDS = 10;
25 |
26 | @Injectable()
27 | export class UsersService {
28 | constructor(private readonly usersRepository: UsersRepository) {}
29 |
30 | public async createUser(params: CreateUserParams): Promise {
31 | const userExists: User = await this.usersRepository.getUserByEmail(
32 | params.email,
33 | );
34 |
35 | if (userExists) {
36 | throw new BadRequestException('Email already in use');
37 | }
38 |
39 | const passwordHash = await hash(params.password, SALT_ROUNDS);
40 |
41 | const user = new User({
42 | name: params.name,
43 | email: params.email,
44 | password: passwordHash,
45 | });
46 |
47 | await this.usersRepository.createUser(user);
48 | return user.id;
49 | }
50 |
51 | public async getUserByID(userID: string): Promise {
52 | const user: User = await this.usersRepository.getUserByID(userID);
53 |
54 | if (!user) {
55 | throw new NotFoundException('User not found');
56 | }
57 |
58 | return {
59 | id: user.id,
60 | name: user.name,
61 | email: user.email,
62 | createdAt: user.createdAt,
63 | updatedAt: user.updatedAt,
64 | };
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/api/src/shared/auth.guard.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CanActivate,
3 | ExecutionContext,
4 | Injectable,
5 | UnauthorizedException,
6 | } from '@nestjs/common';
7 | import { Reflector } from '@nestjs/core';
8 | import { JwtService } from '@nestjs/jwt';
9 | import { FastifyRequest as Request } from 'fastify';
10 | import { JWT_SECRET, PUBLIC_ENDPOINT_METADATA_KEY } from './constants';
11 | import { TokenPayload } from './types';
12 |
13 | @Injectable()
14 | export class AuthGuard implements CanActivate {
15 | constructor(
16 | private readonly jwtService: JwtService,
17 | private readonly reflector: Reflector,
18 | ) {}
19 |
20 | public async canActivate(context: ExecutionContext): Promise {
21 | const isPublic = this.reflector.getAllAndOverride(
22 | PUBLIC_ENDPOINT_METADATA_KEY,
23 | [context.getHandler(), context.getClass()],
24 | );
25 |
26 | if (isPublic) {
27 | return true;
28 | }
29 |
30 | const request = context.switchToHttp().getRequest();
31 | const token = this.extractTokenFromHeader(request);
32 |
33 | if (!token) {
34 | throw new UnauthorizedException();
35 | }
36 |
37 | try {
38 | const payload: TokenPayload = await this.jwtService.verifyAsync(token, {
39 | secret: JWT_SECRET,
40 | });
41 |
42 | request.userID = payload.sub;
43 | } catch {
44 | throw new UnauthorizedException();
45 | }
46 |
47 | return true;
48 | }
49 |
50 | private extractTokenFromHeader(request: Request): string | undefined {
51 | const [type, token] = request.headers.authorization?.split(' ') ?? [];
52 | return type === 'Bearer' ? token : undefined;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/api/src/shared/constants.ts:
--------------------------------------------------------------------------------
1 | export const JWT_SECRET = 'G0fS,`~pga_o"X=}AL.B=;+8:[%13-';
2 | export const PUBLIC_ENDPOINT_METADATA_KEY = 'public-endpoint';
3 |
--------------------------------------------------------------------------------
/api/src/shared/public.decorator.ts:
--------------------------------------------------------------------------------
1 | import { PUBLIC_ENDPOINT_METADATA_KEY } from './constants';
2 | import { CustomDecorator, SetMetadata } from '@nestjs/common';
3 |
4 | export const Public = (): CustomDecorator =>
5 | SetMetadata(PUBLIC_ENDPOINT_METADATA_KEY, true);
6 |
--------------------------------------------------------------------------------
/api/src/shared/types.ts:
--------------------------------------------------------------------------------
1 | import { FastifyRequest } from 'fastify';
2 |
3 | export interface TokenPayload {
4 | sub: string;
5 | name: string;
6 | email: string;
7 | exp: number;
8 | iat: number;
9 | aud: string;
10 | }
11 |
12 | export type AuthenticatedRequest = {
13 | userID: string;
14 | } & FastifyRequest;
15 |
--------------------------------------------------------------------------------
/api/test/app.e2e-spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { INestApplication } from '@nestjs/common';
3 | import * as request from 'supertest';
4 | import { AppModule } from './../src/app.module';
5 |
6 | describe('AppController (e2e)', () => {
7 | let app: INestApplication;
8 |
9 | beforeEach(async () => {
10 | const moduleFixture: TestingModule = await Test.createTestingModule({
11 | imports: [AppModule],
12 | }).compile();
13 |
14 | app = moduleFixture.createNestApplication();
15 | await app.init();
16 | });
17 |
18 | it('/ (GET)', () => {
19 | return request(app.getHttpServer())
20 | .get('/')
21 | .expect(200)
22 | .expect('Hello World!');
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/api/test/jest-e2e.json:
--------------------------------------------------------------------------------
1 | {
2 | "moduleFileExtensions": ["js", "json", "ts"],
3 | "rootDir": ".",
4 | "testEnvironment": "node",
5 | "testRegex": ".e2e-spec.ts$",
6 | "transform": {
7 | "^.+\\.(t|j)s$": "ts-jest"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/api/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/api/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "declaration": true,
5 | "removeComments": true,
6 | "emitDecoratorMetadata": true,
7 | "experimentalDecorators": true,
8 | "allowSyntheticDefaultImports": true,
9 | "target": "ES2021",
10 | "sourceMap": true,
11 | "outDir": "./dist",
12 | "baseUrl": "./",
13 | "incremental": true,
14 | "skipLibCheck": true,
15 | "strictNullChecks": false,
16 | "noImplicitAny": false,
17 | "strictBindCallApply": false,
18 | "forceConsistentCasingInFileNames": false,
19 | "noFallthroughCasesInSwitch": false,
20 | "paths": {
21 | "@root": [
22 | "src"
23 | ],
24 | "@root/*": [
25 | "src/*"
26 | ]
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/archictecture.drawio:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
--------------------------------------------------------------------------------
/collections/authentication/Login.bru:
--------------------------------------------------------------------------------
1 | meta {
2 | name: Login
3 | type: http
4 | seq: 1
5 | }
6 |
7 | post {
8 | url: {{host}}/auth/login
9 | body: json
10 | auth: none
11 | }
12 |
13 | body:json {
14 | {
15 | "email": "john.doe@gmail.com",
16 | "password": "123456"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/collections/bruno.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "1",
3 | "name": "001-api-rest-com-nestjs-do-zero-a-aws",
4 | "type": "collection",
5 | "ignore": [
6 | "node_modules",
7 | ".git"
8 | ]
9 | }
--------------------------------------------------------------------------------
/collections/environments/AWS DEV.bru:
--------------------------------------------------------------------------------
1 | vars {
2 | host: awseb-e-p-AWSEBLoa-19F30FA4MU4DX-241478456.us-east-1.elb.amazonaws.com
3 | }
4 |
--------------------------------------------------------------------------------
/collections/environments/Local.bru:
--------------------------------------------------------------------------------
1 | vars {
2 | host: http://localhost:3000
3 | }
4 |
--------------------------------------------------------------------------------
/collections/users/Create user.bru:
--------------------------------------------------------------------------------
1 | meta {
2 | name: Create user
3 | type: http
4 | seq: 1
5 | }
6 |
7 | post {
8 | url: {{host}}/users
9 | body: json
10 | auth: none
11 | }
12 |
13 | body:json {
14 | {
15 | "name": "Fulano de Tal",
16 | "email": "fulanodetal@gmail.com",
17 | "password": "password"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/collections/users/Get authenticated user.bru:
--------------------------------------------------------------------------------
1 | meta {
2 | name: Get authenticated user
3 | type: http
4 | seq: 3
5 | }
6 |
7 | get {
8 | url: {{host}}/users/me
9 | body: none
10 | auth: bearer
11 | }
12 |
13 | auth:bearer {
14 | token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJiM2U4OTllMi0zNzM2LTQ2YzQtOTMxNS0zYzI0NTNmMjY4MjEiLCJuYW1lIjoiSm9obiBEb2UiLCJlbWFpbCI6ImpvaG4uZG9lQGdtYWlsLmNvbSIsImV4cCI6MTcyNDYxODU0MywiaWF0IjoxNzI0NjE0OTQzLCJhdWQiOiJQZXRydXNEZU1lbG9ERVYgLSBTZSBpbnNjcmV2YSBubyBjYW5hbCBlIGF0aXZlIG8gc2luaW5obyJ9.1B3fp5wvZqcI40_Js7FSuzxd4NSsp7GdbSCwINqXNs8
15 | }
16 |
--------------------------------------------------------------------------------
/collections/users/Get user by ID.bru:
--------------------------------------------------------------------------------
1 | meta {
2 | name: Get user by ID
3 | type: http
4 | seq: 2
5 | }
6 |
7 | get {
8 | url: {{host}}/users/29a3687c-f4ff-4495-9212-5a5c29922bc9
9 | body: none
10 | auth: none
11 | }
12 |
--------------------------------------------------------------------------------
/infra/.gitignore:
--------------------------------------------------------------------------------
1 | *.d.ts
2 | *.js
3 | node_modules
4 | cdktf.out
5 | cdktf.log
6 | *terraform.*.tfstate*
7 | .gen
8 | .terraform
9 | tsconfig.tsbuildinfo
10 | !jest.config.js
11 | !setup.js
--------------------------------------------------------------------------------
/infra/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all"
4 | }
--------------------------------------------------------------------------------
/infra/cdktf.json:
--------------------------------------------------------------------------------
1 | {
2 | "language": "typescript",
3 | "app": "npx ts-node main.ts",
4 | "projectId": "c09b9f92-73cf-4ad4-9e2d-eafcb3d12db2",
5 | "sendCrashReports": "false",
6 | "terraformProviders": [],
7 | "terraformModules": [],
8 | "context": {
9 |
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/infra/main.ts:
--------------------------------------------------------------------------------
1 | import { App } from 'cdktf';
2 | import { ProjectStack } from './stacks/project.stack';
3 |
4 | const app = new App();
5 |
6 | new ProjectStack(app, 'dev-project-stack', {
7 | environmentName: 'dev',
8 | region: 'us-east-1',
9 | });
10 |
11 | app.synth();
12 |
--------------------------------------------------------------------------------
/infra/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "infra",
3 | "version": "1.0.0",
4 | "main": "main.js",
5 | "types": "main.ts",
6 | "license": "MPL-2.0",
7 | "private": true,
8 | "scripts": {
9 | "get": "cdktf get",
10 | "build": "tsc",
11 | "synth": "cdktf synth",
12 | "compile": "tsc --pretty",
13 | "watch": "tsc -w",
14 | "test": "jest",
15 | "test:watch": "jest --watch",
16 | "upgrade": "npm i cdktf@latest cdktf-cli@latest",
17 | "upgrade:next": "npm i cdktf@next cdktf-cli@next"
18 | },
19 | "engines": {
20 | "node": ">=18.0"
21 | },
22 | "dependencies": {
23 | "@cdktf/provider-aws": "^19.32.0",
24 | "cdktf": "^0.20.8",
25 | "constructs": "^10.3.0"
26 | },
27 | "devDependencies": {
28 | "@types/jest": "^29.5.12",
29 | "@types/node": "^22.5.0",
30 | "jest": "^29.7.0",
31 | "ts-jest": "^29.2.5",
32 | "ts-node": "^10.9.2",
33 | "typescript": "^5.5.4",
34 | "@typescript-eslint/eslint-plugin": "^7.0.0",
35 | "@typescript-eslint/parser": "^7.0.0",
36 | "eslint": "^8.42.0",
37 | "eslint-config-prettier": "^9.0.0",
38 | "eslint-plugin-prettier": "^5.0.0",
39 | "prettier": "^3.0.0"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/infra/stacks/project.stack.ts:
--------------------------------------------------------------------------------
1 | import { DataAwsIamPolicyDocument } from '@cdktf/provider-aws/lib/data-aws-iam-policy-document';
2 | import { DynamodbTable } from '@cdktf/provider-aws/lib/dynamodb-table';
3 | import { ElasticBeanstalkApplication } from '@cdktf/provider-aws/lib/elastic-beanstalk-application';
4 | import { ElasticBeanstalkEnvironment } from '@cdktf/provider-aws/lib/elastic-beanstalk-environment';
5 | import { IamInstanceProfile } from '@cdktf/provider-aws/lib/iam-instance-profile';
6 | import { IamPolicy } from '@cdktf/provider-aws/lib/iam-policy';
7 | import { IamRole } from '@cdktf/provider-aws/lib/iam-role';
8 | import { IamRolePolicyAttachment } from '@cdktf/provider-aws/lib/iam-role-policy-attachment';
9 | import { IamUser } from '@cdktf/provider-aws/lib/iam-user';
10 | import { IamUserPolicyAttachment } from '@cdktf/provider-aws/lib/iam-user-policy-attachment';
11 | import { AwsProvider } from '@cdktf/provider-aws/lib/provider';
12 | import { TerraformOutput, TerraformStack } from 'cdktf';
13 | import { Construct } from 'constructs';
14 |
15 | interface ProjectStackProps {
16 | environmentName: string;
17 | region: string;
18 | }
19 |
20 | export class ProjectStack extends TerraformStack {
21 | constructor(scope: Construct, id: string, props: ProjectStackProps) {
22 | super(scope, id);
23 |
24 | const { environmentName, region } = props;
25 |
26 | new AwsProvider(this, 'aws', {
27 | region,
28 | });
29 |
30 | const dynamodbTable = new DynamodbTable(this, 'users_table', {
31 | name: `${environmentName}-users`,
32 | hashKey: 'id',
33 | attribute: [
34 | {
35 | name: 'id',
36 | type: 'S',
37 | },
38 | {
39 | name: 'email',
40 | type: 'S',
41 | },
42 | ],
43 | billingMode: 'PAY_PER_REQUEST',
44 | globalSecondaryIndex: [
45 | {
46 | name: 'email_index',
47 | hashKey: 'email',
48 | projectionType: 'ALL',
49 | },
50 | ],
51 | });
52 |
53 | /**
54 | * Application Permissions
55 | */
56 |
57 | const trustedPolicyDocument = new DataAwsIamPolicyDocument(
58 | this,
59 | 'trusted_policy_document',
60 | {
61 | statement: [
62 | {
63 | effect: 'Allow',
64 | actions: ['sts:AssumeRole'],
65 | principals: [
66 | {
67 | type: 'Service',
68 | identifiers: ['ec2.amazonaws.com'],
69 | },
70 | ],
71 | },
72 | ],
73 | },
74 | );
75 |
76 | const permissionsPolicyDocument = new DataAwsIamPolicyDocument(
77 | this,
78 | 'permissions_policy_document',
79 | {
80 | statement: [
81 | {
82 | effect: 'Allow',
83 | actions: ['dynamodb:*'],
84 | resources: [`${dynamodbTable.arn}*`],
85 | },
86 | ],
87 | },
88 | );
89 |
90 | const iamPolicy = new IamPolicy(this, 'iam_policy', {
91 | name: `${environmentName}_application_policy`,
92 | policy: permissionsPolicyDocument.json,
93 | });
94 |
95 | const iamRole = new IamRole(this, 'iam_role', {
96 | assumeRolePolicy: trustedPolicyDocument.json,
97 | name: `${environmentName}_application_role`,
98 | });
99 |
100 | new IamRolePolicyAttachment(this, 'iam_role_policy_attachment', {
101 | policyArn: iamPolicy.arn,
102 | role: iamRole.name,
103 | });
104 |
105 | const managedPolicies = [
106 | 'arn:aws:iam::aws:policy/AdministratorAccess-AWSElasticBeanstalk',
107 | 'arn:aws:iam::aws:policy/AWSElasticBeanstalkMulticontainerDocker',
108 | 'arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier',
109 | 'arn:aws:iam::aws:policy/AWSElasticBeanstalkWorkerTier',
110 | 'arn:aws:iam::aws:policy/AmazonEC2FullAccess',
111 | ];
112 |
113 | managedPolicies.forEach((policyArn, index) => {
114 | new IamRolePolicyAttachment(this, `role-policy-attachment-${index}`, {
115 | role: iamRole.name,
116 | policyArn: policyArn,
117 | });
118 | });
119 |
120 | /**
121 | * Application Elastic Beanstalk
122 | */
123 |
124 | const application = new ElasticBeanstalkApplication(this, 'application', {
125 | description: 'api-rest-do-zero-a-aws-com-terraform',
126 | name: `${environmentName}-api-rest-do-zero-a-aws-com-terraform`,
127 | appversionLifecycle: {
128 | serviceRole: iamRole.arn,
129 | deleteSourceFromS3: true,
130 | },
131 | });
132 |
133 | const instanceProfile = new IamInstanceProfile(this, 'instance_profile', {
134 | name: 'aws-elasticbeanstalk-ec2-role',
135 | role: iamRole.name,
136 | });
137 |
138 | const environment = new ElasticBeanstalkEnvironment(this, 'environment', {
139 | tier: 'WebServer',
140 | application: application.name,
141 | name: `${environmentName}-api-rest-do-zero-a-aws-com-terraform`,
142 | solutionStackName: '64bit Amazon Linux 2023 v6.2.0 running Node.js 20',
143 | tags: {
144 | Name: 'api-rest-do-zero-a-aws-com-terraform',
145 | Environment: environmentName,
146 | },
147 | setting: [
148 | {
149 | namespace: 'aws:autoscaling:launchconfiguration',
150 | name: 'IamInstanceProfile',
151 | value: instanceProfile.name,
152 | },
153 | {
154 | namespace: 'aws:elasticbeanstalk:cloudwatch:logs',
155 | name: 'StreamLogs',
156 | value: 'true',
157 | },
158 | {
159 | namespace: 'aws:elasticbeanstalk:cloudwatch:logs',
160 | name: 'RetentionInDays',
161 | value: '7',
162 | },
163 | {
164 | namespace: 'aws:elasticbeanstalk:cloudwatch:logs',
165 | name: 'DeleteOnTerminate',
166 | value: 'false',
167 | },
168 | {
169 | namespace: 'aws:elasticbeanstalk:application:environment',
170 | name: 'ENVIRONMENT',
171 | value: environmentName,
172 | },
173 | {
174 | namespace: 'aws:elasticbeanstalk:application:environment',
175 | name: 'NO_COLOR',
176 | value: 'true',
177 | },
178 | {
179 | namespace: 'aws:elasticbeanstalk:application:environment',
180 | name: 'AWS_REGION',
181 | value: region,
182 | },
183 | ],
184 | });
185 |
186 | new TerraformOutput(this, 'environment_url', {
187 | value: environment.endpointUrl,
188 | });
189 |
190 | /**
191 | * Github Actions User and Permissions
192 | */
193 |
194 | const githubActionsUser = new IamUser(this, 'github_actions_user', {
195 | name: `${environmentName}-github-actions`,
196 | });
197 |
198 | const githubActionsPolicyDocument = new DataAwsIamPolicyDocument(
199 | this,
200 | 'github_actions_policy_document',
201 | {
202 | statement: [
203 | {
204 | effect: 'Allow',
205 | actions: ['*'],
206 | resources: ['*'],
207 | },
208 | ],
209 | },
210 | );
211 |
212 | const iamPolicyGithubActions = new IamPolicy(
213 | this,
214 | 'iam_policy_github_actions',
215 | {
216 | name: `${environmentName}_github_actions_policy`,
217 | policy: githubActionsPolicyDocument.json,
218 | },
219 | );
220 |
221 | new IamUserPolicyAttachment(
222 | this,
223 | 'iam_role_policy_attachment_github_actions',
224 | {
225 | policyArn: iamPolicyGithubActions.arn,
226 | user: githubActionsUser.name,
227 | },
228 | );
229 | }
230 | }
231 |
--------------------------------------------------------------------------------
/infra/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "alwaysStrict": true,
4 | "declaration": true,
5 | "experimentalDecorators": true,
6 | "inlineSourceMap": true,
7 | "inlineSources": true,
8 | "lib": [
9 | "es2018"
10 | ],
11 | "module": "CommonJS",
12 | "noEmitOnError": true,
13 | "noFallthroughCasesInSwitch": true,
14 | "noImplicitAny": true,
15 | "noImplicitReturns": true,
16 | "noImplicitThis": true,
17 | "noUnusedLocals": true,
18 | "noUnusedParameters": true,
19 | "resolveJsonModule": true,
20 | "strict": true,
21 | "strictNullChecks": true,
22 | "strictPropertyInitialization": true,
23 | "stripInternal": true,
24 | "target": "ES2018",
25 | "incremental": true,
26 | "skipLibCheck": true
27 | },
28 | "include": [
29 | "**/*.ts"
30 | ],
31 | "exclude": [
32 | "node_modules",
33 | "cdktf.out"
34 | ]
35 | }
--------------------------------------------------------------------------------