├── .dockerignore ├── .env ├── .env.development ├── .env.prepro ├── .env.production ├── .env.qa ├── .env.test ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── Dockerfile ├── Makefile ├── README.md ├── __test__ ├── controllers │ └── movie.controller.spec.ts ├── dtos │ └── movie.dto.spec.ts ├── e2e │ └── movie.e2e-spec.ts ├── factories │ ├── index.ts │ └── movie.factory.ts ├── jest-e2e.json ├── readme.md └── services │ └── movie.service.spec.ts ├── docker-compose.yml ├── jest.config.js ├── k8s ├── .helmignore ├── .sops.yaml ├── Chart.lock ├── Chart.yaml ├── charts │ └── mongodb-11.0.3.tgz ├── secrets.yaml ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── hpa.yaml │ ├── ingress.yaml │ ├── secret.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ └── tests │ │ └── test-connection.yaml └── values.yaml ├── local.Dockerfile ├── package.json ├── scripts ├── bootstrap.sh ├── config_registry.sh └── docker-entrypoint.sh ├── seeder-cli.json ├── skaffold.yaml ├── src ├── application │ ├── README.md │ ├── controllers │ │ ├── README.md │ │ ├── index.ts │ │ └── movie.controller.ts │ └── dtos │ │ ├── README.md │ │ ├── index.ts │ │ └── movie │ │ ├── index.ts │ │ ├── movie-create.dto.ts │ │ └── movie-get.dto.ts ├── domain │ ├── README.md │ ├── entities │ │ ├── index.ts │ │ └── movie.schema.ts │ ├── enums │ │ ├── categories.enum.ts │ │ ├── entities.enum.ts │ │ └── index.ts │ ├── helpers │ │ ├── env.helpers.ts │ │ └── index.ts │ ├── modules │ │ ├── index.ts │ │ ├── movie-seeder.module.ts │ │ └── movie.module.ts │ └── services │ │ ├── README.md │ │ ├── index.ts │ │ ├── movie-seeder.service.ts │ │ └── movie.service.ts ├── infrastructure │ ├── README.md │ ├── config │ │ ├── env.objects.ts │ │ ├── env.validation.ts │ │ └── index.ts │ ├── database │ │ └── orm │ │ │ └── index.ts │ ├── modules │ │ ├── app.module.ts │ │ ├── index.ts │ │ └── seeder.module.ts │ └── repositories │ │ ├── README.md │ │ ├── index.ts │ │ └── movie.repository.ts ├── main.ts └── seeder.ts ├── tsconfig.build.json ├── tsconfig.json ├── webpack-hmr.config.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !.babelrc 4 | !.env* 5 | !bootstrap.sh 6 | !jest* 7 | !__test__/ 8 | !src/ 9 | !scripts/ 10 | !yarn-offline-mirror/ 11 | !package.json 12 | !yarn.lock 13 | !.yarnrc 14 | !tsconfig* 15 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Available environments 2 | # - develop 3 | # - prepro 4 | # - test 5 | # - qa 6 | # - production 7 | 8 | NODE_PORT= 9 | PORT= 10 | 11 | # -- MONGO -- 12 | MONGO_HOST= 13 | MONGO_USER= 14 | MONGO_PASS= 15 | MONGO_PORT= 16 | MONGO_DB_NAME= 17 | MONGO_CLIENT_URL= 18 | MONGO_AUTH_DB= 19 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE OR IN ANY OTHER COMMITTED FILES. 2 | 3 | NODE_ENV=development 4 | 5 | NODE_PORT=5000 6 | PORT=5000 7 | 8 | # -- MONGO -- 9 | MONGO_HOST=mongo 10 | MONGO_USER=root 11 | MONGO_PASS=123123 12 | MONGO_PORT=27017 13 | MONGO_DB_NAME=cinema 14 | MONGO_CLIENT_URL=mongodb://root:123123@mongo 15 | MONGO_AUTH_DB=admin 16 | -------------------------------------------------------------------------------- /.env.prepro: -------------------------------------------------------------------------------- 1 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE OR IN ANY OTHER COMMITTED FILES. 2 | 3 | NODE_ENV=prepro 4 | 5 | NODE_PORT=5000 6 | PORT=5000 7 | 8 | # -- MONGO -- 9 | MONGO_HOST=cluster0.1rtnj.mongodb.net 10 | MONGO_USER=root 11 | MONGO_PASS=7654321 12 | MONGO_PORT=27017 13 | MONGO_DB_NAME=test 14 | MONGO_CLIENT_URL=mongodb+srv://root:7654321@cluster0.1rtnj.mongodb.net 15 | MONGO_AUTH_DB=admin 16 | 17 | # -- KAFKA -- 18 | KAFKA_CLIENT=ms_boilerplate 19 | KAFKA_HOST=kafka:19092 20 | KAFKA_GROUP=importers 21 | 22 | # -- SCHEMA_REGISTRY -- 23 | SCHEMA_REGISTRY_CLIENT=http://schema-registry:8081 24 | SCHEMA_REGISTRY_USER= 25 | SCHEMA_REGISTRY_PASS= 26 | PRODUCTS_SCHEMA=2 27 | ACTIONS_SCHEMA=1 28 | JOBDATA_SCHEMA=3 29 | ERRORS_SCHEMA=4 -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE OR IN ANY OTHER COMMITTED FILES. 2 | 3 | NODE_ENV=production 4 | 5 | NODE_PORT=5000 6 | PORT=5000 7 | 8 | # -- MONGO -- 9 | MONGO_HOST=cluster0.1rtnj.mongodb.net 10 | MONGO_USER=root 11 | MONGO_PASS=7654321 12 | MONGO_PORT=27017 13 | MONGO_DB_NAME=test 14 | MONGO_CLIENT_URL=mongodb+srv://root:7654321@cluster0.1rtnj.mongodb.net 15 | MONGO_AUTH_DB=admin 16 | 17 | # -- KAFKA -- 18 | KAFKA_CLIENT=ms_boilerplate 19 | KAFKA_HOST=kafka:19092 20 | KAFKA_GROUP=importers 21 | 22 | # -- SCHEMA_REGISTRY -- 23 | SCHEMA_REGISTRY_CLIENT=http://schema-registry:8081 24 | SCHEMA_REGISTRY_USER= 25 | SCHEMA_REGISTRY_PASS= 26 | PRODUCTS_SCHEMA=2 27 | ACTIONS_SCHEMA=1 28 | JOBDATA_SCHEMA=3 29 | ERRORS_SCHEMA=4 -------------------------------------------------------------------------------- /.env.qa: -------------------------------------------------------------------------------- 1 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE OR IN ANY OTHER COMMITTED FILES. 2 | 3 | NODE_ENV=qa 4 | 5 | NODE_PORT=5000 6 | PORT=5000 7 | 8 | # -- MONGO -- 9 | MONGO_HOST=cluster0.1rtnj.mongodb.net 10 | MONGO_USER=root 11 | MONGO_PASS=7654321 12 | MONGO_PORT=27017 13 | MONGO_DB_NAME=test 14 | MONGO_CLIENT_URL=mongodb+srv://root:7654321@cluster0.1rtnj.mongodb.net 15 | MONGO_AUTH_DB=admin 16 | 17 | # -- KAFKA -- 18 | KAFKA_CLIENT=ms_boilerplate 19 | KAFKA_HOST=kafka:19092 20 | KAFKA_GROUP=importers 21 | 22 | # -- SCHEMA_REGISTRY -- 23 | SCHEMA_REGISTRY_CLIENT=http://schema-registry:8081 24 | SCHEMA_REGISTRY_USER= 25 | SCHEMA_REGISTRY_PASS= 26 | PRODUCTS_SCHEMA=2 27 | ACTIONS_SCHEMA=1 28 | JOBDATA_SCHEMA=3 29 | ERRORS_SCHEMA=4 -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE OR IN ANY OTHER COMMITTED FILES. 2 | 3 | NODE_ENV=test 4 | 5 | NODE_PORT=5000 6 | PORT=5000 7 | 8 | # -- MONGO -- 9 | MONGO_HOST=mongo 10 | MONGO_USER=root 11 | MONGO_PASS=7654321 12 | MONGO_PORT=27017 13 | MONGO_DB_NAME=test 14 | MONGO_CLIENT_URL=mongodb+srv://root:7654321@cluster0.1rtnj.mongodb.net 15 | MONGO_AUTH_DB=admin 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint", "prettier"], 4 | "env": { 5 | "es6": true, 6 | "node": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:prettier/recommended", 12 | "prettier/@typescript-eslint" 13 | ], 14 | "globals": { 15 | "process": true, 16 | "console": true, 17 | "Atomics": "readonly", 18 | "SharedArrayBuffer": "readonly" 19 | }, 20 | "parserOptions": { 21 | "ecmaVersion": 2020, 22 | "sourceType": "module" 23 | }, 24 | "rules": { 25 | "prettier/prettier": "error", 26 | "max-len": [ 27 | "error", 28 | { 29 | "code": 120, 30 | "ignoreUrls": true, 31 | "ignoreComments": false 32 | } 33 | ], 34 | "no-unused-vars": 1, 35 | "no-case-declarations": "off", 36 | "no-async-promise-executor": "off", 37 | "@typescript-eslint/ban-types": [ 38 | "error", 39 | { 40 | "types": { 41 | "{}": { 42 | "message": "Use object instead", 43 | "fixWith": "object" 44 | } 45 | } 46 | } 47 | ], 48 | "@typescript-eslint/no-explicit-any": "off" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist 3 | dist/** 4 | boilerplate_data/ 5 | boilerplate_data/** 6 | *.log 7 | */*.log 8 | docker-compose.override.yml 9 | 10 | # Jest coverage files 11 | coverage/ 12 | coverage/** 13 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/fermium -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | printWidth: 120, 7 | arrowParens: "avoid" 8 | }; 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:fermium-alpine AS environment 2 | 3 | ARG MS_HOME=/app 4 | ENV MS_HOME="${MS_HOME}" 5 | 6 | ENV MS_SCRIPTS="${MS_HOME}/scripts" 7 | 8 | ENV USER_NAME=node USER_UID=1000 GROUP_NAME=node GROUP_UID=1000 9 | 10 | WORKDIR "${MS_HOME}" 11 | 12 | # Build 13 | FROM environment AS develop 14 | 15 | COPY ["./package.json", "./yarn.lock", "${MS_HOME}/"] 16 | COPY ["./scripts/config_registry.sh", "/tmp/config_registry"] 17 | 18 | RUN \ 19 | # Config NPM Registry 20 | chmod a+x "/tmp/config_registry" \ 21 | && "/tmp/config_registry" \ 22 | # Packages needed for compilation 23 | && apk --update add --no-cache --virtual .gyp python3 py3-pip make g++ \ 24 | # Install dev dependencies 25 | && yarn install --frozen-lockfile --network-timeout 100000 \ 26 | # Clean up 27 | && apk del .gyp 28 | 29 | FROM develop AS builder 30 | 31 | COPY . "${MS_HOME}" 32 | 33 | RUN PATH="$(yarn bin)":${PATH} \ 34 | && yarn test:ci \ 35 | && yarn build \ 36 | # Clean up dependencies for production image 37 | && yarn install --frozen-lockfile --network-timeout 100000 --production 38 | 39 | # Serve 40 | FROM environment AS serve 41 | 42 | COPY ["./scripts/docker-entrypoint.sh", "/usr/local/bin/entrypoint"] 43 | COPY ["./scripts/bootstrap.sh", "/usr/local/bin/bootstrap"] 44 | COPY --from=builder "${MS_HOME}/node_modules" "${MS_HOME}/node_modules" 45 | COPY --from=builder "${MS_HOME}/dist" "${MS_HOME}/dist" 46 | COPY --from=builder "${MS_HOME}/.env*" "${MS_HOME}/" 47 | 48 | RUN \ 49 | # Packages needed during runtime 50 | apk --update add --no-cache tini bash \ 51 | # Leftover from base image 52 | && deluser --remove-home node \ 53 | # Assign new user 54 | && addgroup -g ${GROUP_UID} -S ${GROUP_NAME} \ 55 | && adduser -D -S -s /sbin/nologin -u ${USER_UID} -G ${GROUP_NAME} "${USER_NAME}" \ 56 | # Change ownership of app folder 57 | && chown -R "${USER_NAME}:${GROUP_NAME}" "${MS_HOME}/" \ 58 | && chmod a+x \ 59 | "/usr/local/bin/entrypoint" \ 60 | "/usr/local/bin/bootstrap" \ 61 | && rm -rf \ 62 | "/usr/local/lib/node_modules" \ 63 | "/usr/local/bin/npm" \ 64 | "/usr/local/bin/npx" \ 65 | "/usr/local/bin/yarn" \ 66 | "/usr/local/bin/yarnpkg" \ 67 | "/usr/local/bin/docker-entrypoint.sh" 68 | 69 | USER "${USER_NAME}" 70 | 71 | EXPOSE 3030 72 | 73 | ENTRYPOINT [ "/sbin/tini", "--", "/usr/local/bin/entrypoint" ] 74 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: skaffold-dev 2 | skaffold-dev: 3 | skaffold dev --auto-build --auto-deploy --tail --cleanup 4 | 5 | .PHONY: skaffold-debug 6 | skaffold-debug: 7 | skaffold debug --auto-build --auto-deploy --tail --cleanup 8 | 9 | .PHONY: encrypt-secrets 10 | encrypt-secrets: 11 | helm secrets enc k8s/secrets.yaml 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nest + Mongoose + TypeScript example integration 2 | 3 | 1. Install dependencies via `yarn` 4 | 2. Run `docker-compose up -d` to start mongodb 5 | 3. Run via `yarn start` or `yarn start:dev` (watch mode) 6 | 4. Run via `yarn daemon` for reload app in any change (nodemon) 7 | 5. Example API is running on localhost:3000 8 | 9 | # Available routes: 10 | 11 | |method|endpoint|description|params| 12 | |:-----|:-----|:-----|:-----| 13 | |GET|/author|finds all authors| | 14 | |GET|/author/:id|finds author by id| | 15 | |POST|/author|creates new author| | 16 | |PUT|/author/:id|updates author by id| | 17 | 18 | |method|endpoint|description|params| 19 | |:-----|:-----|:-----|:-----| 20 | |GET|/book|finds all books| | 21 | |GET|/book/:id|finds book by id| | 22 | |POST|/book|creates new book| | 23 | |PUT|/book/:id|updates book by id | 24 | 25 | # Project Structure 26 | ```sh 27 | src 28 | |_ application 29 | |_ controllers 30 | |_ dtos 31 | |_ domain 32 | |_ entities 33 | |_ services 34 | |_ infrastructure 35 | |_ repositories 36 | 37 | ``` 38 | ### - Application 39 | 40 | This layer should limit your responsibilities to the following tasks: 41 | 42 | 1. Execute access control policies (authentication, authorization, etc.) 43 | 2. Validating user input 44 | 3. Send calls or commands to the appropriate service method 45 | 4. Transform entities returned by the service into data transfer objects (DTO) for output / serialization 46 | 47 | ###### Controllers 48 | It receives the requests made to the server and uses the ***services*** to send responses to the client. 49 | 50 | 51 | ###### DTOs 52 | As its name indicates, it is an object that will be used to ***transfer information*** and represents the object that will be sent to the client, this is the object that our API will return to the rest of the services, either For internal use or for third parties, so we can have multiple DTOs for each entity according to the use we need. 53 | It is also used to define the type of objects to be received by the controllers 54 | 55 | - The DTO should have only data, ***should not to have any type of business logic***. 56 | - May have references to other DTOs 57 | 58 | ### - Domain 59 | Contains all domain level concerns, this includes ***business logic***, and domain objects (Entities) 60 | >Transformation to DTOs should be done exclusively at the edge (our controllers), because that is where serialization happens and also because, depending on our project requirements, several controllers or services can call these methods and they will want to deal with the purest form of the data. 61 | 62 | ###### Entities 63 | Represents an object in the database and encapsulates key rules related to that object, so it can contain some logic to ***validate its properties*** but ***not*** complex business logic. 64 | 65 | - An entity must always represent the ***object in the database***, so it must not have more or less properties than the object it represents. 66 | 67 | ###### Services 68 | It contains the business logic which provides controllers (or other services) to be used. 69 | 70 | - Your methods can receive both ***Entities*** and ***Data*** 71 | - They should always return ***entities*** that will be converted into ***DTOs*** by the controller 72 | 73 | ### - Infraestructure 74 | ###### Repositories 75 | 76 | >According to Martin Fowler, the Repository Pattern can be described as: 77 | Mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects. 78 | So a repository will generally have methods such as findOne, findAll, remove, and such. Your service layer will manipulate the collection through these repository methods. Their implementation is not a concern to our business logic. 79 | 80 | The repository is an intermediary between the domain and the data source and ***provides the services with the basic extraction operations (CRUD)*** (findOne, findAll, updateOne, remove). 81 | 82 | When using TypeOrm, the CRUD methods are injected by the ORM to our repository, being able to define more specific methods (that do not imply business logic) in the repository file. 83 | -------------------------------------------------------------------------------- /__test__/controllers/movie.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { validate } from 'class-validator'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { Movie } from '../../src/domain/entities'; 4 | import { MovieController } from './../../src/application/controllers/movie.controller'; 5 | import { MovieService } from '../../src/domain/services/movie.service'; 6 | import { MovieCreateDTO, MovieGetDTO } from '../../src/application/dtos/movie'; 7 | import { fakeMovie } from '../factories'; 8 | 9 | describe('Movie Controller', () => { 10 | let movieService: jest.Mocked; 11 | let movieController: MovieController; 12 | 13 | const movieServiceMock: Partial = { 14 | findAll: jest.fn(), 15 | create: jest.fn(), 16 | }; 17 | 18 | beforeEach(async () => { 19 | const module: TestingModule = await Test.createTestingModule({ 20 | providers: [ 21 | { 22 | provide: MovieService, 23 | useValue: movieServiceMock, 24 | }, 25 | MovieController, 26 | ], 27 | }).compile(); 28 | 29 | movieService = module.get(MovieService); 30 | movieController = module.get(MovieController); 31 | }); 32 | 33 | describe('Controller status', () => { 34 | it('Should be defined', () => { 35 | expect(movieController).toBeDefined(); 36 | }); 37 | }); 38 | 39 | describe('findAll', () => { 40 | it('findAll should return valid DTOs', async () => { 41 | const movie: Movie = new MovieGetDTO(fakeMovie); 42 | const movies: Movie[] = [movie]; 43 | movieService.findAll.mockResolvedValue(movies); 44 | const dtos = await movieController.findAll(); 45 | 46 | dtos.map(async dto => { 47 | const errors = await validate(dto, { 48 | whitelist: true, 49 | }); 50 | expect(errors).toHaveLength(1); 51 | expect(dto).toBeInstanceOf(MovieGetDTO); 52 | }); 53 | }); 54 | }); 55 | 56 | describe('create', () => { 57 | it('New movie should be an DTO instance', async () => { 58 | const movie: Movie = new MovieGetDTO(fakeMovie); 59 | movieService.create.mockResolvedValue(movie); 60 | 61 | const result = await movieController.create(movie as MovieCreateDTO); 62 | 63 | expect(result).toBeInstanceOf(MovieGetDTO); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /__test__/dtos/movie.dto.spec.ts: -------------------------------------------------------------------------------- 1 | import { validate, ValidationError } from 'class-validator'; 2 | import { plainToClass } from 'class-transformer'; 3 | import { MovieCreateDTO } from '../../src/application/dtos/movie/movie-create.dto'; 4 | import { MovieGetDTO } from '../../src/application/dtos/movie'; 5 | import { fakeMovie, fakeMovieMaxLength, fakeMovieDTO, fakeMovieCreateDTO } from './../factories/movie.factory'; 6 | 7 | describe('Movie DTO', () => { 8 | describe('Movie create', () => { 9 | it('Requires [title] property', async () => { 10 | const data = { 11 | url: fakeMovie.url, 12 | }; 13 | const dto = plainToClass(MovieCreateDTO, data); 14 | 15 | const errors = await validate(dto); 16 | const [error] = errors; 17 | 18 | expect(errors).toHaveLength(1); 19 | expect(error).toBeInstanceOf(ValidationError); 20 | expect(error.property).toBe('title'); 21 | }); 22 | 23 | it('Requires [url] property', async () => { 24 | const data = { 25 | title: fakeMovie.url, 26 | }; 27 | const dto = plainToClass(MovieCreateDTO, data); 28 | 29 | const errors = await validate(dto); 30 | const [error] = errors; 31 | 32 | expect(errors).toHaveLength(1); 33 | expect(error).toBeInstanceOf(ValidationError); 34 | expect(error.property).toBe('url'); 35 | }); 36 | 37 | it('Failed with max length', async () => { 38 | const dto = plainToClass(MovieCreateDTO, fakeMovieMaxLength); 39 | 40 | const errors = await validate(dto); 41 | 42 | expect(errors).toHaveLength(2); 43 | 44 | errors && 45 | errors.map(error => { 46 | expect(error).toBeInstanceOf(ValidationError); 47 | }); 48 | }); 49 | 50 | it('Succes with a valid properties', async () => { 51 | const errors = await validate(fakeMovieCreateDTO); 52 | 53 | expect(errors).toHaveLength(0); 54 | }); 55 | }); 56 | 57 | describe('Movie get', () => { 58 | it('Success with a valid properties', async () => { 59 | const errors = await validate(fakeMovieDTO); 60 | 61 | expect(errors).toHaveLength(1); 62 | }); 63 | 64 | it('Requires [id] property', async () => { 65 | const { _id, ...data } = fakeMovie; 66 | const dto = plainToClass(MovieGetDTO, data); 67 | 68 | const errors = await validate(dto); 69 | const [error] = errors; 70 | 71 | expect(errors).toHaveLength(1); 72 | expect(error).toBeInstanceOf(ValidationError); 73 | expect(error.property).toBe('_id'); 74 | }); 75 | 76 | it('Requires [title] property', async () => { 77 | const { title, ...data } = fakeMovie; 78 | const dto = plainToClass(MovieGetDTO, data); 79 | 80 | const errors = await validate(dto); 81 | const [error] = errors; 82 | 83 | expect(errors).toHaveLength(1); 84 | expect(error).toBeInstanceOf(ValidationError); 85 | expect(error.property).toBe('title'); 86 | }); 87 | 88 | it('Requires [url] property', async () => { 89 | const { url, ...data } = fakeMovie; 90 | const dto = plainToClass(MovieGetDTO, data); 91 | 92 | const errors = await validate(dto); 93 | const [error] = errors; 94 | 95 | expect(errors).toHaveLength(1); 96 | expect(error).toBeInstanceOf(ValidationError); 97 | expect(error.property).toBe('url'); 98 | }); 99 | 100 | it('Requires [categories] property', async () => { 101 | const { categories, ...data } = fakeMovie; 102 | const dto = plainToClass(MovieGetDTO, data); 103 | 104 | const errors = await validate(dto); 105 | const [error] = errors; 106 | 107 | expect(errors).toBeDefined(); 108 | expect(error).toBeInstanceOf(ValidationError); 109 | expect(error.property).toBe('categories'); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /__test__/e2e/movie.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | import { MovieController } from '../../src/application/controllers'; 5 | import { MovieService } from '../../src/domain/services/movie.service'; 6 | import { fakeMovie, fakeMovieCreateDTO } from '../factories/movie.factory'; 7 | import { MovieRepository } from '../../src/infrastructure/repositories'; 8 | 9 | describe('Movie endpoints (e2e)', () => { 10 | let app: INestApplication; 11 | 12 | beforeAll(async () => { 13 | const mockRepository = { 14 | findByTitle: jest.fn(() => []), 15 | findAll: jest.fn(() => [fakeMovie]), 16 | persist: jest.fn(() => Promise.resolve(fakeMovieCreateDTO)), 17 | }; 18 | 19 | const module: TestingModule = await Test.createTestingModule({ 20 | imports: [], 21 | controllers: [MovieController], 22 | providers: [ 23 | MovieService, 24 | { 25 | provide: MovieRepository, 26 | useValue: mockRepository, 27 | }, 28 | ], 29 | }).compile(); 30 | 31 | app = module.createNestApplication(); 32 | await app.init(); 33 | }); 34 | 35 | describe('Check findAll method', () => { 36 | it('Should received status code 200', () => { 37 | return request(app.getHttpServer()).get('/movies').expect(200); 38 | }); 39 | 40 | it('Should received an array of movies', async () => { 41 | const { body } = await request(app.getHttpServer()).get('/movies'); 42 | 43 | expect(body).toHaveLength(1); 44 | }); 45 | }); 46 | 47 | describe('Check create method', () => { 48 | it('Should received status code 201', () => { 49 | console.error(fakeMovieCreateDTO); 50 | console.error(fakeMovieCreateDTO); 51 | return request(app.getHttpServer()) 52 | .post('/movies') 53 | .send(fakeMovieCreateDTO) 54 | .set('Accept', 'application/json') 55 | .expect(201); 56 | }); 57 | 58 | it('Should create new movie and received it in the response object', async () => { 59 | const { body } = await request(app.getHttpServer()) 60 | .post('/movies') 61 | .send(fakeMovieCreateDTO) 62 | .set('Accept', 'application/json'); 63 | 64 | const { title, url } = fakeMovieCreateDTO; 65 | 66 | expect(body).toMatchObject({ 67 | title, 68 | url, 69 | }); 70 | }); 71 | }); 72 | 73 | afterAll(async () => { 74 | await app.close(); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /__test__/factories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './movie.factory'; 2 | -------------------------------------------------------------------------------- /__test__/factories/movie.factory.ts: -------------------------------------------------------------------------------- 1 | import { name, date, internet, lorem, random } from 'faker'; 2 | import { v4 as uuid } from 'uuid'; 3 | import { MovieCreateDTO, MovieGetDTO } from '../../src/application/dtos/movie'; 4 | import { Movie } from '../../src/domain/entities'; 5 | import { Categories } from '../../src/domain/enums'; 6 | 7 | const MaxLengthName = 50; 8 | const MaxLengthDescription = 100; 9 | 10 | export const fakeMovie: Movie = { 11 | _id: uuid(), 12 | title: name.firstName(), 13 | url: internet.url(), 14 | description: lorem.word(MaxLengthDescription), 15 | categories: new Set( 16 | random.arrayElements([ 17 | Categories.Action, 18 | Categories.Comedy, 19 | Categories.Drama, 20 | Categories.Mistery, 21 | Categories.Romance, 22 | Categories.Thriller, 23 | ]) 24 | ), 25 | publishDate: date.past(), 26 | }; 27 | 28 | export const fakeMovieCreate: MovieCreateDTO = { 29 | title: name.firstName(), 30 | url: internet.url(), 31 | description: lorem.word(MaxLengthDescription), 32 | categories: new Set( 33 | random.arrayElements([ 34 | Categories.Action, 35 | Categories.Comedy, 36 | Categories.Drama, 37 | Categories.Mistery, 38 | Categories.Romance, 39 | Categories.Thriller, 40 | ]) 41 | ), 42 | publishDate: date.past(), 43 | }; 44 | 45 | export const fakeMovieMaxLength: MovieCreateDTO = { 46 | title: lorem.word(MaxLengthName + 1), 47 | url: lorem.word(MaxLengthDescription + 1), 48 | categories: new Set( 49 | random.arrayElements([ 50 | Categories.Action, 51 | Categories.Comedy, 52 | Categories.Drama, 53 | Categories.Mistery, 54 | Categories.Romance, 55 | Categories.Thriller, 56 | ]) 57 | ), 58 | }; 59 | 60 | export const fakeMovieDTO = new MovieGetDTO(fakeMovie); 61 | export const fakeMovieCreateDTO = new MovieCreateDTO(fakeMovieCreate); 62 | -------------------------------------------------------------------------------- /__test__/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": [ 3 | "js", 4 | "json", 5 | "ts" 6 | ], 7 | "rootDir": ".", 8 | "testEnvironment": "node", 9 | "testRegex": ".e2e-spec.ts$", 10 | "transform": { 11 | "^.+\\.(t|j)s$": "ts-jest" 12 | }, 13 | "coverageReporters": ["json"], 14 | "coverageDirectory": "coverage/e2e", 15 | "collectCoverageFrom": ["src/**"], 16 | "coveragePathIgnorePatterns": [ 17 | ".module.ts$", 18 | ".spec.ts$", 19 | ".e2e-spec.ts$" 20 | ] 21 | } -------------------------------------------------------------------------------- /__test__/readme.md: -------------------------------------------------------------------------------- 1 | # TDD Rules 2 | ### Development process 3 | Successful implementation of TDD depends on the development processes described below. 4 | #### 1- Write the test before writing the implementation code. 5 | This approach enables developers to focus on the requirements and helps to ensure that tests work as quality assurance, not quality checking. 6 | 7 | #### 2- Write new code only when the test is failing. 8 | If tests don’t show the need to modify the implementation code it means either that the test is faulty or that the feature is already implemented. If there are no new features introduced then tests are always passed and therefore useless. 9 | 10 | #### 3- Rerun all tests every time the implementation code changes. 11 | The way developers can ensure code modifications do not lead to unintended results. Tests should be run each time the implementation code is changed. After the code is submitted to version control, developers should perform all tests again to guarantee that no problem will arise due to code changes. This is particularly important when there is more than one developer involved in the project. 12 | #### 4- Pass all tests before writing a new one. 13 | Sometimes developers ignore the problems revealed by existing tests and move towards new functionality. You may want to write several tests before the implementation actually takes place but it is better to resist the temptation. In most cases, this will lead to more problems. 14 | 15 | #### 5- Refactor only after passing the tests. If the possibly affected implementation code passes all tests it can be refactored. 16 | In most cases, it doesn’t require new testing. Small changes to existing tests should be enough. The expected outcome of refactoring is to have all tests passed before and after the code has been changed. 17 | 18 | ### Anatomy 19 | Follow the next recommendations so that your tests are more readable and follow the correct standard of the project. 20 | #### 1- Includes 3 parts when naming each test 21 | 1. **What is being tested?** For example, the ProductsService.addNewProduct method 22 | 2. **Under what circumstances and scenario?** For example, no price is passed to the method 23 | 3. **What is the expected result?** For example, the new product is not approved 24 | ```sh 25 | describe('Products Service', function() { 26 | #1. unit under test 27 | describe('Add new product', function() { 28 | #2. scenario and # 3. expectation 29 | it('When no price is specified, then the product status is pending approval', ()=> { 30 | const newProduct = new ProductService().add(...); 31 | expect(newProduct.status).to.equal('pendingApproval'); 32 | }); 33 | }); 34 | }); 35 | ``` 36 | The result will be something like 37 | ```sh 38 | Author Controller 39 | Controller status 40 | ✓ Should be defined (15 ms) 41 | find authors 42 | ✓ findAll should create a DTO with missing values and catch errors (7 ms) 43 | ``` 44 | #### 2- Structure your tests following the AAA pattern 45 | 1. A - **Arrange**: All the setup code to bring the system to the scenario the test aims to simulate. This might include instantiating the unit under test constructor, adding DB records, mocking/stubbing on objects and any other preparation code 46 | 2. A - **Act**: Execute the unit under test. Usually 1 line of code 47 | 3. A - **Assert**: Ensure that the received value satisfies the expectation. Usually 1 line of code 48 | ```sh 49 | describe("Customer classifier", () => { 50 | test("When customer spent more than 500$, should be classified as premium", () => { 51 | #Arrange 52 | const customerToClassify = { spent: 505, joined: new Date(), id: 1 }; 53 | const DBStub = sinon.stub(dataAccess, "getCustomer").reply({ id: 1, classification: "regular" }); 54 | #Act 55 | const receivedClassification = customerClassifier.classifyCustomer(customerToClassify); 56 | #Assert 57 | expect(receivedClassification).toMatch("premium"); 58 | }); 59 | }); 60 | ``` 61 | 62 | #### 3- Categorize the tests into at least 2 levels. 63 | Applying this structure, the tests are grouped in a much more orderly way, which allows us to clearly know the module that is being tested and which is the scenario or action that we want to check. 64 | A common method for this is to place at least 2 'describe' blocks on top of your tests: 65 | 1. **name of the unit to be test** 66 | 2. additional level of categorization such as **custom stage or categories** 67 | ```sh 68 | # Unity/Service under test 69 | describe("Shop service", () => { 70 | # Scenario 71 | describe("When product is out of stock", () => { 72 | # Expectation 73 | test("Then the response status is 404", () => {}); 74 | # Expectation 75 | test("Then not send it in response", () => {}); 76 | }); 77 | }); 78 | ``` -------------------------------------------------------------------------------- /__test__/services/movie.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { MovieService } from './../../src/domain/services/movie.service'; 3 | import { MovieRepository } from './../../src/infrastructure/repositories'; 4 | import { fakeMovieCreateDTO } from './../factories//movie.factory'; 5 | 6 | describe('Movie Service', () => { 7 | let service: MovieService; 8 | 9 | beforeAll(async () => { 10 | const MovieRepositoryMock = { 11 | findByTitle: jest.fn(() => []), 12 | findAll: jest.fn(async () => ['movie']), 13 | persist: jest.fn(async () => Promise.resolve(fakeMovieCreateDTO)), 14 | }; 15 | 16 | const module: TestingModule = await Test.createTestingModule({ 17 | providers: [ 18 | { 19 | provide: MovieRepository, 20 | useValue: MovieRepositoryMock, 21 | }, 22 | MovieService, 23 | ], 24 | }).compile(); 25 | 26 | service = module.get(MovieService); 27 | }); 28 | 29 | describe('Service status', () => { 30 | it('Should be defined', () => { 31 | expect(service).toBeDefined(); 32 | }); 33 | }); 34 | 35 | describe('Service test', () => { 36 | it('List all movies', async () => { 37 | const result = await service.findAll(); 38 | 39 | expect(result).toHaveLength(1); 40 | }); 41 | 42 | it('Create a new movie', async () => { 43 | const result = await service.create(fakeMovieCreateDTO); 44 | 45 | expect(result).toMatchObject(fakeMovieCreateDTO); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | mongo: 5 | image: mongo:latest 6 | container_name: mongo 7 | restart: always 8 | ports: 9 | - 27017:27017 10 | environment: 11 | - MONGO_INITDB_DATABASE=cinema 12 | - MONGO_INITDB_ROOT_USERNAME=root 13 | - MONGO_INITDB_ROOT_PASSWORD=123123 14 | volumes: 15 | - ./app_data/mongodb:/data/db 16 | mem_limit: '4G' 17 | 18 | app: 19 | build: 20 | context: . 21 | dockerfile: local.Dockerfile 22 | container_name: boilerplate 23 | restart: always 24 | ports: 25 | - '5000:5000' 26 | depends_on: 27 | - mongo 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | roots: ['./'], 5 | transform: { '\\.ts$': ['ts-jest'] }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec|e2e-spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | globals: { 9 | 'ts-jest': { 10 | tsconfig: { 11 | // allow js in typescript 12 | allowJs: true, 13 | }, 14 | }, 15 | }, 16 | coverageProvider: 'v8', 17 | collectCoverageFrom: [ 18 | 'src/**/*.ts', 19 | '!src/main.ts', 20 | '!src/**/*.config.ts', 21 | '!src/application/controllers/*', 22 | '!src/application/interceptors/*', 23 | '!src/domain/entities/**', 24 | '!src/domain/interfaces/*', 25 | '!src/domain/modules/*', 26 | '!src/infrastructure/**', 27 | ], 28 | coverageThreshold: { 29 | global: { 30 | lines: 70, 31 | statements: 70, 32 | }, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /k8s/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /k8s/.sops.yaml: -------------------------------------------------------------------------------- 1 | creation_rules: 2 | - pgp: 'D4BBFAE84A17F786D133185E9CCFC90304A6E509' 3 | 4 | -------------------------------------------------------------------------------- /k8s/Chart.lock: -------------------------------------------------------------------------------- 1 | dependencies: 2 | - name: mongodb 3 | repository: https://charts.bitnami.com/bitnami 4 | version: 11.0.3 5 | digest: sha256:8a85b0933543a3b2f75a910541b197e97bfffeaa74e891bd8859155d04195a84 6 | generated: "2022-02-09T18:15:37.389228711-03:00" 7 | -------------------------------------------------------------------------------- /k8s/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: app 3 | description: A Helm chart for Kubernetes 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.1.0 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | # It is recommended to use it with quotes. 24 | appVersion: "1.16.0" 25 | 26 | 27 | dependencies: 28 | - name: mongodb 29 | version: 11.0.3 30 | repository: https://charts.bitnami.com/bitnami 31 | -------------------------------------------------------------------------------- /k8s/charts/mongodb-11.0.3.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianfiszman/nestjs-onion-architecture-boilerplate/46e8f93ada8cdd34ca0a4b432532e5edc263b0b7/k8s/charts/mongodb-11.0.3.tgz -------------------------------------------------------------------------------- /k8s/secrets.yaml: -------------------------------------------------------------------------------- 1 | secrets: 2 | NODE_ENV: prod 3 | NODE_PORT: '8080' 4 | PORT: '8080' 5 | MONGO_HOST: app-mongodb 6 | MONGO_USER: root 7 | MONGO_PASS: '123123' 8 | MONGO_PORT: '27017' 9 | MONGO_DB_NAME: cinema 10 | MONGO_CLIENT_URL: mongodb://root:123123@app-mongodb 11 | MONGO_AUTH_DB: admin 12 | mongodb-root-password: '123123' 13 | mongodb-passwords: '123123' 14 | -------------------------------------------------------------------------------- /k8s/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services 10 | {{ include "app.fullname" . }}) 11 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 12 | echo http://$NODE_IP:$NODE_PORT 13 | {{- else if contains "LoadBalancer" .Values.service.type }} 14 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 15 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include 16 | "app.fullname" . }}' 17 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "app.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 18 | echo http://$SERVICE_IP:{{ .Values.service.port }} 19 | {{- else if contains "ClusterIP" .Values.service.type }} 20 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "app.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 21 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 22 | echo "Visit http://127.0.0.1:8080 to use your application" 23 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 24 | {{- end }} 25 | -------------------------------------------------------------------------------- /k8s/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "app.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "app.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "app.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "app.labels" -}} 37 | helm.sh/chart: {{ include "app.chart" . }} 38 | {{ include "app.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "app.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "app.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "app.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "app.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /k8s/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "app.fullname" . }} 5 | labels: 6 | {{- include "app.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "app.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "app.selectorLabels" . | nindent 8 }} 22 | spec: 23 | {{- with .Values.imagePullSecrets }} 24 | imagePullSecrets: 25 | {{- toYaml . | nindent 8 }} 26 | {{- end }} 27 | serviceAccountName: {{ include "app.serviceAccountName" . }} 28 | securityContext: 29 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 30 | containers: 31 | - name: {{ .Chart.Name }} 32 | env: 33 | - name: NODE_ENV 34 | valueFrom: 35 | secretKeyRef: 36 | name: app 37 | key: NODE_ENV 38 | - name: NODE_PORT 39 | valueFrom: 40 | secretKeyRef: 41 | name: app 42 | key: NODE_PORT 43 | - name: PORT 44 | valueFrom: 45 | secretKeyRef: 46 | name: app 47 | key: PORT 48 | - name: MONGO_HOST 49 | valueFrom: 50 | secretKeyRef: 51 | name: app 52 | key: MONGO_HOST 53 | - name: MONGO_USER 54 | valueFrom: 55 | secretKeyRef: 56 | name: app 57 | key: MONGO_USER 58 | - name: MONGO_PASS 59 | valueFrom: 60 | secretKeyRef: 61 | name: app 62 | key: MONGO_PASS 63 | - name: MONGO_PORT 64 | valueFrom: 65 | secretKeyRef: 66 | name: app 67 | key: MONGO_PORT 68 | - name: MONGO_DB_NAME 69 | valueFrom: 70 | secretKeyRef: 71 | name: app 72 | key: MONGO_DB_NAME 73 | - name: MONGO_CLIENT_URL 74 | valueFrom: 75 | secretKeyRef: 76 | name: app 77 | key: MONGO_CLIENT_URL 78 | - name: MONGO_AUTH_DB 79 | valueFrom: 80 | secretKeyRef: 81 | name: app 82 | key: MONGO_AUTH_DB 83 | securityContext: 84 | {{- toYaml .Values.securityContext | nindent 12 }} 85 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 86 | imagePullPolicy: {{ .Values.image.pullPolicy }} 87 | ports: 88 | - name: http 89 | containerPort: {{ .Values.service.port }} 90 | protocol: TCP 91 | # livenessProbe: 92 | # httpGet: 93 | # path: /health 94 | # port: {{ .Values.service.port }} 95 | # readinessProbe: 96 | # httpGet: 97 | # path: /health 98 | # port: {{ .Values.service.port }} 99 | resources: 100 | {{- toYaml .Values.resources | nindent 12 }} 101 | {{- with .Values.nodeSelector }} 102 | nodeSelector: 103 | {{- toYaml . | nindent 8 }} 104 | {{- end }} 105 | {{- with .Values.affinity }} 106 | affinity: 107 | {{- toYaml . | nindent 8 }} 108 | {{- end }} 109 | {{- with .Values.tolerations }} 110 | tolerations: 111 | {{- toYaml . | nindent 8 }} 112 | {{- end }} 113 | -------------------------------------------------------------------------------- /k8s/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2beta1 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "app.fullname" . }} 6 | labels: 7 | {{- include "app.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "app.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 21 | {{- end }} 22 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 23 | - type: Resource 24 | resource: 25 | name: memory 26 | targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 27 | {{- end }} 28 | {{- end }} 29 | -------------------------------------------------------------------------------- /k8s/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "app.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 5 | {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} 6 | {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} 7 | {{- end }} 8 | {{- end }} 9 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 10 | apiVersion: networking.k8s.io/v1 11 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 12 | apiVersion: networking.k8s.io/v1beta1 13 | {{- else -}} 14 | apiVersion: extensions/v1beta1 15 | {{- end }} 16 | kind: Ingress 17 | metadata: 18 | name: {{ $fullName }} 19 | labels: 20 | {{- include "app.labels" . | nindent 4 }} 21 | {{- with .Values.ingress.annotations }} 22 | annotations: 23 | {{- toYaml . | nindent 4 }} 24 | {{- end }} 25 | spec: 26 | {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 27 | ingressClassName: {{ .Values.ingress.className }} 28 | {{- end }} 29 | {{- if .Values.ingress.tls }} 30 | tls: 31 | {{- range .Values.ingress.tls }} 32 | - hosts: 33 | {{- range .hosts }} 34 | - {{ . | quote }} 35 | {{- end }} 36 | secretName: {{ .secretName }} 37 | {{- end }} 38 | {{- end }} 39 | rules: 40 | {{- range .Values.ingress.hosts }} 41 | - host: {{ .host | quote }} 42 | http: 43 | paths: 44 | {{- range .paths }} 45 | - path: {{ .path }} 46 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 47 | pathType: {{ .pathType }} 48 | {{- end }} 49 | backend: 50 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 51 | service: 52 | name: {{ $fullName }} 53 | port: 54 | number: {{ $svcPort }} 55 | {{- else }} 56 | serviceName: {{ $fullName }} 57 | servicePort: {{ $svcPort }} 58 | {{- end }} 59 | {{- end }} 60 | {{- end }} 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /k8s/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: {{ include "app.fullname" . }} 5 | labels: {{- include "app.labels" . | nindent 4 }} 6 | type: Opaque 7 | data: 8 | {{- range $key, $val := .Values.secrets }} 9 | {{ $key }}: {{ $val | b64enc | quote }} 10 | {{- end }} 11 | -------------------------------------------------------------------------------- /k8s/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "app.fullname" . }} 5 | labels: 6 | {{- include "app.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "app.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /k8s/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "app.serviceAccountName" . }} 6 | labels: 7 | {{- include "app.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /k8s/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "app.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "app.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "app.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /k8s/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for app. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: brianfiszman/nestjs-test-app 9 | pullPolicy: IfNotPresent 10 | # Overrides the image tag whose default is the chart appVersion. 11 | tag: '' 12 | 13 | imagePullSecrets: [] 14 | nameOverride: '' 15 | fullnameOverride: '' 16 | 17 | serviceAccount: 18 | # Specifies whether a service account should be created 19 | create: true 20 | # Annotations to add to the service account 21 | annotations: {} 22 | # The name of the service account to use. 23 | # If not set and create is true, a name is generated using the fullname template 24 | name: '' 25 | 26 | podAnnotations: {} 27 | 28 | podSecurityContext: 29 | {} 30 | # fsGroup: 2000 31 | 32 | securityContext: 33 | {} 34 | # capabilities: 35 | # drop: 36 | # - ALL 37 | # readOnlyRootFilesystem: true 38 | # runAsNonRoot: true 39 | # runAsUser: 1000 40 | 41 | service: 42 | type: NodePort 43 | port: 8080 44 | 45 | ingress: 46 | enabled: false 47 | className: '' 48 | annotations: 49 | {} 50 | # kubernetes.io/ingress.class: nginx 51 | # kubernetes.io/tls-acme: "true" 52 | hosts: 53 | - host: chart-example.local 54 | paths: 55 | - path: / 56 | pathType: ImplementationSpecific 57 | tls: [] 58 | # - secretName: chart-example-tls 59 | # hosts: 60 | # - chart-example.local 61 | 62 | resources: 63 | {} 64 | # We usually recommend not to specify default resources and to leave this as a conscious 65 | # choice for the user. This also increases chances charts run on environments with little 66 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 67 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 68 | # limits: 69 | # cpu: 100m 70 | # memory: 128Mi 71 | # requests: 72 | # cpu: 100m 73 | # memory: 128Mi 74 | 75 | autoscaling: 76 | enabled: false 77 | minReplicas: 1 78 | maxReplicas: 100 79 | targetCPUUtilizationPercentage: 80 80 | # targetMemoryUtilizationPercentage: 80 81 | 82 | nodeSelector: {} 83 | 84 | tolerations: [] 85 | 86 | affinity: {} 87 | 88 | mongodb: 89 | enabled: true 90 | useStatefulSet: true 91 | architecture: standalone 92 | auth: 93 | existingSecret: app 94 | databases: ["cinema"] 95 | usernames: ["test"] 96 | -------------------------------------------------------------------------------- /local.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:fermium-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY ./package.json /app/package.json 6 | RUN yarn 7 | 8 | COPY . . 9 | 10 | CMD ["yarn", "start:debug"] 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ddd-pe-boilerplate", 3 | "version": "0.0.1", 4 | "description": "Example integration of MikroORM into express (in typescript)", 5 | "author": "Brian Ezequiel Fiszman", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "build": "nest build", 9 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 10 | "seed": "NODE_ENV=development ts-node src/seeder.ts --watch --config seeder-cli.json", 11 | "start:debug": "NODE_ENV=development nest start --debug --watch", 12 | "start": "nest start", 13 | "start:dev": "NODE_ENV=development nest start --watch", 14 | "start:debug": "NODE_ENV=development nest start --debug --watch", 15 | "start:prod": "NODE_ENV=production node dist/main", 16 | "daemon": "nest build --webpack --webpackPath webpack-hmr.config.js", 17 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 18 | "test": "jest", 19 | "test:ci": "jest --ci", 20 | "test:watch": "jest --watch", 21 | "test:cov": "jest --coverage", 22 | "test:debug": "NODE_ENV=test node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 23 | "test:e2e": "jest --config ./__test__/jest-e2e.json" 24 | }, 25 | "dependencies": { 26 | "@kafkajs/confluent-schema-registry": "^1.0.6", 27 | "@nestjs/common": "^7.4.4", 28 | "@nestjs/config": "^0.6.1", 29 | "@nestjs/core": "^7.4.4", 30 | "@nestjs/microservices": "^7.6.5", 31 | "@nestjs/mongoose": "^7.1.2", 32 | "@nestjs/platform-express": "^7.4.4", 33 | "@types/dotenv-flow": "^3.1.0", 34 | "@types/faker": "^5.1.4", 35 | "@types/kafkajs": "^1.9.0", 36 | "@types/morgan": "^1.9.2", 37 | "@types/ramda": "^0.27.38", 38 | "@types/uuidv4": "^5.0.0", 39 | "class-transformer": "^0.3.1", 40 | "class-validator": "^0.12.2", 41 | "dotenv": "^8.2.0", 42 | "dotenv-expand": "^5.1.0", 43 | "dotenv-flow": "^3.2.0", 44 | "faker": "^5.1.0", 45 | "kafkajs": "^1.15.0", 46 | "mongoose": "^5.11.15", 47 | "morgan": "^1.10.0", 48 | "ramda": "^0.27.1", 49 | "reflect-metadata": "^0.1.13", 50 | "rimraf": "^3.0.2", 51 | "rxjs": "^6.6.3", 52 | "start-server-webpack-plugin": "^2.2.5", 53 | "tsc-watch": "^4.2.9", 54 | "uuidv4": "^6.2.5" 55 | }, 56 | "devDependencies": { 57 | "@nestjs/cli": "^7.5.1", 58 | "@nestjs/schematics": "^7.1.2", 59 | "@nestjs/testing": "^7.5.1", 60 | "@types/express": "^4.17.8", 61 | "@types/express-promise-router": "^3.0.0", 62 | "@types/jest": "^26.0.15", 63 | "@types/node": "^14.14.6", 64 | "@types/supertest": "^2.0.10", 65 | "@typescript-eslint/eslint-plugin": "^4.6.0", 66 | "@typescript-eslint/parser": "^4.6.0", 67 | "eslint": "^7.12.1", 68 | "eslint-config-prettier": "^6.15.0", 69 | "eslint-plugin-prettier": "^3.1.4", 70 | "jest": "^26.6.1", 71 | "prettier": "^2.1.2", 72 | "prettier-eslint": "^11.0.0", 73 | "supertest": "^6.0.1", 74 | "ts-jest": "^26.4.3", 75 | "ts-loader": "^8.0.7", 76 | "ts-node": "^9.0.0", 77 | "typescript": "^4.0.5" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /scripts/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on error. Append "|| true" if you expect an error. 4 | set -o errexit 5 | # Exit on error inside any functions or subshells. 6 | set -o errtrace 7 | # Do not allow use of undefined vars. Use ${VAR:-} to use an undefined VAR 8 | set -o nounset 9 | # Catch the error in case mysqldump fails (but gzip succeeds) in `mysqldump |gzip` 10 | set -o pipefail 11 | # Turn on traces, useful while debugging but commented out by default 12 | # set -o xtrace 13 | 14 | # Turn on bash's job control 15 | set -m 16 | 17 | __APP_PATH="${MS_HOME:-/app}" 18 | 19 | node dist/main.js 20 | -------------------------------------------------------------------------------- /scripts/config_registry.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | echo "registry ${NPM_REGISTRY_URL}" > "${MS_HOME}/.yarnrc" 4 | echo "_auth ${NPM_REGISTRY_TOKEN}" >> "${MS_HOME}/.yarnrc" -------------------------------------------------------------------------------- /scripts/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -e 3 | 4 | export NODE_ENV=${CURRENT_ENV_:-prod} 5 | 6 | __boostrap="/usr/local/bin/bootstrap" 7 | 8 | # this if will check if the first argument is a flag 9 | # but only works if all arguments require a hyphenated flag 10 | # -v; -SL; -f arg; etc will work, but not arg1 arg2 11 | if [ "$#" -eq 0 ] || [ "${1#-}" != "$1" ]; then 12 | exec "${__boostrap}" "$@" 13 | # If no arguments passed, run the main executable 14 | elif [ -z "$1" ]; then 15 | exec "${__boostrap}" 16 | fi 17 | 18 | # else default to run whatever the user wanted like "bash" or "sh" 19 | exec "$@" 20 | -------------------------------------------------------------------------------- /seeder-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceRoot": "src", 3 | "entryFile": "seeder" 4 | } 5 | -------------------------------------------------------------------------------- /skaffold.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: skaffold/v2beta26 2 | kind: Config 3 | build: 4 | local: 5 | useBuildkit: true 6 | artifacts: 7 | - image: brianfiszman/nestjs-test-app 8 | deploy: 9 | helm: 10 | releases: 11 | - name: app 12 | chartPath: k8s 13 | wait: true 14 | useHelmSecrets: true 15 | valuesFiles: 16 | - ./k8s/values.yaml 17 | - ./k8s/secrets.yaml 18 | artifactOverrides: 19 | image: brianfiszman/nestjs-test-app 20 | imageStrategy: 21 | helm: {} 22 | 23 | portForward: 24 | - resourceType: service 25 | resourceName: app 26 | port: 8080 27 | localPort: 8080 28 | - resourceType: service 29 | resourceName: app-mongodb 30 | port: 27017 31 | localPort: 27017 32 | -------------------------------------------------------------------------------- /src/application/README.md: -------------------------------------------------------------------------------- 1 | # APPLICATION 2 | > The application layer defines the actual behavior of our application, thus being responsible for performing interactions among units of the domain layer. 3 | -------------------------------------------------------------------------------- /src/application/controllers/README.md: -------------------------------------------------------------------------------- 1 | # CONTROLLER 2 | 3 | > The responsibility of controllers in Nest.js is to receive and handle the incoming HTTP requests from the client side of an application and return the appropriate responses based on the business logic. 4 | > The routing mechanism, which is controlled by the decorator `@Controller()` attached to the top of each controller, usually determines which controller receives which requests. 5 | ``` 6 | -------------------------------------------------------------------------------- /src/application/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './movie.controller'; 2 | -------------------------------------------------------------------------------- /src/application/controllers/movie.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Body, Put } from '@nestjs/common'; 2 | import { MovieService } from '../../domain/services/movie.service'; 3 | import { MovieCreateDTO, MovieGetDTO } from '../dtos/movie'; 4 | 5 | @Controller('movies') 6 | export class MovieController { 7 | constructor(private movieService: MovieService) {} 8 | 9 | @Get() 10 | async findAll(): Promise { 11 | const movies = await this.movieService.findAll(); 12 | const dtos: MovieGetDTO[] = movies.map(movie => new MovieGetDTO(movie)); 13 | 14 | return dtos; 15 | } 16 | 17 | @Post() 18 | async create(@Body() movieCreateDTO: MovieCreateDTO): Promise { 19 | const dto = new MovieCreateDTO(movieCreateDTO); 20 | const movie = await this.movieService.create(dto); 21 | return new MovieGetDTO(movie); 22 | } 23 | 24 | @Put() 25 | async update(@Body() movieCreateDTO: MovieCreateDTO): Promise { 26 | const dto = new MovieCreateDTO(movieCreateDTO); 27 | const movie = await this.movieService.update(dto); 28 | return new MovieGetDTO(movie); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/application/dtos/README.md: -------------------------------------------------------------------------------- 1 | # Data Transfer Objects 2 | 3 | > [DTOs](https://en.wikipedia.org/wiki/Data_transfer_object) are essentially domain objects transformed to a shape that is more context aware by being filtered for private/[PII](https://en.wikipedia.org/wiki/Personal_data) data and absent of characteristics that are serialization barriers like circular dependencies. They are optimized for bandwidth usage as well, but more generally, **they include all and only the data the current read or write action requires to function.** 4 | 5 | > They are ready for serialization into JSON or whatever transport protocol we choose to use and their primary purpose is to transfer data between two processes or systems according to a predefined spec or schema. We will be placing any DTO used for either input or output at src/application/dtos. 6 | -------------------------------------------------------------------------------- /src/application/dtos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './movie'; 2 | -------------------------------------------------------------------------------- /src/application/dtos/movie/index.ts: -------------------------------------------------------------------------------- 1 | export * from './movie-create.dto'; 2 | export * from './movie-get.dto'; 3 | -------------------------------------------------------------------------------- /src/application/dtos/movie/movie-create.dto.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { IsDefined, IsOptional, IsUrl, MaxLength } from 'class-validator'; 3 | import { Categories } from '../../../domain/enums'; 4 | 5 | export class MovieCreateDTO { 6 | @IsDefined() 7 | @MaxLength(50) 8 | title: string; 9 | @IsUrl() 10 | @IsDefined() 11 | @MaxLength(100) 12 | url: string; 13 | @IsOptional() 14 | @MaxLength(100) 15 | description?: string; 16 | @IsDefined() 17 | categories: Set | Categories[]; 18 | @IsOptional() 19 | publishDate?: Date; 20 | 21 | constructor({ title, url, description, categories = [], publishDate }: any = {}) { 22 | this.title = title; 23 | this.url = url; 24 | this.description = description; 25 | this.categories = categories; 26 | this.publishDate = publishDate; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/application/dtos/movie/movie-get.dto.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { IsUUID, IsOptional, IsDefined, MaxLength, IsUrl, IsDate } from 'class-validator'; 3 | import { Categories } from '../../../domain/enums'; 4 | 5 | export class MovieGetDTO { 6 | @IsUUID() 7 | _id: string; 8 | @IsDefined() 9 | @MaxLength(50) 10 | title: string; 11 | @IsUrl() 12 | @IsDefined() 13 | @MaxLength(100) 14 | url: string; 15 | @IsOptional() 16 | @MaxLength(100) 17 | description?: string; 18 | @IsDefined() 19 | categories: Set; 20 | @IsDate() 21 | @IsOptional() 22 | publishDate?: Date; 23 | 24 | constructor({ title, url, description, categories }: any = {}) { 25 | this.title = title; 26 | this.url = url; 27 | this.description = description; 28 | this.categories = categories; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/domain/README.md: -------------------------------------------------------------------------------- 1 | # DOMAIN 2 | > In this layer, we may define units which play the role of entities and business rules and have a direct relationship to our domain. 3 | -------------------------------------------------------------------------------- /src/domain/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './movie.schema'; 2 | -------------------------------------------------------------------------------- /src/domain/entities/movie.schema.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import { Document } from 'mongoose'; 3 | import { Categories } from '../enums'; 4 | 5 | export interface Movie { 6 | _id?: string; 7 | title: string; 8 | url: string; 9 | description?: string; 10 | categories: Categories[] | Set; 11 | publishDate?: Date; 12 | } 13 | 14 | export type MovieDocument = Movie & Document; 15 | 16 | export const MovieSchema: any = new mongoose.Schema( 17 | { 18 | title: String, 19 | url: String, 20 | description: String, 21 | categories: { 22 | type: [String], 23 | enum: Categories, 24 | }, 25 | publishDate: Date, 26 | }, 27 | { 28 | timestamps: true, 29 | _id: true, 30 | } 31 | ); 32 | 33 | MovieSchema.index({ title: 1 }, { unique: true }); 34 | -------------------------------------------------------------------------------- /src/domain/enums/categories.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Categories { 2 | Action = 'Action', 3 | Drama = 'Drama', 4 | Comedy = 'Comedy', 5 | Mistery = 'Mistery', 6 | Romance = 'Romance', 7 | Thriller = 'Thriller', 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/enums/entities.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Entities { 2 | Movie = 'Movie', 3 | } 4 | 5 | export enum Timestamps { 6 | UpdatedAt = 'updated', 7 | CreatedAt = 'date', 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entities.enum'; 2 | export * from './categories.enum'; 3 | -------------------------------------------------------------------------------- /src/domain/helpers/env.helpers.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import { config } from 'dotenv-flow'; 3 | import dotenv_expand from 'dotenv-expand'; 4 | 5 | export function expandEnvVariables(): void { 6 | dotenv.config(); 7 | const envConfig = config({ purge_dotenv: true }); 8 | dotenv_expand(envConfig); 9 | } 10 | -------------------------------------------------------------------------------- /src/domain/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './env.helpers'; 2 | -------------------------------------------------------------------------------- /src/domain/modules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './movie.module'; 2 | -------------------------------------------------------------------------------- /src/domain/modules/movie-seeder.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { OrmModule } from '../../infrastructure/database/orm'; 3 | import { MovieSchema } from '../entities'; 4 | import { Entities } from '../enums/entities.enum'; 5 | import { MovieSeederService } from '../services'; 6 | import { MovieModule } from './movie.module'; 7 | 8 | @Module({ 9 | imports: [OrmModule.forFeature([{ name: Entities.Movie, schema: MovieSchema }]), MovieModule], 10 | providers: [MovieSeederService], 11 | }) 12 | export class MovieSeederModule {} 13 | -------------------------------------------------------------------------------- /src/domain/modules/movie.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MovieService } from '../services'; 3 | import { MovieController } from '../../application/controllers'; 4 | import { OrmModule } from '../../infrastructure/database/orm'; 5 | import { MovieSchema } from '../entities'; 6 | import { MovieRepository } from '../../infrastructure/repositories'; 7 | import { Entities } from '../enums/entities.enum'; 8 | 9 | @Module({ 10 | imports: [OrmModule.forFeature([{ name: Entities.Movie, schema: MovieSchema }])], 11 | controllers: [MovieController], 12 | providers: [MovieService, MovieRepository], 13 | exports: [MovieService, MovieRepository], 14 | }) 15 | export class MovieModule {} 16 | -------------------------------------------------------------------------------- /src/domain/services/README.md: -------------------------------------------------------------------------------- 1 | # SERVICE 2 | > A service, also known as a provider, is another building block in Nest.js that is categorized under the separation of concerns principle. 3 | > It is designed to handle and abstract complex business logic away from the controller and return the appropriate responses. 4 | > All services in Nest.js are decorated with the **@Injectable()** decorator and this makes it easy to inject services into any other file, such as controllers and modules. 5 | -------------------------------------------------------------------------------- /src/domain/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './movie.service'; 2 | export * from './movie-seeder.service' 3 | -------------------------------------------------------------------------------- /src/domain/services/movie-seeder.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { date, internet, lorem, name, random } from 'faker'; 3 | import { Categories, Entities } from '../enums'; 4 | import { MovieCreateDTO } from '../../application/dtos'; 5 | import { InjectModel } from '@nestjs/mongoose'; 6 | import { MovieDocument } from '../entities'; 7 | import { Model } from 'mongoose'; 8 | 9 | @Injectable() 10 | export class MovieSeederService { 11 | static MaxLengthDescription = 100; 12 | 13 | constructor(@InjectModel(Entities.Movie) private readonly movieModel: Model) {} 14 | 15 | async onModuleInit() { 16 | try { 17 | const movies: MovieCreateDTO[] = [...Array(100).keys()] 18 | .map(() => this.createFakeMovie()) 19 | .map(({ categories, ...m }) => ({ ...m, categories: [...categories] })); 20 | 21 | await this.createMany(movies); 22 | } catch (e) { 23 | console.error(e); 24 | } 25 | } 26 | 27 | async createMany(movieCreateDTOs: MovieCreateDTO[]): Promise { 28 | await this.movieModel.insertMany(movieCreateDTOs); 29 | } 30 | 31 | createFakeMovie = (): MovieCreateDTO => ({ 32 | title: name.firstName(), 33 | url: internet.url(), 34 | description: lorem.word(MovieSeederService.MaxLengthDescription), 35 | categories: new Set( 36 | random.arrayElements([ 37 | Categories.Action, 38 | Categories.Comedy, 39 | Categories.Drama, 40 | Categories.Mistery, 41 | Categories.Romance, 42 | Categories.Thriller, 43 | ]) 44 | ), 45 | publishDate: date.past(), 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/domain/services/movie.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { validate } from 'class-validator'; 3 | import { MovieCreateDTO, MovieGetDTO } from '../../application/dtos/movie'; 4 | import { MovieRepository } from '../../infrastructure/repositories'; 5 | import { Movie } from '../entities'; 6 | 7 | @Injectable() 8 | export class MovieService { 9 | constructor(readonly movieRepository: MovieRepository) {} 10 | 11 | async findAll(): Promise { 12 | const results: Movie[] = await this.movieRepository.findAll(); 13 | return results; 14 | } 15 | 16 | async create(movieCreateDTO: MovieCreateDTO): Promise { 17 | const errors = await validate(movieCreateDTO); 18 | 19 | if (errors.length) throw errors; 20 | 21 | const exists = await this.movieRepository.findByTitle({ title: movieCreateDTO.title }); 22 | 23 | if (exists.length) throw 'Element already exists in database'; 24 | 25 | const newMovie = await this.movieRepository.persist(movieCreateDTO); 26 | return newMovie; 27 | } 28 | 29 | async update(movieCreateDTO: MovieCreateDTO): Promise { 30 | const errors = await validate(movieCreateDTO); 31 | 32 | if (errors.length) throw errors; 33 | 34 | const updatedMovie = await this.movieRepository.upsert(movieCreateDTO); 35 | const movieDTO: MovieGetDTO = new MovieGetDTO(updatedMovie); 36 | 37 | return movieDTO; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/infrastructure/README.md: -------------------------------------------------------------------------------- 1 | # INFRASTRUCTURE 2 | > This is the lowest layer of all, and it’s the boundary to whatever is external to our application: the database, email services, queue engines, etc. 3 | -------------------------------------------------------------------------------- /src/infrastructure/config/env.objects.ts: -------------------------------------------------------------------------------- 1 | import { expandEnvVariables } from '../../domain/helpers/'; 2 | 3 | expandEnvVariables(); 4 | 5 | export enum EnvObjects { 6 | MONGO_OPTIONS = 'MongoOptions', 7 | } 8 | 9 | export interface MongoOptions { 10 | host: string; 11 | options: { 12 | dbName: string; 13 | auth: { 14 | user: string; 15 | password: string; 16 | }; 17 | }; 18 | } 19 | 20 | export const configuration = (): any => ({ 21 | MongoOptions: { 22 | host: process.env.MONGO_CLIENT_URL, 23 | options: { 24 | dbName: process.env.MONGO_DB_NAME, 25 | auth: { 26 | user: process.env.MONGO_USER, 27 | password: process.env.MONGO_PASS, 28 | }, 29 | }, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /src/infrastructure/config/env.validation.ts: -------------------------------------------------------------------------------- 1 | import { plainToClass } from 'class-transformer'; 2 | import { validateSync, IsNotEmpty } from 'class-validator'; 3 | 4 | class EnvironmentVariables { 5 | @IsNotEmpty() 6 | PORT: string; 7 | 8 | @IsNotEmpty() 9 | MONGO_CLIENT_URL: string; 10 | @IsNotEmpty() 11 | MONGO_DB_NAME: string; 12 | @IsNotEmpty() 13 | MONGO_USER: string; 14 | @IsNotEmpty() 15 | MONGO_PASS: string; 16 | } 17 | 18 | export function validate(config: Record) { 19 | const validatedConfig = plainToClass(EnvironmentVariables, config, { enableImplicitConversion: true }); 20 | const errors = validateSync(validatedConfig, { skipMissingProperties: false }); 21 | 22 | if (errors.length > 0) { 23 | throw new Error(errors.toString()); 24 | } 25 | return validatedConfig; 26 | } 27 | -------------------------------------------------------------------------------- /src/infrastructure/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './env.objects'; 2 | export * from './env.validation'; 3 | -------------------------------------------------------------------------------- /src/infrastructure/database/orm/index.ts: -------------------------------------------------------------------------------- 1 | export { MongooseModule as OrmModule } from '@nestjs/mongoose'; 2 | -------------------------------------------------------------------------------- /src/infrastructure/modules/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { configuration, EnvObjects, MongoOptions } from '../config/env.objects'; 4 | import { OrmModule } from '../database/orm'; 5 | import { MovieModule } from '../../domain/modules/movie.module'; 6 | import { validate } from '../config/env.validation'; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule.forRoot({ 11 | load: [configuration], 12 | validate, 13 | isGlobal: true, 14 | cache: true, 15 | expandVariables: true, 16 | }), 17 | OrmModule.forRootAsync({ 18 | imports: [ConfigModule], 19 | useFactory: async (configService: ConfigService) => { 20 | const data = configService.get(EnvObjects.MONGO_OPTIONS); 21 | return { uri: data?.host, ...data?.options }; 22 | }, 23 | inject: [ConfigService], 24 | }), 25 | MovieModule, 26 | ], 27 | controllers: [], 28 | }) 29 | export class AppModule {} 30 | -------------------------------------------------------------------------------- /src/infrastructure/modules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app.module'; 2 | export * from './seeder.module'; 3 | -------------------------------------------------------------------------------- /src/infrastructure/modules/seeder.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { configuration, EnvObjects, MongoOptions } from '../config/env.objects'; 4 | import { OrmModule } from '../database/orm'; 5 | import { validate } from '../config/env.validation'; 6 | import { MovieSeederModule } from '../../domain/modules/movie-seeder.module'; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule.forRoot({ 11 | load: [configuration], 12 | validate, 13 | isGlobal: true, 14 | cache: true, 15 | expandVariables: true, 16 | }), 17 | OrmModule.forRootAsync({ 18 | imports: [ConfigModule], 19 | useFactory: async (configService: ConfigService) => { 20 | const data = configService.get(EnvObjects.MONGO_OPTIONS); 21 | return { uri: data?.host, ...data?.options }; 22 | }, 23 | inject: [ConfigService], 24 | }), 25 | MovieSeederModule, 26 | ], 27 | controllers: [], 28 | }) 29 | export class SeederModule {} 30 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/README.md: -------------------------------------------------------------------------------- 1 | # REPOSITORY PATTERN 2 | > According to Martin Fowler, the Repository Pattern can be described as: 3 | 4 | > Mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects. 5 | 6 | > So a repository will generally have methods such as findOne, findAll, remove, and such. Your service layer will manipulate the collection through these repository methods. Their implementation is not a concern to our business logic. 7 | 8 | > Using this pattern is what enables us to easily unit-test our business logic by allowing us to mock the repository in an isolated manner. We'll see how that works later on. 9 | 10 | > In our case, mikro-orm already provides repositories for our entities so that we don't have to write those basic collection methods ourselves. As you can see, we are creating our own custom repository by extending EntityRepository which will allow us to define any needed custom queries. Other than the basic collection methods, any custom query your serivce needs to run against your database should be written as a new method on the repository. For example, there could be a need for a findAllCompletedTodos. 11 | 12 | > It would also be possible to write the entire repository from scratch ourselves and still keep the same interface in a situation where we are not interested or are unable to use any ORM. This is why abstractions are so important. 13 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './movie.repository'; 2 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/movie.repository.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'mongoose'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { InjectModel } from '@nestjs/mongoose'; 4 | import { Movie, MovieDocument } from '../../domain/entities'; 5 | import { MovieCreateDTO } from '../../application/dtos/movie/movie-create.dto'; 6 | import { Entities } from '../../domain/enums/entities.enum'; 7 | 8 | @Injectable() 9 | export class MovieRepository { 10 | constructor(@InjectModel(Entities.Movie) private readonly movieModel: Model) {} 11 | 12 | async findByTitle({ title }: any): Promise { 13 | const results: Movie[] = await this.movieModel.find({ title }).exec(); 14 | return results; 15 | } 16 | 17 | async findAll(): Promise { 18 | const results: Movie[] = await this.movieModel.find().exec(); 19 | return results; 20 | } 21 | 22 | async persist(movieCreateDTO: MovieCreateDTO): Promise { 23 | const newMovie = new this.movieModel(movieCreateDTO); 24 | return await newMovie.save(); 25 | } 26 | 27 | async upsert(movieCreateDTO: MovieCreateDTO): Promise { 28 | return await this.movieModel.findOneAndUpdate({ title: movieCreateDTO.title }, movieCreateDTO); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import morgan from 'morgan'; 2 | import { Logger } from '@nestjs/common'; 3 | import { NestFactory } from '@nestjs/core'; 4 | import { AppModule } from './infrastructure/modules/app.module'; 5 | import { ConfigService } from '@nestjs/config'; 6 | 7 | async function bootstrap() { 8 | // Http Server 9 | const app = await NestFactory.create(AppModule); 10 | 11 | app.use(morgan('dev')); 12 | 13 | const configService = app.get(ConfigService); 14 | const NODE_PORT = configService.get('NODE_PORT'); 15 | 16 | await app.listen(NODE_PORT, () => Logger.log('HTTP Service is listening', 'App')); 17 | } 18 | 19 | bootstrap(); 20 | -------------------------------------------------------------------------------- /src/seeder.ts: -------------------------------------------------------------------------------- 1 | import morgan from 'morgan'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { SeederModule } from './infrastructure/modules'; 4 | 5 | async function bootstrap() { 6 | // Http Server 7 | const app = await NestFactory.create(SeederModule); 8 | 9 | app.use(morgan('dev')); 10 | 11 | await app.init(); 12 | } 13 | 14 | bootstrap(); 15 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "moduleResolution": "Node", 6 | "sourceMap": true, 7 | "declaration": true, 8 | "outDir": "./dist", 9 | "esModuleInterop": true, 10 | "strict": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "strictPropertyInitialization": false, 14 | "skipLibCheck": true 15 | }, 16 | "include": ["./src/**/*.ts"], 17 | "exclude": ["node_modules"] 18 | } 19 | -------------------------------------------------------------------------------- /webpack-hmr.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const nodeExternals = require('webpack-node-externals'); 3 | const StartServerPlugin = require('start-server-webpack-plugin'); 4 | 5 | module.exports = function (options) { 6 | return { 7 | ...options, 8 | entry: ['webpack/hot/poll?100', options.entry], 9 | watch: true, 10 | externals: [ 11 | nodeExternals({ 12 | allowlist: ['webpack/hot/poll?100'], 13 | }), 14 | ], 15 | plugins: [ 16 | ...options.plugins, 17 | new webpack.HotModuleReplacementPlugin(), 18 | new webpack.WatchIgnorePlugin([/\.js$/, /\.d\.ts$/]), 19 | new StartServerPlugin({ name: options.output.filename }), 20 | ], 21 | }; 22 | }; 23 | --------------------------------------------------------------------------------