├── .dockerignore ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── docker-compose.yml ├── nest-cli.json ├── nodemon-debug.json ├── nodemon.json ├── package-lock.json ├── package.json ├── src ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── config.ts ├── contacts │ ├── actions │ │ └── contact.actions.ts │ ├── contacts.module.ts │ ├── controllers │ │ └── contacts.controller.ts │ ├── entities │ │ └── contact.entity.ts │ ├── gateways │ │ └── contacts.gateway.ts │ ├── models │ │ └── contact.ts │ └── services │ │ └── contacts.service.ts ├── core │ └── adapters │ │ └── redis-io.adapter.ts └── main.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json └── tslint.json /.dockerignore: -------------------------------------------------------------------------------- 1 | test 2 | node_modules 3 | dist 4 | .git 5 | .idea 6 | data 7 | volumes 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | data/* 4 | .idea 5 | volumes 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:carbon-alpine as builder 2 | WORKDIR /tmp/ 3 | 4 | COPY . . 5 | RUN npm install 6 | RUN npm run build 7 | 8 | FROM node:carbon-alpine 9 | 10 | WORKDIR /app 11 | COPY --from=builder /tmp ./ 12 | ENV NODE_ENV production 13 | ENTRYPOINT ["npm", "run"] 14 | CMD ["start"] 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | # NestJS Contacts API 6 | 7 | This project uses [@nestjsx/crud](https://github.com/nestjsx/crud) to simplify and standardize the REST API 8 | 9 | This backend is based on NestJS Framework V6 (https://nestjs.com/) 10 | 11 | - DB: Postgres 12 | - Websockets: Socket.io 13 | - Synchronises sockets thourgh Redis adapter for horizontal scaling 14 | 15 | 16 | **Frontend is available here: https://github.com/avatsaev/angular-contacts-app-example** 17 | 18 | 19 | ## Env VARS: 20 | SERVER_PORT: 3000 21 | POSTGRES_HOST: db 22 | POSTGRES_PORT: 5432 23 | POSTGRES_USER: contacts_db 24 | POSTGRES_PASSWORD: contacts_db_pass 25 | POSTGRES_DB: contacts_db 26 | REDIS_HOST: redis 27 | REDIS_PORT: 6379 28 | REDIS_ENABLED: 'true' 29 | 30 | ## Run 31 | 32 | `docker-compose up --build ` 33 | 34 | Server will be running on http://localhost:3000 35 | 36 | # Endpoints 37 | 38 | - `GET /contacts` : returns an array of `Contacts` 39 | - `GET /contacts/:id`: returns a `Contact` shape 40 | - `POST /contacts`: Create a contact, returns a `Contact` shape 41 | - `PATCH /contacts/:id`: Partially update a `Contact`, returns a `Contact` shape 42 | - `DELETE /contacts/:id`: Delete a `Contact`, empty response 43 | 44 | ### Contact shape: 45 | ```typescript 46 | interface Contact { 47 | id?: number | string; 48 | name: string; 49 | email: string; 50 | phone?: string; 51 | } 52 | ``` 53 | ## Installation 54 | 55 | ```bash 56 | $ npm install 57 | ``` 58 | 59 | ## Running the app 60 | 61 | ```bash 62 | # development 63 | $ npm run start 64 | 65 | # watch mode 66 | $ npm run start:dev 67 | 68 | # production mode 69 | npm run start:prod 70 | ``` 71 | 72 | ## Test 73 | 74 | ```bash 75 | # unit tests 76 | $ npm run test 77 | 78 | # e2e tests 79 | $ npm run test:e2e 80 | 81 | # test coverage 82 | $ npm run test:cov 83 | ``` 84 | 85 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | version: '3' 3 | 4 | services: 5 | db: 6 | image: postgres:11-alpine 7 | restart: always 8 | ports: 9 | - 5432:5432 10 | volumes: 11 | - ./volumes/contacts_db:/contacts_db 12 | environment: 13 | POSTGRES_USER: contacts_db 14 | POSTGRES_PASSWORD: contacts_db_pass 15 | POSTGRES_DB: contacts_db 16 | PGDATA: /contacts_db 17 | 18 | 19 | redis: 20 | image: redis:5-alpine 21 | volumes: 22 | - ./volumes/redis:/data 23 | 24 | api: 25 | build: . 26 | restart: always 27 | ports: 28 | - 3000:3000 29 | depends_on: 30 | - db 31 | - redis 32 | environment: 33 | SERVER_PORT: 3000 34 | POSTGRES_HOST: db 35 | POSTGRES_PORT: 5432 36 | POSTGRES_USER: contacts_db 37 | POSTGRES_PASSWORD: contacts_db_pass 38 | POSTGRES_DB: contacts_db 39 | REDIS_HOST: redis 40 | REDIS_PORT: 6379 41 | REDIS_ENABLED: 'true' 42 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /nodemon-debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "node --inspect-brk -r ts-node/register -r tsconfig-paths/register src/main.ts" 6 | } 7 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "ts-node -r tsconfig-paths/register src/main.ts" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contacts-api", 3 | "version": "2.0.0", 4 | "description": "", 5 | "author": "", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "tsc -p tsconfig.build.json", 9 | "format": "prettier --write \"src/**/*.ts\"", 10 | "start": "ts-node -r tsconfig-paths/register src/main.ts", 11 | "start:dev": "nodemon", 12 | "start:debug": "nodemon --config nodemon-debug.json", 13 | "prestart:prod": "rimraf dist && npm run build", 14 | "start:prod": "node dist/main.js", 15 | "lint": "tslint -p tsconfig.json -c tslint.json", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^6.3.1", 24 | "@nestjs/core": "^6.3.1", 25 | "@nestjs/platform-express": "^6.3.1", 26 | "@nestjs/platform-socket.io": "^6.3.1", 27 | "@nestjs/swagger": "^3.0.2", 28 | "@nestjs/typeorm": "^6.1.2", 29 | "@nestjs/websockets": "^6.3.1", 30 | "@nestjsx/crud": "^3.2.0", 31 | "class-transformer": "^0.2.0", 32 | "class-validator": "^0.9.1", 33 | "pg": "^7.11.0", 34 | "reflect-metadata": "^0.1.12", 35 | "rimraf": "^2.6.2", 36 | "rxjs": "^6.5.2", 37 | "socket.io-redis": "^5.2.0", 38 | "swagger": "^0.7.5", 39 | "typeorm": "^0.2.18" 40 | }, 41 | "devDependencies": { 42 | "@nestjs/testing": "^6.3.1", 43 | "@types/express": "^4.16.0", 44 | "@types/jest": "^23.3.13", 45 | "@types/node": "^10.12.18", 46 | "@types/socket.io": "^2.1.2", 47 | "@types/socket.io-redis": "^1.0.25", 48 | "@types/supertest": "^2.0.7", 49 | "jest": "^23.6.0", 50 | "nodemon": "^1.18.9", 51 | "prettier": "^1.15.3", 52 | "supertest": "^3.4.1", 53 | "ts-jest": "^23.10.5", 54 | "ts-node": "^7.0.1", 55 | "tsconfig-paths": "^3.7.0", 56 | "tslint": "5.12.1", 57 | "typescript": "^3.5.1" 58 | }, 59 | "jest": { 60 | "moduleFileExtensions": [ 61 | "js", 62 | "json", 63 | "ts" 64 | ], 65 | "rootDir": "src", 66 | "testRegex": ".spec.ts$", 67 | "transform": { 68 | "^.+\\.(t|j)s$": "ts-jest" 69 | }, 70 | "coverageDirectory": "../coverage", 71 | "testEnvironment": "node" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | 4 | describe('AppController', () => { 5 | let appController: AppController; 6 | 7 | beforeEach(async () => { 8 | const app: TestingModule = await Test.createTestingModule({ 9 | controllers: [AppController], 10 | }).compile(); 11 | 12 | appController = app.get(AppController); 13 | }); 14 | 15 | describe('root', () => { 16 | it('should return "Hello World!"', () => { 17 | expect(appController.getHello()).toEqual({ 18 | status: 'ok' 19 | }); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | @Controller() 4 | export class AppController { 5 | 6 | @Get() 7 | getHello() { 8 | return { 9 | status: 'ok' 10 | }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import {TypeOrmModule} from '@nestjs/typeorm'; 4 | import { config } from './config'; 5 | import { ContactsModule } from './contacts/contacts.module'; 6 | 7 | @Module({ 8 | imports: [ 9 | TypeOrmModule.forRoot(config.database), 10 | ContactsModule, 11 | ], 12 | controllers: [AppController] 13 | }) 14 | export class AppModule {} 15 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionOptions } from 'typeorm'; 2 | 3 | export interface AppConfiguration { 4 | serverPort: number; 5 | redis?: { 6 | host: string, 7 | port: number, 8 | enabled: boolean | string, 9 | }; 10 | database: ConnectionOptions; 11 | } 12 | 13 | export const config: AppConfiguration = { 14 | serverPort: Number(process.env.SERVER_PORT) || 3000, 15 | redis: { 16 | host: process.env.REDIS_HOST || '127.0.0.1', 17 | port: Number(process.env.REDIS_PORT) || 6379, 18 | enabled: process.env.REDIS_ENABLED || false 19 | }, 20 | database: { 21 | type: 'postgres', 22 | host: process.env.POSTGRES_HOST || 'localhost', 23 | port: Number(process.env.POSTGRES_PORT) || 5432, 24 | username: process.env.POSTGRES_USER || 'contacts_db', 25 | password: process.env.POSTGRES_PASSWORD || 'contacts_db_pass', 26 | database: process.env.POSTGRES_DB || 'contacts_db', 27 | entities: [`${__dirname}/**/entities/*.{js,ts}`], 28 | synchronize: true, 29 | replication: process.env.DB_MASTER_URL && process.env.DB_REPLICAS_URL ? { 30 | master: { 31 | url: process.env.DB_MASTER_URL, 32 | }, 33 | slaves: process.env.DB_REPLICAS_URL ? [ 34 | {url: process.env.DB_REPLICAS_URL}, 35 | ] : [], 36 | } : null, 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/contacts/actions/contact.actions.ts: -------------------------------------------------------------------------------- 1 | export enum CONTACTS_ACTIONS { 2 | LIVE_CREATED = '[Contacts] LIVE CREATED', 3 | LIVE_UPDATED = '[Contacts] LIVE UPDATED', 4 | LIVE_DELETED = '[Contacts] LIVE DELETED', 5 | } 6 | -------------------------------------------------------------------------------- /src/contacts/contacts.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import {TypeOrmModule} from '@nestjs/typeorm'; 4 | import { ContactEntity } from './entities/contact.entity'; 5 | import { ContactsService } from './services/contacts.service'; 6 | import { ContactsGateway } from './gateways/contacts.gateway'; 7 | import { ContactsController } from './controllers/contacts.controller'; 8 | 9 | @Module({ 10 | imports: [ 11 | TypeOrmModule.forFeature([ContactEntity]), 12 | ], 13 | providers: [ContactsService, ContactsGateway], 14 | controllers: [ContactsController] 15 | }) 16 | export class ContactsModule {} 17 | -------------------------------------------------------------------------------- /src/contacts/controllers/contacts.controller.ts: -------------------------------------------------------------------------------- 1 | import {Controller} from '@nestjs/common'; 2 | import {ContactsService} from '../services/contacts.service'; 3 | import {ContactEntity} from '../entities/contact.entity'; 4 | import { Crud } from '@nestjsx/crud'; 5 | 6 | @Crud(ContactEntity, { 7 | routes: { 8 | deleteOneBase: { 9 | returnDeleted: true 10 | } 11 | } 12 | }) 13 | @Controller('contacts') 14 | export class ContactsController { 15 | 16 | constructor(public readonly service: ContactsService) {} 17 | } 18 | -------------------------------------------------------------------------------- /src/contacts/entities/contact.entity.ts: -------------------------------------------------------------------------------- 1 | import {Column, CreateDateColumn, Entity, PrimaryColumn, PrimaryGeneratedColumn, UpdateDateColumn} from 'typeorm'; 2 | import {Contact} from '../models/contact'; 3 | 4 | @Entity() 5 | export class ContactEntity implements Contact { 6 | 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @PrimaryColumn() 11 | @Column({ 12 | nullable: false, 13 | length: 500, 14 | unique: true 15 | }) 16 | name: string; 17 | 18 | @PrimaryColumn() 19 | @Column({ 20 | unique: true, 21 | nullable: false, 22 | }) 23 | email: string; 24 | 25 | @Column({ 26 | unique: true, 27 | nullable: false, 28 | }) 29 | phone: string; 30 | 31 | @CreateDateColumn() 32 | createdAt; 33 | 34 | @UpdateDateColumn() 35 | updatedAt; 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/contacts/gateways/contacts.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OnGatewayConnection, 3 | OnGatewayDisconnect, 4 | OnGatewayInit, 5 | WebSocketGateway, 6 | WebSocketServer 7 | } from '@nestjs/websockets'; 8 | 9 | import {Contact} from '../models/contact'; 10 | import {CONTACTS_ACTIONS} from '../actions/contact.actions'; 11 | 12 | @WebSocketGateway({ 13 | namespace: '/contacts' 14 | }) 15 | export class ContactsGateway implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit { 16 | 17 | @WebSocketServer() private server; 18 | 19 | afterInit() { 20 | console.log('socket initialized'); 21 | } 22 | 23 | handleConnection(client) { 24 | console.log('client connected'); 25 | } 26 | 27 | handleDisconnect(client) { 28 | console.log('client disconnected'); 29 | } 30 | 31 | contactCreated(contact: Contact) { 32 | console.log('CT-GATEWAY: contact created', contact); 33 | this.server.emit(CONTACTS_ACTIONS.LIVE_CREATED, contact); 34 | } 35 | 36 | contactUpdated(contact: Contact) { 37 | console.log('CT-GATEWAY: contact updated', contact); 38 | this.server.emit(CONTACTS_ACTIONS.LIVE_UPDATED, contact); 39 | } 40 | 41 | contactDeleted(id: number) { 42 | console.log('CT-GATEWAY: contact deleted', id); 43 | this.server.emit(CONTACTS_ACTIONS.LIVE_DELETED, id); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/contacts/models/contact.ts: -------------------------------------------------------------------------------- 1 | export interface Contact { 2 | id?: number | string; 3 | name: string; 4 | email: string; 5 | phone?: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/contacts/services/contacts.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import {ContactEntity} from '../entities/contact.entity'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { RepositoryService } from '@nestjsx/crud/typeorm'; 5 | import { DeleteOneRouteOptions, FilterParamParsed, UpdateOneRouteOptions } from '@nestjsx/crud'; 6 | import { ContactsGateway } from '../gateways/contacts.gateway'; 7 | import { DeepPartial } from 'typeorm'; 8 | 9 | @Injectable() 10 | export class ContactsService extends RepositoryService { 11 | 12 | constructor( 13 | @InjectRepository(ContactEntity) repo, 14 | private contactsGateway: ContactsGateway 15 | ) { 16 | super(repo); 17 | } 18 | 19 | async createOne(data: ContactEntity, params: FilterParamParsed[]): Promise { 20 | const contact = await super.createOne(data, params); 21 | this.contactsGateway.contactCreated(contact); 22 | return contact; 23 | } 24 | 25 | async updateOne(data: DeepPartial, params?: FilterParamParsed[], routeOptions?: UpdateOneRouteOptions): Promise { 26 | const contact = await super.updateOne(data, params, routeOptions); 27 | this.contactsGateway.contactUpdated(contact); 28 | return contact; 29 | } 30 | 31 | async deleteOne(params: FilterParamParsed[], routeOptions?: DeleteOneRouteOptions): Promise { 32 | await super.deleteOne(params, routeOptions); 33 | this.contactsGateway.contactDeleted(params[0].value); 34 | return undefined; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/core/adapters/redis-io.adapter.ts: -------------------------------------------------------------------------------- 1 | import { IoAdapter } from '@nestjs/platform-socket.io'; 2 | import * as redisIoAdapter from 'socket.io-redis'; 3 | import { INestApplicationContext } from '@nestjs/common'; 4 | 5 | export class RedisIoAdapter extends IoAdapter { 6 | 7 | constructor( 8 | private readonly redisConfig: { host: string, port: number }, 9 | appOrHttpServer?: INestApplicationContext | any 10 | ) { 11 | super(appOrHttpServer); 12 | } 13 | 14 | createIOServer(port: number, options?: any): any { 15 | const redisAdapter = redisIoAdapter(this.redisConfig); 16 | const server = super.createIOServer(port, options); 17 | server.adapter(redisAdapter); 18 | return server; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { RedisIoAdapter } from './core/adapters/redis-io.adapter'; 4 | import { config } from './config'; 5 | 6 | (async () => { 7 | const app = await NestFactory.create(AppModule, {cors: true}); 8 | if (config.redis.enabled) { 9 | app.useWebSocketAdapter(new RedisIoAdapter({ 10 | host: config.redis.host, 11 | port: config.redis.port 12 | })); 13 | console.log('INFO: Redis adapter enabled'); 14 | } 15 | await app.listen(config.serverPort); 16 | console.info('SERVER IS RUNNING ON PORT', config.serverPort); 17 | })(); 18 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as request from 'supertest'; 3 | import { AppModule } from './../src/app.module'; 4 | 5 | describe('AppController (e2e)', () => { 6 | let app; 7 | 8 | beforeEach(async () => { 9 | const moduleFixture: TestingModule = await Test.createTestingModule({ 10 | imports: [AppModule], 11 | }).compile(); 12 | 13 | app = moduleFixture.createNestApplication(); 14 | await app.init(); 15 | }); 16 | 17 | it('/ (GET)', () => { 18 | return request(app.getHttpServer()) 19 | .get('/') 20 | .expect(200) 21 | .expect('Hello World!'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es6", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./" 12 | }, 13 | "exclude": ["node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single"], 9 | "member-access": [false], 10 | "trailing-comma": false, 11 | "no-trailing-whitespace": false, 12 | "no-console": false, 13 | "ordered-imports": [false], 14 | "max-line-length": [true, 150], 15 | "member-ordering": [false], 16 | "interface-name": [false], 17 | "arrow-parens": false, 18 | "object-literal-sort-keys": false 19 | }, 20 | "rulesDirectory": [] 21 | } 22 | --------------------------------------------------------------------------------