├── .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 |
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 |
--------------------------------------------------------------------------------