├── .prettierrc ├── src ├── products │ ├── dto │ │ ├── index.ts │ │ ├── update-product.dto.ts │ │ └── create-product.dto.ts │ ├── products.module.ts │ ├── test │ │ ├── mockProductsRepository.ts │ │ ├── product.stub.ts │ │ └── products.service.spec.ts │ ├── product.entity.ts │ ├── products.controller.ts │ └── products.service.ts ├── config │ └── typeorm.config.ts ├── app.module.ts └── main.ts ├── tsconfig.build.json ├── nest-cli.json ├── .gitignore ├── docker-compose.yml ├── tsconfig.json ├── .eslintrc.js ├── init └── seed.sql ├── package.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /src/products/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-product.dto'; 2 | export * from './update-product.dto'; 3 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /src/products/dto/update-product.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | import { CreateProductDto } from './index'; 3 | 4 | export class UpdateProductDto extends PartialType(CreateProductDto) {} 5 | -------------------------------------------------------------------------------- /src/products/products.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Product } from './product.entity'; 4 | import { ProductsController } from './products.controller'; 5 | import { ProductsService } from './products.service'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Product])], 9 | controllers: [ProductsController], 10 | providers: [ProductsService], 11 | }) 12 | export class ProductsModule {} 13 | -------------------------------------------------------------------------------- /src/config/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 2 | 3 | const port: number = parseInt(process.env.PORT) || 3306; 4 | 5 | export const typeormConnectionConfig: TypeOrmModuleOptions = { 6 | type: 'mysql', 7 | host: process.env.MYSQL_HOST, 8 | port: port, 9 | username: process.env.MYSQL_USER, 10 | password: process.env.MYSQL_PASSWORD, 11 | database: process.env.MYSQL_DATABASE, 12 | entities: ['dist/**/*.entity{.ts,.js}'], 13 | synchronize: true, 14 | timezone: 'utc', 15 | }; 16 | -------------------------------------------------------------------------------- /src/products/test/mockProductsRepository.ts: -------------------------------------------------------------------------------- 1 | import { Product } from '../product.entity'; 2 | import { productsStub, productStub } from './product.stub'; 3 | 4 | export const mockProductsRepository = { 5 | find: jest.fn().mockResolvedValue(productsStub()), 6 | findOneOrFail: jest.fn().mockResolvedValue(productStub()), 7 | findOneBy: jest.fn().mockResolvedValue(productStub()), 8 | create: jest.fn().mockResolvedValue(productStub()), 9 | save: jest.fn((product: Product) => product), 10 | delete: jest.fn((id: number) => id), 11 | }; 12 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | const envModule = ConfigModule.forRoot({ 4 | isGlobal: true, 5 | }); 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | import { typeormConnectionConfig } from 'src/config/typeorm.config'; 8 | import { ProductsModule } from './products/products.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | envModule, 13 | TypeOrmModule.forRoot(typeormConnectionConfig), 14 | ProductsModule, 15 | ], 16 | }) 17 | export class AppModule {} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | .env -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | mysql-products-db: 4 | image: mysql 5 | ports: 6 | - 3306:${MYSQL_PORT} 7 | environment: 8 | MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} 9 | MYSQL_DATABASE: ${MYSQL_DATABASE} 10 | MYSQL_USER: ${MYSQL_USER} 11 | MYSQL_PASSWORD: ${MYSQL_PASSWORD} 12 | volumes: 13 | - mysql_products_volume:/var/lib/mysql 14 | - ./init:/docker-entrypoint-initdb.d 15 | 16 | adminer: 17 | image: adminer 18 | restart: always 19 | ports: 20 | - 8080:8080 21 | volumes: 22 | mysql_products_volume: 23 | driver: local 24 | -------------------------------------------------------------------------------- /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": "es2017", 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 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/products/dto/create-product.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsNotEmpty, 3 | IsNumber, 4 | IsString, 5 | MaxLength, 6 | Max, 7 | Min, 8 | MinLength, 9 | } from 'class-validator'; 10 | import { ApiProperty } from '@nestjs/swagger'; 11 | 12 | export class CreateProductDto { 13 | @IsNotEmpty() 14 | @IsString({ message: 'name must be a text' }) 15 | @MaxLength(255) 16 | @MinLength(3) 17 | @ApiProperty() 18 | name: string; 19 | 20 | @IsNotEmpty() 21 | @IsNumber() 22 | @Max(9999999999) 23 | @Min(0) 24 | @ApiProperty() 25 | price: number; 26 | 27 | @IsString({ message: 'description must be a text' }) 28 | @ApiProperty() 29 | description: string; 30 | } 31 | -------------------------------------------------------------------------------- /src/products/product.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity({ name: 'products' }) 4 | export class Product { 5 | @PrimaryGeneratedColumn() 6 | product_id: number; 7 | 8 | @Column({ 9 | nullable: false, 10 | }) 11 | name: string; 12 | 13 | @Column({ 14 | type: 'float', 15 | nullable: false, 16 | }) 17 | price: number; 18 | 19 | @Column({ 20 | type: 'text', 21 | }) 22 | description: string; 23 | 24 | @Column({ 25 | nullable: false, 26 | default: () => 'CURRENT_TIMESTAMP', 27 | }) 28 | created_at: Date; 29 | 30 | @Column({ 31 | nullable: false, 32 | default: () => 'CURRENT_TIMESTAMP', 33 | }) 34 | updated_at: Date; 35 | } 36 | -------------------------------------------------------------------------------- /.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': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /init/seed.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE products ( 2 | product_id INT NOT NULL AUTO_INCREMENT, 3 | name VARCHAR(255) NOT NULL, 4 | price FLOAT(10,2) NOT NULL, 5 | description TEXT, 6 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, 7 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, 8 | PRIMARY KEY(product_id) 9 | ); 10 | 11 | INSERT INTO products (name, price, description) VALUES ('Red Pen', 2.23, 'lorem ipsum dolor sit amet'); 12 | INSERT INTO products (name, price, description) VALUES ('Painting Brush', 10.9, 'some high quality painting brush'); 13 | INSERT INTO products (name, price, description) VALUES ('Eraser', 1.23, 'lorem ipsum dolor sit amet erat, sed diam nonum nonummy ut labore et dolore magna aliquet'); 14 | INSERT INTO products (name, price, description) VALUES ('Notebook', 5.50, 'a popular notebook among art students'); -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import helmet from 'helmet'; 4 | import { ValidationPipe } from '@nestjs/common'; 5 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 6 | 7 | async function bootstrap() { 8 | const app = await NestFactory.create(AppModule); 9 | 10 | // security middlewares 11 | app.enableCors(); 12 | app.use(helmet()); 13 | 14 | // Validation 15 | app.useGlobalPipes(new ValidationPipe({ disableErrorMessages: false })); 16 | 17 | // OpenAPI Specification 18 | const config = new DocumentBuilder() 19 | .setTitle('Products Demo API') 20 | .setDescription( 21 | 'A REST API using Nestjs to create CRUD operations on products table', 22 | ) 23 | .setVersion('1.0') 24 | .addTag('products') 25 | .build(); 26 | const document = SwaggerModule.createDocument(app, config); 27 | SwaggerModule.setup('api', app, document); 28 | 29 | await app.listen(process.env.API_PORT || 5000); 30 | } 31 | bootstrap(); 32 | -------------------------------------------------------------------------------- /src/products/test/product.stub.ts: -------------------------------------------------------------------------------- 1 | import { Product } from '../product.entity'; 2 | 3 | export const productsStub = (): Product[] => { 4 | return [ 5 | { 6 | product_id: 1, 7 | name: 'Red Pen', 8 | price: 2.23, 9 | description: 'lorem ipsum dolor sit amet', 10 | created_at: new Date('2022-07-06T08:13:25.000Z'), 11 | updated_at: new Date('2022-07-06T08:13:25.000Z'), 12 | }, 13 | { 14 | product_id: 2, 15 | name: 'Painting Brush', 16 | price: 10.9, 17 | description: 'some high quality painting brush', 18 | created_at: new Date('2022-07-06T08:13:25.000Z'), 19 | updated_at: new Date('2022-07-06T08:13:25.000Z'), 20 | }, 21 | ]; 22 | }; 23 | 24 | export const productStub = (): Product => { 25 | return { 26 | product_id: 1, 27 | name: 'Red Pen', 28 | price: 2.23, 29 | description: 'lorem ipsum dolor sit amet', 30 | created_at: new Date('2022-07-06T08:13:25.000Z'), 31 | updated_at: new Date('2022-07-06T08:13:25.000Z'), 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/products/products.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Post, 6 | Delete, 7 | Param, 8 | ParseIntPipe, 9 | UsePipes, 10 | ValidationPipe, 11 | Patch, 12 | } from '@nestjs/common'; 13 | import { CreateProductDto, UpdateProductDto } from './dto/index'; 14 | import { Product } from './product.entity'; 15 | import { ProductsService } from './products.service'; 16 | 17 | @Controller('products') 18 | export class ProductsController { 19 | constructor(private productsService: ProductsService) {} 20 | 21 | @Get() 22 | async GetAll(): Promise { 23 | return this.productsService.getAll(); 24 | } 25 | 26 | @Get(':id') 27 | async GetOne(@Param('id', ParseIntPipe) id: number): Promise { 28 | return this.productsService.getOneById(id); 29 | } 30 | 31 | @Post() 32 | async create(@Body() product: CreateProductDto): Promise { 33 | return this.productsService.create(product); 34 | } 35 | 36 | @Patch(':id') 37 | @UsePipes(ValidationPipe) 38 | async update( 39 | @Param('id', ParseIntPipe) id: number, 40 | @Body() product: UpdateProductDto, 41 | ): Promise { 42 | return this.productsService.update(id, product); 43 | } 44 | 45 | @Delete(':id') 46 | async delete(@Param('id') id: number): Promise { 47 | return this.productsService.delete(id); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/products/products.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { CreateProductDto, UpdateProductDto } from './dto/index'; 5 | import { Product } from './product.entity'; 6 | 7 | @Injectable() 8 | export class ProductsService { 9 | constructor( 10 | @InjectRepository(Product) private productRepository: Repository, 11 | ) {} 12 | 13 | async getAll(): Promise { 14 | return await this.productRepository.find(); 15 | } 16 | 17 | async getOneById(id: number): Promise { 18 | try { 19 | return await this.productRepository.findOneOrFail({ 20 | where: { product_id: id }, 21 | }); 22 | } catch (err) { 23 | console.log('Get one product by id error: ', err.message ?? err); 24 | throw new HttpException( 25 | `Product with id ${id} not found.`, 26 | HttpStatus.NOT_FOUND, 27 | ); 28 | } 29 | } 30 | 31 | async create(product: CreateProductDto): Promise { 32 | const createdProduct = this.productRepository.create(product); 33 | return await this.productRepository.save(createdProduct); 34 | } 35 | 36 | async update(id: number, product: UpdateProductDto): Promise { 37 | let foundProduct = await this.productRepository.findOneBy({ 38 | product_id: id, 39 | }); 40 | 41 | if (!foundProduct) { 42 | throw new HttpException( 43 | `Product with id ${id} not found.`, 44 | HttpStatus.NOT_FOUND, 45 | ); 46 | } 47 | 48 | foundProduct = { ...foundProduct, ...product, updated_at: new Date() }; 49 | return await this.productRepository.save(foundProduct); 50 | } 51 | 52 | async delete(id: number): Promise { 53 | let foundProduct = await this.productRepository.findOneBy({ 54 | product_id: id, 55 | }); 56 | 57 | if (!foundProduct) { 58 | throw new HttpException( 59 | `Product with id ${id} not found.`, 60 | HttpStatus.NOT_FOUND, 61 | ); 62 | } 63 | 64 | await this.productRepository.delete(id); 65 | return foundProduct.product_id; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@nestjs/common": "^8.0.0", 25 | "@nestjs/config": "^2.1.0", 26 | "@nestjs/core": "^8.0.0", 27 | "@nestjs/mapped-types": "^1.0.1", 28 | "@nestjs/platform-express": "^8.0.0", 29 | "@nestjs/swagger": "^5.2.1", 30 | "@nestjs/typeorm": "^8.1.4", 31 | "class-transformer": "^0.5.1", 32 | "class-validator": "^0.13.2", 33 | "helmet": "^5.1.0", 34 | "mysql2": "^2.3.3", 35 | "reflect-metadata": "^0.1.13", 36 | "rimraf": "^3.0.2", 37 | "rxjs": "^7.2.0", 38 | "swagger-ui-express": "^4.4.0", 39 | "typeorm": "^0.3.7" 40 | }, 41 | "devDependencies": { 42 | "@nestjs/cli": "^8.0.0", 43 | "@nestjs/schematics": "^8.0.0", 44 | "@nestjs/testing": "^8.4.7", 45 | "@types/express": "^4.17.13", 46 | "@types/jest": "27.5.0", 47 | "@types/node": "^16.0.0", 48 | "@types/supertest": "^2.0.11", 49 | "@typescript-eslint/eslint-plugin": "^5.0.0", 50 | "@typescript-eslint/parser": "^5.0.0", 51 | "eslint": "^8.0.1", 52 | "eslint-config-prettier": "^8.3.0", 53 | "eslint-plugin-prettier": "^4.0.0", 54 | "jest": "28.0.3", 55 | "prettier": "^2.3.2", 56 | "source-map-support": "^0.5.20", 57 | "supertest": "^6.1.3", 58 | "ts-jest": "28.0.1", 59 | "ts-loader": "^9.2.3", 60 | "ts-node": "^10.0.0", 61 | "tsconfig-paths": "4.0.0", 62 | "typescript": "^4.3.5" 63 | }, 64 | "jest": { 65 | "moduleFileExtensions": [ 66 | "js", 67 | "json", 68 | "ts" 69 | ], 70 | "rootDir": "src", 71 | "testRegex": ".*\\.spec\\.ts$", 72 | "transform": { 73 | "^.+\\.(t|j)s$": "ts-jest" 74 | }, 75 | "collectCoverageFrom": [ 76 | "**/*.(t|j)s" 77 | ], 78 | "coverageDirectory": "../coverage", 79 | "testEnvironment": "node" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/products/test/products.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { getRepositoryToken } from '@nestjs/typeorm'; 4 | import { Repository } from 'typeorm'; 5 | import { Product } from '../product.entity'; 6 | import { ProductsService } from '../products.service'; 7 | import { mockProductsRepository } from './mockProductsRepository'; 8 | import { productsStub, productStub } from './product.stub'; 9 | 10 | describe('ProductsService', () => { 11 | let service: ProductsService; 12 | let productRepository: Repository; 13 | 14 | beforeEach(async () => { 15 | const module: TestingModule = await Test.createTestingModule({ 16 | providers: [ 17 | ProductsService, 18 | { 19 | provide: getRepositoryToken(Product), 20 | useValue: mockProductsRepository, 21 | }, 22 | ], 23 | }).compile(); 24 | 25 | service = module.get(ProductsService); 26 | productRepository = module.get(getRepositoryToken(Product)); 27 | }); 28 | 29 | afterEach(() => { 30 | jest.clearAllMocks(); 31 | }); 32 | 33 | it('should be defined', () => { 34 | expect(service).toBeDefined(); 35 | }); 36 | 37 | describe('getAll', () => { 38 | it('should return an array of products', async () => { 39 | const cats = await service.getAll(); 40 | expect(cats).toEqual(productsStub()); 41 | }); 42 | }); 43 | 44 | describe('getOneById', () => { 45 | it('should get a single product', () => { 46 | expect(service.getOneById(1)).resolves.toEqual(productStub()); 47 | expect(productRepository.findOneOrFail).toBeCalledWith({ 48 | where: { product_id: 1 }, 49 | }); 50 | }); 51 | 52 | it("should return an http error when product id doesn't exist", async () => { 53 | const id = 10; 54 | const spy = jest 55 | .spyOn(productRepository, 'findOneOrFail') 56 | .mockImplementationOnce(() => { 57 | throw new HttpException( 58 | `Product with id ${id} not found.`, 59 | HttpStatus.NOT_FOUND, 60 | ); 61 | }); 62 | await expect(service.getOneById(id)).rejects.toThrowError(HttpException); 63 | expect(spy).toBeCalledTimes(1); 64 | expect(spy).toBeCalledWith({ 65 | where: { product_id: id }, 66 | }); 67 | }); 68 | }); 69 | 70 | describe('create', () => { 71 | it('should successfully create a product', () => { 72 | expect(service.create(productStub())).resolves.toEqual(productStub()); 73 | expect(productRepository.create).toBeCalledTimes(1); 74 | expect(productRepository.create).toBeCalledWith(productStub()); 75 | expect(productRepository.save).toBeCalledTimes(1); 76 | }); 77 | }); 78 | 79 | describe('update', () => { 80 | it('should update a product', async () => { 81 | const updatedProduct = await service.update(1, productStub()); 82 | expect(updatedProduct).toEqual({ 83 | ...productStub(), 84 | updated_at: updatedProduct.updated_at, 85 | }); 86 | expect(productRepository.findOneBy).toBeCalledTimes(1); 87 | expect(productRepository.findOneBy).toBeCalledWith({ 88 | product_id: 1, 89 | }); 90 | expect(productRepository.save).toBeCalledTimes(1); 91 | expect(productRepository.save).toBeCalledWith(updatedProduct); 92 | }); 93 | 94 | it("should return an http error when product id doesn't exist", async () => { 95 | const id = 10; 96 | const spy = jest 97 | .spyOn(productRepository, 'findOneBy') 98 | .mockImplementationOnce(() => undefined); 99 | await expect(service.update(id, productStub())).rejects.toThrowError( 100 | HttpException, 101 | ); 102 | expect(spy).toHaveBeenCalledTimes(1); 103 | expect(spy).toHaveBeenCalledWith({ 104 | product_id: id, 105 | }); 106 | expect(productRepository.save).toHaveBeenCalledTimes(0); 107 | }); 108 | }); 109 | 110 | describe('delete', () => { 111 | it('should delete a product', async () => { 112 | const deletedId = await service.delete(1); 113 | expect(deletedId).toEqual(1); 114 | expect(productRepository.findOneBy).toBeCalledTimes(1); 115 | expect(productRepository.findOneBy).toBeCalledWith({ 116 | product_id: 1, 117 | }); 118 | expect(productRepository.delete).toBeCalledTimes(1); 119 | }); 120 | 121 | it("should return an http error when product id doesn't exist", async () => { 122 | const id = 10; 123 | const spy = jest 124 | .spyOn(productRepository, 'findOneBy') 125 | .mockImplementationOnce(() => undefined); 126 | await expect(service.delete(id)).rejects.toThrowError(HttpException); 127 | expect(spy).toBeCalledTimes(1); 128 | expect(spy).toBeCalledWith({ 129 | product_id: id, 130 | }); 131 | expect(productRepository.delete).toBeCalledTimes(0); 132 | }); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Table of contents 2 | 3 | - [Description](#Description) 4 | - [Explanation](#Explanation) 5 | - [Installation](#Installation) 6 | - [Configuration](#1-Configuration) 7 | - [Database](#2-Database) 8 | - [Nest.js API](#3-Nest.js-API) 9 | - [OpenAPI Specification](#4-OpenAPI-Specification) 10 | - [Unit Testing](#5-Unit-Testing) 11 | 12 | ## Description 13 | 14 | A [Nest.js](https://github.com/nestjs/nest) REST API demo project that is linked to a docker MySQL database. The API performs CRUD operations on a table named "products" in the database. 15 | 16 | ## Explanation 17 | 18 | As requested in the challenge's pdf, this section contains an explanation of why the tools used in this project were picked. 19 | 20 | - **Nest.js as the Node.js Framework**
21 | By taking a look at the requirements, Nest.js was the framework that comes with most of the required features. It is a Node.js framework that uses Express.js under the hood and is highly opinionated framework, which enforces an MVC architecture making it less prone to errors, it uses TypeScript as its primary language, and Nest.js CLI comes with a default testing environment which is configured with Jest.
22 | Comparing this with the other popular frameworks, such as Express.js, Nest.js wins in the case since Express is a very unopinionated framework that uses JavaScript and using it with TypeScript would require extra time and effort. 23 | 24 | - **MySQL as the Database**
25 | Since there was a choice between MySQL and PostgreSQL in the requirements and both of them are relational databases, I picked MySQL. Both of the aforementioned databases would be a good fit for this project. PostgreSQL is more feasible if the project is going to scale with complex queries, a variety of data types, and analytical processing. However for a smaller project that does not need feature-rich database queries, MySQL is faster, and more widely used, that is why I picked it. 26 | 27 | - **TypeORM as the ORM Library**
28 | TypeORM is built for TypeScript and is recommended when using TypeScript in a project. On the other hand, using Sequelize with TypeScript requires extra packages. In my opinion, this makes TypeORM more suitable for using it with Nest.js. 29 | 30 | - **class-validator as the Validation Package**
31 | In the [Nest.js documentation](https://docs.nestjs.com/techniques/validation), it is recommended to use the `class-validator` package because Nest.js's `ValidationPipe` already makes use of it. After further reading, I found that this package already has all the validation that I need in this project and decided to use it. 32 | 33 | ## Installation 34 | 35 | To install this project and run it locally, please clone the project first, navigate to the root directory and run `npm i` to install the required packaged, and then follow the following instructions: 36 | 37 | ### 1) Configuration 38 | 39 | Create a .env file in the root diectory, add the following keys, and provide values of your choice to the keys without values: 40 | 41 | ``` 42 | MYSQL_ROOT_PASSWORD 43 | MYSQL_USER 44 | MYSQL_PASSWORD 45 | MYSQL_PORT 46 | API_PORT 47 | MYSQL_HOST=localhost 48 | MYSQL_DATABASE=products_db 49 | ``` 50 | 51 | These keys will be used throughout the project for the database connection. Since the database runs in a docker container in this project, `MYSQL_HOST` value would be equal to `localhost`, and `MYSQL_DATABASE` is the name of the database that the project will use. The key `API_PORT` is the port on which the API will run. 52 | 53 | ### 2) Database 54 | 55 | The database has one table called `products` and has the following structure: 56 | 57 | | Column Name | Data Type | 58 | | ----------- | ------------ | 59 | | product_id | INT | 60 | | name | VARCHAR(255) | 61 | | price | FLOAT(10,2) | 62 | | description | TEXT | 63 | | created_at | DATETIME | 64 | | updated_at | DATETIME | 65 | 66 | The first step is running the database docker container and seeding the database. Open the the terminal and run the following commands: 67 | 68 | Create a local volume for the container 69 | 70 | ```bash 71 | $ docker volume create --name mysql_products_volume -d local 72 | ``` 73 | 74 | Run the database container in the background 75 | 76 | ```bash 77 | $ docker-compose up -d 78 | ``` 79 | 80 | After running these commands, the database container will be up and running in docker in the background. The [docker-compose.yml](https://github.com/maryamaljanabi/demo-project/blob/master/docker-compose.yml) file configures Adminer client to interact with the database. To access the seeded database through Adminer GUI, go to: 81 | http://localhost:8080/ 82 | 83 | And fill the following fields based on the values specified in the .env file for each key name (except the Server field). 84 | 85 | | Field | .ENV Value | 86 | | -------- | ----------------- | 87 | | Server | mysql-products-db | 88 | | Username | MYSQL_USER | 89 | | Password | MYSQL_PASSWORD | 90 | | Database | MYSQL_DATABASE | 91 | 92 | ### 3) Nest.js API 93 | 94 | To start the application with the server listening for HTTP requests on the specified port in the `main.ts` file, _which in this application is port 3000_, run the following command in the terminal: 95 | 96 | ```bash 97 | $ npm run start 98 | ``` 99 | 100 | Or to automatically watch for changes: 101 | 102 | ```bash 103 | $ npm run start:dev 104 | ``` 105 | 106 | The application now should be running on the port specified in the `.env` file with the key `API_PORT`. 107 | 108 | The available API endpoints are as follows: 109 | 110 | - Get all products 111 | 112 | ``` 113 | [GET]: http://localhost:API_PORT/products 114 | ``` 115 | 116 | - Get one product by id. If id doesn't exist, throws an error. 117 | 118 | ``` 119 | [GET]: http://localhost:API_PORT/products/:id 120 | ``` 121 | 122 | - Create a product. Must provide the product in the body as follows: 123 | 124 | ``` 125 | { name* string 126 | price* number 127 | description* string 128 | } 129 | ``` 130 | 131 | ``` 132 | [POST]: http://localhost:API_PORT/products 133 | ``` 134 | 135 | - Update a product by id. If id doesn't exist, throws an error. Can provide any field _(name, price, or description)_ to be updated. 136 | 137 | ``` 138 | [PATCH]: http://localhost:API_PORT/products/:id 139 | ``` 140 | 141 | - Delete a product by id. If id doesn't exist, throws an error. 142 | 143 | ``` 144 | [DELETE]: http://localhost:API_PORT/products/:id 145 | ``` 146 | 147 | ### 4) OpenAPI Specification 148 | 149 | This project is configured with Swagger for OpenAPI Specification. To check the Swagger UI of this application, go to: 150 | 151 | ``` 152 | http://localhost:API_PORT/api/ 153 | ``` 154 | 155 | ### 5) Unit Testing 156 | 157 | As specified in the challenge's pdf file, the unit tests were written using jest for the `products.service.ts`. To run the tests, use the following command: 158 | 159 | ```bash 160 | $ npm test products.service 161 | ``` 162 | 163 | The output should be as follows: 164 | 165 | ```bash 166 | Test Suites: 1 passed, 1 total 167 | Tests: 9 passed, 9 total 168 | Snapshots: 0 total 169 | Time: 7.114 s 170 | ``` 171 | --------------------------------------------------------------------------------