# NestJS Ref and Doc
###### tags: `NestJS` `TypeORM` `Swagger` `Mongoose` `Postgres` `Jest`
###### [Github Repository](https://github.com/ClydeRM/NestJS-Fundamentals-App-practice/tree/section_2)
## Start project
```
sudo npm i -g @nest/cli
nest -v
nest new project_name
git init
npm run start:dev
docker-compose up (-d)
docker exec -it __pid__ bash
psql -U acc
```
## Npm Script
```>>
測試e2e 建立Mock PG DB用
"pretest:e2e": "docker-compose up -d test-db",
"test:e2e": "jest --config ./test/jest-e2e.json",
"posttest:e2e": "docker-compose stop test-db && docker-compose rm -f test-db"
```
## Package version
```>>
"@hapi/joi": "^17.1.1" // 檢查Env value
"@nestjs/cli": "^8.2.5" // Nest Dep
"@nestjs/core": "^8.4.4"// Nest Dep
...
"@nestjs/mapped-types": "^1.0.1" // 建構Partial Dto
"@nestjs/swagger": "^5.2.1" // 建立API Document
"swagger-ui-express": "^4.3.0" // Swagger UI
"@nestjs/typeorm": "^8.0.3" // 連線與Map SQL
"rxjs": "^7.2.0"
"pg": "^8.7.1", // Postgres
"typeorm": "^0.2.41" // Typeorm
"@nestjs/mongoose": "^9.0.3" // 連線與Map NO-SQL
"mongoose": "^6.3.2" // Mongoose
dev // 使用 Jest 測試
"@nestjs/testing": "^8.4.4",
"@types/hapi__joi": "^17.1.8",
"@types/jest": "27.0.2",
"@types/supertest": "^2.0.11",
"jest": "^28.0.3", // Jest 28.0.1^ 移除jasmine
"supertest": "^6.1.3",
"typescript": "^4.3.5"
```
## Project Structure
```>>
./nest-app
├── dist // 由 tsc 編譯ts專案輸出成js專案
└── ...
├── node_module // 安裝的外部模組
├── src // ts主要開發資料夾
├── config // 全域的Env Object
└── app.config.ts
├── app.controller.spec.ts // app 路由測試檔案
├── app.controller.ts
├── app.service.ts
├── app.module.ts // 設定注入與使用subModule
├── main.ts // 主程式,設定伺服器跟端口與運作AppModule
├── XXXModule // 封裝成模組
├── configs // Partial register ENV config
├── entities // 資料的schema
└── ...
├── dto // 資料實體化的sharp
└── ...
├── constants.ts // Provider token
├── controller.ts
├── service.ts
└── module.ts
├── common // 常會用到的重複使用的邏輯,例如middleware,DBConnect,DataSchema etc.
├── decorators
├── filters
├── pipes
├── guards
├── interceptor
├── middlewares
└── common.module.ts // 包裝成Module 區域使用
├── event // Data Transaction 的模組,包含完整的路由與商業邏輯,目前暫時只有entity
└── ...
├── migration // typeorm data migration
└── ...
└── test // e2e test
├── coffees // 單一個Module CRUD e2e測試
└── ...
├── app.e2e-spec.ts
└── jest-e2e.json // jest設定檔
├── .gitignore
├── docker-compose.yml // Docker-compose file
├── .env // 全域環境變數
├── ormconfig.ts // orm config Obj
├── package.json
├── package-look.json
├── nestcli.json // 設定 nest cli 功能
└── tsconfig.json // 設定tsc 編譯的格式與規定
```
## Nest cli Command
* Flag --no-spec 表示只產生controller檔案與資料夾,不產生測試檔案
* Flag --dry-run 只顯示結果,常測試檔案名有無衝突
* Controller
```
nest g controller __name__ --no-spec
```
* Service (Provider)
```
nest g service __SameNameController__ --no-spec
```
* Module
```
nest g module __moduleName__
```
* Filter
```
nest g filter common/filters/http-exception
```
* Guard
```
nest g guard common/guards/api-key
```
* Interceptor
```
nest g interceptor common/interceptors/timeout
```
* Pipe
```
nest g pipe common/pipes/prase-int
```
* Middleware
```
nest g middleware common/middlewares/logging
```
## Decorater 裝飾子
### @Controller @Get @Post @Put @Patch @Delete
* **@Controller()** 宣告子:宣稱此物件屬於Controller類別,參數可傳入此路徑的根路徑
* **@Get()** :此方法是處理get request
* **@Post()** :此方法是處理post request,參數同上
* **@Put()** :此方法是處理put request,參數同上
* **@Patch()** :此方法是處理patch request,參數同上
* **@Delete()** :此方法是處理delete request,參數同上
### @Query()
```>>
@Get() // paginationQuery 限縮搜尋資料庫table的範圍
findAll(@Query() paginationQuery: PaginationQueryDto) {
// limit 限制多少資料數量, offset 宣告要跳過哪些列資料
// const { limit, offset } = paginationQuery;
...
}
```
### @Param()
```>>
@Get(':productId') // 使用此API,需要傳入ID
//API名稱(@Params('個別參數') 重構參數名:型態){...}
findById(@Params('productId') pId:string){
console.log(`This is product #${pId}`);
return ...
}
```
### @Body()
```>>
@Post()
//API名稱(@Body('物件名稱或內屬性')){...}
createOne(@Body() DTO:__DTO){
console.log(DTO);
return ...
}
```
## Service (Provider)
### @Injectable()
**#宣告此類別為可在任何地方注入類別**
```>>
@Injectable()
export class TestService{
...
}
```
## Module (encapsulate)
**#封裝路由與商業邏輯成一個小模組**
@Module()
```>>
@Module({
imports:[],
controllers:[],
providers:[],
exports:[],
}}
export class TestModule{}
```
1. **imports:[]** 注入哪些套件或模組
2. **controllers:[]** 使用哪些路由
3. **providers:[]** 使用哪些提供器
4. **exports:[]** 出口哪些提供器
## DTO (Data to Object)
**#將資料轉變成物件,方便使用**
**#定義資料的內容與詳細型態**
**#設定唯讀防止屬性被更改**
```>>
export class TestDto{
readonly property1: string;
readonly .....
}
```
## Entity (Data entity)
**#定義資料的實體屬性**
**#表示SQL資料庫中的資料表**
```>>
>> coffee.entity.ts
// Definition of coffee data entity.
@Entity() // sql table === 'coffee'
export class Coffee {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
brand: string;
// Many to many table
@JoinTable() // foreign table join in coffee table
@ManyToMany(
// this type set to flavor, and coffees <=> flavors
(type) => Flavor,
(flavor) => flavor.coffees,
{
cascade: true, //['insert'] auto insert new flavor data in Flavor table
},
)
flavors: Flavor[];
@Column({ default: 0 }) // recommendation of coffee
recommendation: number;
}
```
## ValidationPipe (Pipe)
**#驗證客戶端傳入的資料完整性**
[# Validation](https://docs.nestjs.com/techniques/validation)
# npm i class-validator class-transformer **for create-dto**
# npm i @nestjs/mapped-types **for update-dto**
```>>
#Method1: GlobalPipes
>>main.ts
async function bootstrap(){
...
app.useGlobalPipes(new ValidationPipe()); // 在整個App中 使用驗證
...
}
#hint npm i class-validator class-transformer
>>Test-create.dto.ts
import { IsEmail } from 'class-validator';
export class TestCreateDto{
@IsEmail()
email: string;
}
#hint npm i @nestjs/mapped-types
>>Test-update.dto.ts
import { PartialType } from '@nestjs/mapped-types'; // 利用原本的DTO去建立一個內容元素是可選擇使用的新DTO
import { TestCreateDto } from '...dto'; // 原本的DTO
export class TestUpdateDto extend PartialType(TestCreateDto){}
==================================================================================================
#Method2: WhiteList
>>main.ts
// 檢查Request資料正確性
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // 限制request,只傳入DTO規定的內容
transform: true, // 自動轉換 request Body, 將Body的型別成DTO的類別物件
forbidNonWhitelisted: true, // 抵擋request,如果request body有DTO規定外的欄位,request被攔截
transformOptions: {
enableImplicitConversion: true, // 不需要在額外標示@Type, ValidationPipe會依據原設定資料型態去驗證
},
})
```
### PaginationQuery
* 建立通用型DTO 規定limit offset的資料物件
* limit
```>>
>> common/pagination-query.dto.ts
import { IsOptional, IsPositive } from 'class-validator';
export class PaginationQueryDto {
@IsOptional() // this property is optional
@IsPositive() // this property must be a nature number
// @Type(()=>Number) // 在main.ts中的GlobalPipe設定transformOptions
// 設定enableImplictConversion 固定轉換成ts指定的type, 不再需要另外使用@Type()修飾子
limit: number;
@IsOptional()
@IsPositive()
offset: number;
}
>> main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 檢查Request資料完整性
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // 限制request,只傳入DTO規定的內容
transform: true, // 自動轉換 request Body, 將Body的型別成DTO的類別物件,會輕微影響效能
forbidNonWhitelisted: true, // 抵擋request,如果request body有DTO規定外的欄位,request被攔截
transformOptions: {
enableImplicitConversion: true, // 不需要在額外標示@Type, ValidationPipe會依據原設定資料型態去驗證
},
}),
);
await app.listen(3000);
}
>> coffees/coffees.service.ts
export class CoffeesService{
constructor(){...}
findAll(paginationQuery: PaginationQueryDto) {
// limit 限制多少資料數量, offset 宣告要跳過哪些列資料
const { limit, offset } = paginationQuery;
return this.coffeeRepository.find({
relations: ['flavors'], // 因為coffee table中,有個多對多對應元素到 foreign table,啟用relations:['foreign_table_name'],取得joinTable後的資料
skip: offset, // 跳過多少個
take: limit, // 只取得多少個
});
}
// business login...
}
```
## TypeORM Postgres
* #Package
```
npm i @nestjs/typeorm typeorm pg
```
* #docker-compose.yml
```>>
version: "3"
services:
db:
image: postgres
restart: always
ports:
- "5432:5432"
environment:
POSTGRES_PASSWORD: PWD
```
### 使用
* forRoot() 只要在根模組設定一次就好
* forFeature() 在對應的子模組設定到指定的entity
```>>
>> app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'IP',
port: 5432,
username: 'ACC',
password: 'PWD',
database: 'DB',
autoLoadEntities: true, // If option not found, try reinstall @nestjs/typeorm
synchronize: true, // shouldn't be used in production - otherwise you can lose production data #自動產生entity的SQL table
}),
CoffeeRatingModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
>> test.module.ts
import { Injectable, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [TypeOrmModule.forFeature([Coffee, Flavor, Event])], // Use typeorm mapping entity and sql table
})
export class CoffeesModule {}
```
### 原理

* entity 將同類的資料聚集成一個Repository,並可被注入
* entity 也表示資料在SQL中的schema
```>>
>> test.service.ts
import { InjectRepository } from '@nestjs/typeorm';
@Injectable()
export class CoffeesService {
constructor(
@InjectRepository(Coffee) // Use Repository register coffee entity to PGSql
private readonly coffeeRepository: Repository<Coffee>){}
business logic.....
}
```
### Decorator
* @JoinTable()
Helps specify the OWNER side of the relationship
表示此Table是 Primary Table
* @OneToOne(type => entity, ()=>{})
In Primary Table each row has only one related row in Foreign Table
* @OneToMany(type...) / @ManyToOne(...)
In Primary Table each row has one or more than one related row in Foreign Table
* @ManyToMany(type => entity, (元素)=>{對應到Table中的哪一個元素})
In Primary Table each row many related rows in Foreign Table
```>>
>> coffees/entities/coffee.entity.ts
// Definition of coffee data entity.
@Entity() // sql table === 'coffee'
export class Coffee {
@PrimaryGeneratedColumn()
id: number;
@Column()...
// Many to many table
@JoinTable() // foreign table join in coffee table
@ManyToMany(
// this type set to flavor, and coffees <=> flavors
(type) => Flavor,
(flavor) => flavor.coffees,
{
// detail setting...
},
)
flavors: Flavor[];
}
>> coffees/entities/flavor.entity.ts
@Entity()
export class Flavor {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@ManyToMany((type) => Coffee, (coffee) => coffee.flavors)
coffees: Coffee[];
}
```
### Fetch Data
* hint: find function detail
```>>
>> coffee.service.ts
findAll(...: ...Dto) {
return this.coffeeRepository.find({
relations: ['flavors'],
// 因為coffee table中,有個多對多對應元素到 foreign table,
// 啟用relations:['foreign_table_name'],取得joinTable後的資料
});
}
```
### Cascading Insert (串連插入新資料)
* 聯集多Table時,新增一筆資料將同時插入至集合中的其他子Table
```>>
>> coffee.entity.ts
@JoinTable() // foreign table join in coffee table
@ManyToMany(
// this type set to flavor, and coffees <=> flavors
(type) => Flavor,
(flavor) => flavor.coffees,
{
// we can also limit cascades to just insert OR update
cascade: true, //['insert'] auto insert new flavor data in Flavor table, Can
},
)
flavors: Flavor[];
```
### Transaction
* 假如同時有多個endpoint想修改同一筆資料
* 為保全資料在DB中的完整性,需要將資料狀態儲存成一個"Event"
* 在前一個"Event"完成後在進行下一步"Event"
* 這種安全措施稱為"Transaction"

#### QueryRunner
* Connection 模組: 建立QueryRunner 連線
* 步驟
1. 建立QueryRunner連線物件
2. 等待連線
3. 啟動Transaction
4. Try:建立"Event" => 儲存"Event"
5. Catch: 發生錯誤就"Roll Back",防止資料不同步
6. Finally: 關閉QueryRunner連線
```>>
>> coffees/coffees.service.ts
import { Connection, Repository } from 'typeorm';
...
@Injectable()
export class CoffeesService {
constructor(
...
private readonly connection: Connection, // For data transaction
){}
async recommendCoffee(coffee: Coffee) {
// In real world application, this should be wrapped in a single module
// Handle user to recommend coffee
const queryRunner = this.connection.createQueryRunner();
// Open connection
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Increase recommendations number, and save new recommendEvent data, commitTransaction
coffee.recommendation++;
const recommendEvent = new Event();
recommendEvent.name = 'recommend_coffee';
recommendEvent.type = 'coffee';
recommendEvent.payload = { coffeeId: coffee.id };
await queryRunner.manager.save(coffee);
await queryRunner.manager.save(recommendEvent);
await queryRunner.commitTransaction();
} catch (error) {
// If recommendEvent object has wrong property, throw error and rollbackTransaction
await queryRunner.rollbackTransaction();
} finally {
// When anything is finish, close and release the transaction
await queryRunner.release();
}
}
// buniness logic ...
}
```
#### @Index()
* 加快 fetch data的速度
* Single index
```>>
>> event/entities/event.entity.ts
import {Index} from 'typeorm';
@Entity()
export class Event{
@PrimaryGeneratedColumn()
...
@Index() // 指定此欄位為Index
@Column()
...
}
```
* Composite index
```>>
>> event/entities/event.entity.ts
import {Index} from 'typeorm';
@Index(['column1', 'column2'])
@Entity()
export class Event{
@PrimaryGeneratedColumn()
...
@Column()
...
}
```
### Migration
* DB migrations "提供開發者在**已存在的資料上**"逐漸更新與同步 data schema 與 App data model (entity)
#### ormconfig.ts
* ormconfig.ts 設定migration的資料庫與migration file的生成路徑
```>>
>> /ormconfig.ts
module.exports = {
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'ACC',
password: 'PWD',
database: 'DB',
entities: ['dist/**/*.entity.js'], // 編譯輸出的所有entities
migrations: ['dist/migrations/*.js'], // 編譯輸出的索有migration files
cli: {
migrationsDir: 'src/migrations', // 參考哪個路徑的migration files 做編譯
},
};
```
#### typeorm cli command
* 生成 migration.ts file
* npx 可以在不下載套件的cli下,執行套件的cli
```
npx typeorm migration:create -n _migration_file_name_
```
* 執行migration
* 記得先**關閉dev模式**
```
#1 編譯Project
npm run build
#2 手動產生migration file
npx typeorm migration:create -n _migration_file_name_
#3 執行migration
npx typeorm migration:run
successfully 表示執行成功
No migrations are depanding 表示沒有新更動被發現
#4 roll back (復原)
npx typeorm migration:revert
reverted successfully 表示復原成功
```
* 自動偵測更新 migration
* 記得先**關閉dev模式**
```
#1 編譯Project
npm run build
#2 自動偵測與生成migration file
npx typeorm migration:generate -n _FILENAME_
#3 執行migration
npx typeorm migration:run
successfully 表示執行成功
No migrations are depanding 表示沒有新更動被發現
#4 roll back (復原)
npx typeorm migration:revert
reverted successfully 表示復原成功
```
#### up()
* 宣告哪些資料屬性需要更改
```>>
>> migrations/CoffeeRefactor.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ...Refactor implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(
// SQL command
`ALTER TABLE "coffee" RENAME COLUMN "name" TO "title"`,
);
}
// down()...
}
```
#### down()
* 宣告roll back時,哪些資料屬性需要復原
```>>
>> migrations/CoffeeRefactor.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ...Refactor implements MigrationInterface {
// up() ...
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(
`ALTER TABLE "coffee" RENAME COLUMN "title" TO "name"`,
);
}
}
```
## Depandency Injection
### 步驟
1. 建立與檔案名稱相同類別的Service.ts,並加上@Injectable()的裝飾子
2. 在 Module 的 providers:[]當中加入 service
3. Nest Runtime system 就會依據Module建構時所列舉的providers 類別去建構 service 物件

* 分離商業邏輯物件類別,需要用到時在呼叫
* Container 代表整個Nest Runtime System,排程每個子模組的生命週期
* Injector 在 Module 被建構時,才會注入(Injection)

* 當Module的Controller被互叫時,需要建構Service Depandency
* 這時候被注入的Service過程,就稱為Injection
* 優點是可以無限擴充Controller的功能性,並且容易維護
### Custom Provider
* 當我們要注入現有的 Service時,可以使用 exports:[] 把現有的 Service變成公開使用,重複利用
* 也可以使用"Strategy Pattern" overwrite覆蓋現有的 Service,進而達到客製化的效果
* **使用在 unit Testing**, 也可以客製化 mockService()
### Provider Token
```>>
@Module(
imports:[...],...
providers:[
{
provide: _NameOfProviderToken_,
useClass: _WhichInjectableClass_,
// useValue: [data or obj etc...]
// useFactory: (_nameOfFactory_: FACTORYCLASS) => _nameOfFactory_.internalFunction(),
// inject: [_listOfFactoryClass_],
// scope: Scope.DEFAULT/ TRANSIENT /REQUEST // 設定Provider 的LifeCycle
}
]
){}
================================================================================================
#直接在@Injectable() 修飾子裡設定LifeCycle
@Injectable({scope: Scope.DEFAULT/ TRANSIENT /REQUEST})
export class xxService{
...
}
```
#### useValue (Mock data)
* 用來做自動化測試
* 靜態資料
* 每當Module被建構時,Service被注入,但注入的是 @Injectable() Class 物件
```>>
>> coffees/coffees.module.ts
import...
// Mock service class 建立測試資料類別
class MockCoffeesService{...}
@Module({
imports:[...],
controllers:[...],
providers:[{
provide: CoffeesService,
useValue: new MockCoffeesService() // 使用測試資料類別
}],
exports:[...]
})
======================================================
# 直接注入測試資料
>> coffees/coffees.constants.ts
// export a test PROVIDER_TOKEN_CLASS for injecting data in service
export const COFFEE_BRANDS = 'COFFEE_BRANDS';
>> coffees/coffees.module.ts
@Module({
imports:[...],
controllers:[...],
providers:[CoffeesService,
{
provide: COFFEE_BRANDS, // PROVIDER_TOKEN_CLASS
useValue: ['buddy brew', 'nescafe'] // mock data
}
],
exports:[...]
})
>> coffees/coffees.service.ts
import {...
import {COFFEE_BRANDS} from './coffees.constant.ts';
@Injectable()
export class CoffeesService{
constructor(
@InjectRepository(...)
private readonly ....
...
// 注入測試假資料
//@Inject(PROVIDER_TOKEN_CLASS)
@Inject(COFFEE_BRANDS) coffeeBrands: string[],
)
...
}
```
#### useClass()
* 依據node執行模式(開發/產品),呼叫不同的Service Class
```>>
>> coffees/coffees.module.ts
// Dynamic Service Class
class ConfigClass{...}
class DevelopmentConfigService{...}
class ProductionConfigService{...}
@Module({
imports:[...],
controller:[...],
providers:[
CoffeesService,
{
provide: ConfigService,
useClass:
process.env.NODE_ENV === 'development'
? DevelopmentConfigService
: ProductionConfigService,
}
],
exports:[...]
}){...}
```
#### useFactory()
* Create provider dynamicaclly
* 動態產生 @Injectable() Class
```>>
>> coffees/coffees.module.ts
@Module({
imports:[...],
controllers:[...],
providers:[CoffeesService,
{
provide: COFFEE_BRANDS, // PROVIDER_TOKEN_CLASS
useFactory: () => ['buddy brew', 'nescafe'] // useFactory function
}
],
exports:[...]
})
```
#### Async useFactory()
* Fetch data from DB or other Promise Obj
* 當產品服務啟動時,有些必要先啟動的Provider 要先被建立
* 非同步建立Provider,配合useFactory(),Promise 物件
* 等到Provider都就定位,才開始接收 request
* 例如DB Provider,需要DB完成連線,才開始服務
```>>
>> coffees/coffees.module.ts
// Static Custom provider token
import { COFFEE_BRANDS } from './coffees.constants';
@Module({
imports: [...],
controllers: [...],
providers: [
CoffeesService,
{
provide: COFFEE_BRANDS,
useFactory: async (connection: Connection): Promise<string[]> => {
// const coffeeBrands = await connection.query('SELECT * ...'); // SQL Command
const coffeeBrands = await Promise.resolve(['buddy brew', 'nescafe']); // mock promise for test
return coffeeBrands;
},
}, // Static Custom provider useFactory "Async" method, retrieve data from database use typeorm connection
],
exports: [...],
})
```
### Dynamic Module
* 在某些情況,模組需要使用完全不一樣的功能
* 導致useFactory(),太多事情要做
* 把特定功能獨立成 DynamicModule,依據情境去傳入"選擇的功能"
* 最後在模組中引入DynamicModule即可
* 需要實作DynamicModule interface
* 需要提供一個 register() Method
```>>
# 假設使用連線不同資料庫的動態模組為例
>> database/database.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { ConnectionOptions, createConnection } from 'typeorm';
@Module({})
export class DatabaseModule {
static register(options: ConnectionOptions): DynamicModule {
return {
module: DatabaseModule,
providers: [
{
provide: 'CONNECTION',
useValue: createConnection(options),
},
],
};
}
}
>> coffee-rating/coffee-rating.module.ts
import { DatabaseModule } from 'src/database/database.module';
@Module({
imports: [
DatabaseModule.register({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'ACC',
password: 'PWD',
database: 'postgres',
}),
],
providers: [...],
})
```
### Scope (Life Cycle)
#### 原理
* 因為NodeJS 沒有所謂的多線程(Muti Task)
* 所以當有Request傳入NestApp時,理論上所有的Module都是共享的
* 基於某些情況,為了節省效能,只希望Provider的Life Cycle 在處理完成Request後就關閉
* Scope 可以設定 Provider 的 Life Cycle
#### 種類
* **Scope.DEFAULT** :完全與APP的Life Cycle相同,APP活多久,Provider 就活多久

* **Scope.TRANSIENT** : 除了原本DEFAULT的Provider被建構一次,會額外建構一個 Private的 Provider instance,假如同個Provider會在多個Module中被呼叫注入,TRANSIENT可以將 TASK分開處理

* **Scope.REQUEST** :與被Request呼叫到處理結束的週期為這個Provider 的 Life Cycle

## Config Module
### .env file
* @nestjs/config
```
npm i @nestjs/config
```
* 一個應用程式,會依據不同的運作需求,而有不同的參數設定
* 有些敏感資料或參數,需要被隔離或是隱藏於.gitignore中
* 設定**環境變數**,可以讓開發者輕易的更改所有的參數
* 新增 **.env** 檔,將環境參數命名,再藉由ConfigModule讀取,即可
### ConfigModule
* 預設情況下會自動讀取路徑 **./ .env** 檔,作為環境參數設定檔
* 需要在根模組(AppModule)中 import
```>>
>> ./src/app.module.ts
import {...}
import {ConfigModule} from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot(), // 預設讀取./.env
CoffeesModule,
TypeOrmModule.forRoot({...})
],
controllers:[...],
providers:[...],
exports:[...],
}){}
export class AppModule{}
```
### Custom path or ignore
* ConfigMoudle.forRoot(),可以傳入尋找的路徑設定,或是特定檔名
* **如果檔名相同, 會以第一個找到的檔案為優先設定**
* 如果程式執行的平台例如**Heroko**,擁有其他的環境設定檔,就必需要忽略本專案的 .env
* ignoreEnvFile: true 可以讓ConfigModule忽略所有的 .env 檔
```>>
>> app.module.ts
@Module({
imports: [
ConfigModule.forRoot({
envFilePath: '__PathOrFileName__',
// ignoreEnvFile: true
})
]
})
```
### validate env value
* **joi** package 可以定義 object schema跟 validate JS Object
* Regular dependence
```
npm i @hapi/joi
```
* Devolopment dependence
```
npm i --save-dev @types/hapi__joi
```
[# Joi DOC](https://joi.dev/api/?v=17.6.0)
* validationSchema: 建立Joi 物件Schema
```>>
>> app.module.ts
import * as Joi from '@hapi/joi';
@Module({
imports: [
ConfigModule.forRoot({
validationSchema:Joi.object({
DATABASE_HOST: Joi.required(),
DATABASE_PORT: Joi.number().default(5432),
}),
})
],
...
})
```
### ConfigService
* 為了防止缺少變數後,APP也能順利執行
* ConfigService可以取得Env,也能設定 Default Value
```>>
>> /src/coffees/coffees.service.ts
import {ConfigSerivce} form '@nestjs/config';
...
@injectable()
export class CoffeesService{
constructor(
...
private readonly configService: ConfigService,
){...}
const databaseHost =
this.configService.get<string>(
'DATABASE_HOST', // ENV VALUE
'localhost' // DEFAULT VALUE
);
...
}
```
### Custom ConfigFile
* 集中所有相同類別的設定為一個檔案
* 方便獨立更改每個類別的設定
* 輸出成JS object 比起一般字串更方便呼叫
* 可使用物件屬性(obj.property)的呼叫方法區分環境變數
```>>
>> /src/config/app.config.ts
// An example for global configuration object
export default () => ({
// 利用箭頭函式建立一個全域物件
environment: process.env.NODE_ENV || 'development', // 執行環境
database: {
// DB的設定值
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT, 10) || 5432, // 把環境變數'字串'轉成10進位'數字'
},
});
>> /src/app.module.ts
...
import { ConfigModule } from '@nestjs/config';
import appConfig from './config/app.config';
@Module({
imports: [
ConfigModule.forRoot({
load: [appConfig], // 讀取ConfigObject
}),
}),
],
...
})
>> /src/coffees/coffees.service.ts
import {ConfigSerivce} form '@nestjs/config';
...
@injectable()
export class CoffeesService{
constructor(
...
private readonly configService: ConfigService,
){...}
const databaseHost =
this.configService.get(
'database.host', // ConfigObject VALUE
'localhost' // DEFAULT VALUE
);
...
}
```
### Partial Registration and Namespace
* 利用 Namespace的方式,註冊特定Service的 KEY Token
* 利用Partial Registration 設定只存在特定Service中的環境變數
* 使用 @inject(KEY) 的方式保證Type Safety
* 方便撰寫測試與維護
```>>
>> /src/coffees/config/coffees.config.ts
// An example of ConfigObject Namespaces and Partial Registration
import { registerAs } from '@nestjs/config'; // 部分註冊
export default registerAs('coffees', () => ({
foo: 'bar', // coffees.foo 的ConfigObject
}));
>> /src/coffees/coffees.module.ts
// Partial Registration of ConfigObject
import coffeesConfig from './config/coffees.config';
...
@Module({
imports: [
...
ConfigModule.forFeature(coffeesConfig),
], // Use typeorm mapping entity and sql table
...
})
>> /src/coffees/coffees.service.ts
import { ConfigService, ConfigType } from '@nestjs/config';
import coffeesConfig from './config/coffees.config'; // Coffees's ConfigObject namespaces key value
...
@Injectable({ scope: Scope.DEFAULT })
export class CoffeesService {
constructor(
...
@Inject(coffeesConfig.KEY) // Coffees's ConfigObject namespaces key value
private readonly coffeesConfiguration: ConfigType<typeof coffeesConfig>,
) {
console.log(coffeesConfiguration.foo);
}
...
}
```
## Nest 4 Techniques (Middleware)
### Exception Filters
* 處理預測的Request意外狀況
* 依據狀況回覆特定的Response
* 更加控制原Nestjs 的 Exception Layer
* 使錯誤訊息更"可讀化"
# Nest CLI
```
nest g filter common/filters/http-exception
```
#### 實作ExceptionFilter class
```>>
>> /src/common/filters/http-exception.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from '@nestjs/common';
import { Response } from 'express';
@Catch(HttpException) // 專門處理Service throw HttpException
export class HttpExceptionFilter<T extends HttpException> // T 是HttpException類別,確保catch HttpException
implements ExceptionFilter // 實作Filter interface
{
catch(exception: T, host: ArgumentsHost) { // host 是一個實體的 Request或Response Object
const ctx = host.switchToHttp(); // 轉換Context成 http 格式
const response = ctx.getResponse<Response>(); // 取出Http的Response 部分
const status = exception.getStatus(); // 取出status code
const exceptionResponse = exception.getResponse(); // 取出Exception
const error =
typeof response === 'string'
? { message: exceptionResponse } // 轉換型別,如果單純是字串,轉換成物件,放入Message的Key中
: (exceptionResponse as object); // 全部轉換成物件
response.status(status).json({
...error,
timestamp: new Date().toISOString(),
});
}
}
```
### Guard
* Authorization (Register and Valid)
* Authentization (Session and Token)
* Permissions
* Roles
* ACLs (Access Control Lists)
# Nest CLI
```
nest g guard common/guards/api-key
```
#### @SetMetadata('key', 'value')
* 設定路由權限
* key: 尋找哪個KEY
* value: (typeof any) 存入KEY的值
* @SetMetadata('isPublic', true)

#### Custom Decorator
```>>
>> /src/common/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
>> /src/coffees/coffees.controller.ts
import {...}
import { Public } from 'src/common/decorators/public.decorator';
@injectable('coffees')
export CoffeesController {
constructor(...){}
//@SetMetadata('isPublic', true)
@Public() // 使用 Custom Decorator
@Get()
findAll(@Query() paginationQuery: PaginationQueryDto)
return this.coffeesService.findAll(paginationQuery);
}
}
```
#### 實作 Guard
* Reflector (hint: @SetMetadata())
* Reflector.get(哪個MetaData, 哪個Route);
```>>
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly configService: ConfigService,
) {}
// CanActivate interface must return boolean True allowed / False Denied
canActivate(
context: ExecutionContext, // inherits the ArgumentsHost (hint) ExceptionFilter
): boolean | Promise<boolean> | Observable<boolean> {
const isPublic = this.reflector.get(IS_PUBLIC_KEY, context.getHandler()); // reflector.get(WHICHMETADATA, WHICHROUTE);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const authHeader = request.header('Authorization'); // 取得標頭"權限"欄位值
return authHeader === this.configService.get('API_KEY');
}
}
```
### Interceptors
* 在Call route的 "前後" 都會觸發
* 阻擋而外的程式邏輯
* 轉換程式邏輯的 return 值
* 繼承與拓展程式邏輯
* 覆寫程式邏輯(疊加)
# Nest CLI
```
nest g interceptor common/interceptors/wrap-response
```
#### [Rxjs API](https://rxjs.dev/api)
* rxjs 是一個Liberary,使開發者更容易操縱"非同步API"
* 可以監聽所有的Promise/CallBack物件
* 統一管理不同的API
* pipe(fun1, fun2,..串接多個Method
* tap() 終止Observable物件
* map() 走訪Observable中所有資料
#### 實作 wrap-response
```>>
>> /src/common/interceptors/wrap-response.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';
// Wrap Req and Res stream, allow us to decide execute the route or not
@Injectable()
export class WrapResponseInterceptor implements NestInterceptor {
// Every CustomInterceptor must implement NestInterceptor interface
// Every CustomInterceptor must have one intercept method
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// Every intercept method must return a 'Observable' from Rxjs library
// CallHandler interface implement handle method. Call it can invoke the route handler method, If don't not invoke
console.log('Before...'); // Execute it before the route handler be called
// return next.handle().pipe(tap((data) => console.log('After...', data))); // Execute it after the route handler be called
// tap() invokes an anonymous logging function upon graceful termination of the Observable stream
// data => ... is the response we send back to the router handler
return next.handle().pipe(map((data) => ({ data })));
// map() takes a value from the stream and returns a modified one
// wrap response data in a object
}
}
```
#### 實作 timeout
```>>
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
RequestTimeoutException,
} from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// If a req or res execute more than 3 sec. ,Interceptor is going to end handler forcefully
return next.handle().pipe(
timeout(3000), // 超過3秒終止 handler
catchError((err) => {
if (err instanceof TimeoutError) { // 如果 err 是 TimeoutError的"實例"
return throwError(new RequestTimeoutException());
}
return throwError(err);
}),
);
}
}
```
#### 加入Main
```>>
>> /src/main.ts
// Interceptor
import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor';
import { WrapResponseInterceptor } from './common/interceptors/wrap-response.interceptor';
async function bootstrap() {
const app ...
// 測試Interceptor的lifetime
app.useGlobalInterceptors( // 逗號區分 同時加入多個interceptor
new WrapResponseInterceptor(),
new TimeoutInterceptor(),
);
await app.listen(3000);
}
bootstrap();
```
### Pipes (ValidationPipe)
* 只在Call routes 前被觸發
* (Transfromation) 驗證Request傳入的資料,轉換成規定的格式
* (Validation) 遮擋跟拒絕規定外的資料
# Nest CLI
```
nest g pipe common/pipes/prase-int
```
### 使用 Nest 4 helper (Token)
* main.ts (Global)
* app.useGlobalPipes()
* app.useGlobalFilters()
* app.useGlobalGuards()
* app.useGlobalInterceptors()
* module.ts @nestjs/core (Provider)
* Custom Provider (hint: Provider token)
* APP_PIPE
* APP_FILTER
* APP_GUARD
* APP_INTERCEPTOR
* controller.ts @nestjs/common (Decorator)
* @UsePipes()
* @UseFilter()
* @UseGuard()
* @UseInterceptor()
* Params (Pipe only)
* @Body(Custom_Pipe_Class)
### Custom Middleware
* execute code
* change req/res object
* ending req/res cycle
* calling "next()" middleware
* 不能綁定在Provider中
* 只能以string的方式注入Controller
# Nest CLI
```
nest g middleware common/middlewares/logging
```
#### Function Middleware
* stateless
* can't inject dependencies
* can't access Nest Container (run time cycle)
#### Class Middleware
* register in module scope
* can inject dependencies
* can access Nest Container (run time cycle)
```>>
>> /src/common/middleware/logging.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
@Injectable()
export class LoggingMiddleware implements NestMiddleware {
// Every custom middleware must implement NestMiddleware interface
// Every custom middleware must provide a 'use method'
use(req: any, res: any, next: () => void) {
// req is from pervious middleware or request arguments
// res is this middleware want to pass to the next middleware arguments
// next() is a method, called the next middleware
console.time('Request-response time');
console.log('hi from next middleware');
res.on('finish', () => console.timeEnd('Request-response time'));
// always call next(), otherwise the request will all hanging
next();
}
}
>> /src/common/common.module.ts
import ...
// Custom middleware
import { LoggingMiddleware } from './middlewares/logging.middleware';
@Module({
imports: [...],
providers: [...],
})
export class CommonModule implements NestModule {
// Every custom middleware inject in Module must implement NestModule interface
// Every custom middleware must provide a 'configure()' method
configure(consumer: MiddlewareConsumer) {
// consumer provides a set of useful methods to tie middleware to "specific routes"
consumer.apply(LoggingMiddleware).forRoutes('*'); // * represent all route
// consumer.apply(LoggingMiddleware).exclude('coffees').forRoutes('*'); // exclude all /coffees routes
// consumer
// .apply(LoggingMiddleware)
// .forRoutes({ path: 'coffees', method: RequestMethod.GET }); // specify GET /coffees routes
}
}
```
### Custom Decorator
#### 實作
* createParamDecorator 要實作的interface
* ExecutionContext 傳入的http context
```>>
>> /src/common/decorators/protocol.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const Protocol = createParamDecorator(
(defaultValue: string, ctx: ExecutionContext) => {
console.log({ defaultValue });
const request = ctx.switchToHttp().getRequest();
return request.protocol;
},
);
@Protocol('data')
```
## OpenAPI Specification
* Document of OpenAPI is vital part of APP
* Guide and expose our API as a SDK(Sofeware Developer Kit)
### Swagger
* A Nest tool, help us integrate and automatically generate Open API documentation
* Dependencies
* Express
```
npm i @nestjs/swagger swagger-ui-express
```
* Fastify
```
npm i @nestjs/swagger fastify-swagger
```

### Open API specification
* A language agnostic definition format used to describe a RESTful APIs
* include
1. Available operations or endpoints
2. Operation parameters: Input and output for each operation
3. Authentication methods
4. Contact information, license, terms of use, and other information
### Swagger UI setup
#### Swagger UI Doc and endpoint route
* setup('RoutePathToSwaggerUI', 'APPInstance', 'DocumentObject');
* 'RoutePathToSwaggerUI': Endpoint to Swagger UI (http://localhost:3000/api)
* 'APPInstance': App Instance
* 'DocumentObject': Doc object
```>>
>> /src/main.ts
import ...
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
async function bootstrap() {
const app ...
// Set OpenAPI specification property
const options = new DocumentBuilder()
.setTitle('Ilovecoffee')
.setDescription('Coffee application')
.setVersion('1.0.0')
.build();
// Create document
const document = SwaggerModule.createDocument(app, options);
// setup('RoutePathToSwaggerUI', 'APPInstance', 'DocumentObject');
SwaggerModule.setup('api', app, document); // UI setup in http://localhost:3000/api
await app.listen(3000);
}
bootstrap();
```
#### Swagger plugin setup
* 使Swagger可以找到 DTO Schema
* compilerOptions: 設定加入Plugin
```>>
>> nest-cli.json
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions":{
"__comment__": "Load swagger plugin enable to use specific Decorator",
"deleteOutDir": true,
"plugins": ["@nestjs/swagger/plugin"]
}
}
```
#### PartialType Bug

* 有些DTO是基於其他DTO的變化
* 更改 PartialType的Lib "@nestjs/mapped-types" => "@nestjs/swagger"
```>>
>> /src/coffees/dto/update-coffee.dto.ts
// CLI >> nest g class coffees/dto/update-coffee.dto --no-spec
// Update property optional
// import { PartialType } from '@nestjs/mapped-types'; // 利用原本的DTO去建立一個內容元素是可選擇使用的新DTO
import { PartialType } from '@nestjs/swagger'; // Insure Swagger UI can get all partial type properties
import { CreateCoffeeDto } from './create-coffee.dto'; // 原本的DTO
export class UpdateCoffeeDto extends PartialType(CreateCoffeeDto) {}
```
### @ApiProperty()

* import from @nestjs/swagger
* 提供開發者設定 Property 的 預設值/敘述
```>>
>> /src/coffees/dto/create-coffee.dto.ts
// CLI >> nest g class coffees/dto/create-coffee.dto --no-spec
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateCoffeeDto {
@ApiProperty({ description: 'The name of a coffee.' })
@IsString()
readonly name: string;
@ApiProperty({ description: 'The brand of a coffee.' })
@IsString()
readonly brand: string;
@ApiProperty({ example: [] })
@IsString({ each: true }) // 檢查陣列每一個元素
readonly flavors: string[];
}
```
### @ApiResponse() & @ApiForbiddenResponse()

* 提供範例輸出
```>>
>> /src/coffees/coffees.controller.ts
import { ApiResponse, ApiForbiddenResponse } from '@nestjs/swagger';
@Controller('coffees')
export class CoffeesController {
constructor(...){}
// @ApiResponse({ status: 403, description: 'Forbidden.' })
@ApiForbiddenResponse({ description: 'Forbidden.' })
@Public()
@Get()
findAll(
...
) {
...
}
}
```
### @ApiTags()

* 更好的統整"相同"的 Route
* 區分"相同" Route 增加可讀性
``` >>
>> /src/coffees/coffees.controller.ts
import { ApiTags } from '@nestjs/swagger';
@ApiTags('coffees')
@Controller('coffees')
export class CoffeesController {
constructor(...){}
...
}
```
## Testing
* Unit test
```
npm run test
```
* Coverage test
```
npm run test:cov
```
* e2e test
```
npm run test:e2e
```
### [Jest Lib](https://docs.nestjs.com/fundamentals/testing)
#### [Jest Doc](https://jestjs.io/docs/getting-started)
* Great error message
* Build-in Mocking utilities
* Reliably run test pararllel
#### class
* Test: 建立與提供"Mock ExecutionContext",
* TestModule: 測試模組,將實體化參數內的Class,並測試
#### method
* Test.createTestingModule({"Metadata"}) : 相同於在@Module({"Metadata"}){},依照傳入的Metadata建立物件
#### hook
* describe()
* beforeEach() : **"Setup phase"**, 在所有Test前 執行
* compile() : 實體化一個TestingModule,成為一個"Mock Application"
* get():取得"Mock Application"中的特定物件
* it('desc', func()): 表示一個“單一測試”
* expect(Obj)
* toBeDefinded()
* beforeAll() :
* afterEach() :
* afterAll() :
### Unit test
#### CLI
* 單一
```
npm run test:watch -- coffees.service
```
* 全部
```
npm run test:watch
```
#### Inject Dependencies as Mock
* 訣竅 Service的constructor 中 @Inject(Token) @InjectRepository(Token)
* Token全部都要在providers:[] 中用CustomProvider實作Mock
* [Utility Type TS DOC](https://www.typescriptlang.org/docs/handbook/utility-types.html)
```>>
>> /src/coffees/coffees.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { CoffeesService } from './coffees.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Connection, Repository } from 'typeorm';
import { Coffee } from './entities/coffee.entity';
import { Flavor } from './entities/flavor.entity';
// Extra Provider Token
import { COFFEE_BRANDS } from './coffees.constants';
// ConfigService Provider Token
import coffeesConfig from './config/coffees.config';
import { NotFoundException } from '@nestjs/common';
// 理想的方法去產生Mock Repository<通用樣板>
// Partial<Type> 把傳入的Type,變成選擇性存在
// Record<...Type, ...value> 把傳入的Type展開變成Key,與後方展開的value做 Peer
type MockRepository<T = any> = Partial<Record<keyof Repository<T>, jest.Mock>>;
const createMockRepository = <T = any>(): MockRepository<T> => ({
findOne: jest.fn(),
create: jest.fn(),
});
// describe('Title', func() ) grouping all unit tests in func of 'Title'
describe('CoffeesService', () => {
let service: CoffeesService;
let coffeeRepository: MockRepository;
// beforeEach() will be executed "before every test", A "setup phase"
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CoffeesService,
{
provide: Connection,
useValue: {},
},
{
provide: getRepositoryToken(Coffee),
useValue: createMockRepository(),
},
{
provide: getRepositoryToken(Flavor),
useValue: createMockRepository(),
},
{
provide: COFFEE_BRANDS,
useValue: {},
},
{
provide: coffeesConfig.KEY,
useValue: {
get: jest.fn((key: string) => {
// this is being super extra, in the case that you need multiple keys with the `get` method
if (key === 'foo') {
return 'bar';
}
return null;
}),
},
},
],
}).compile();
service = module.get<CoffeesService>(CoffeesService);
// service = await module.resolve(CoffeesService); // 如果Provider的lifetime 是“TRANSIENT”或“REQUEST”
coffeeRepository = module.get<MockRepository>(getRepositoryToken(Coffee));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('findOne', () => {
describe('when coffee with ID exists', () => {
it('should return the coffee object', async () => {
const coffeeId = '1';
const exceptedCoffee = {};
coffeeRepository.findOne.mockReturnValue(exceptedCoffee);
const coffee = await service.findOne(coffeeId);
expect(coffee).toEqual(exceptedCoffee);
});
});
describe('otherwise', () => {
it('should return the "NotFoundException"', async () => {
const coffeeId = '1';
coffeeRepository.findOne.mockReturnValue(undefined);
try {
await service.findOne(coffeeId);
} catch (err) {
expect(err).toBeInstanceOf(NotFoundException);
expect(err.message).toEqual(`Coffee #${coffeeId} not found`);
}
});
});
});
});
```
### e2e test
* 接近真實使用者的使用情況
* 測試每一個“Route”
* **記得把每個檔案import路徑改成“相對路徑”**
#### CLI
* e2e
```
npm run test:e2e
```
* detect open handle
```
npx jest
```
#### hook
* beforeAll(): 只建立App實體一次
* compile(): 建立實體
* moduleFixture.createNestApplication(): 建立測試APP實體
* app.init(): 初始化APP
* afterAll()
* app.close(): 關閉所有Promise物件,減少OpenHanging的問題
* it('desc', func(supertest.request(){...}))
* 設定單一測試Route
* 使用request 模擬使用者request
* 使用chain的方式設定
* 例如get()/post()/set('header')
#### app.e2e-spec.ts
```>>
>> /src/test/app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => { // Change beforeEach to beforeAll, bc we don't want to restart the app for each e2e test
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication(); // 一定要建立一個App runtime system
await app.init();
});
// it('route (Method)', func(supertest.request(){...}))
it('/ (GET)', () => {
return request(app.getHttpServer()) // Connect to server (Express or Fastify)
.get('/')
.set('Authorization', process.env.API_KEY) // Pass to Global Guard
.expect(200)
.expect('Hello World!');
});
afterAll(async () => {
await app.close(); // 因為App有一些“非同步”的程序,這邊是DB connecter
});
});
```
#### coffees.e2e-spes.ts
##### package.json (npm script)
* pretest : 在執行test script前執行
* posttest : 在執行test script後執行
```>>
>> /package.json
{
"name": ...,
"scripts": {
...
"pretest:e2e": "docker-compose up -d test-db",
"test:e2e": "jest --config ./test/jest-e2e.json",
"posttest:e2e": "docker-compose stop test-db && docker-compose rm -f test-db"
},
dep...
}
```
* jest@28.0.1^ 移除jasmine
```>>
>> /test/coffees/coffees.e2e-spec.ts
import {
HttpServer,
HttpStatus,
INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { TestingModule, Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import * as request from 'supertest';
import { HttpExceptionFilter } from '../../src/common/filters/http-exception.filter';
import { CoffeesModule } from '../../src/coffees/coffees.module';
import { CreateCoffeeDto } from '../../src/coffees/dto/create-coffee.dto';
import { UpdateCoffeeDto } from '../../src/coffees/dto/update-coffee.dto';
describe('[Feature] Coffees - /coffees', () => {
const coffee = {
name: 'Shipwreck Roast',
brand: 'Buddy Brew',
flavors: ['Chocolate', 'Vanilla'],
};
const expectedPartialCoffee = expect.objectContaining({
...coffee,
flavors: expect.arrayContaining(
coffee.flavors.map((name) => expect.objectContaining({ name })),
),
});
let app: INestApplication;
let httpServer: HttpServer;
// Method1: use mock object(hard maintain)
// Method2: use DISK_BASE DB (Diff with real world)
// Method3: use EXTRA_TESTDB (Recommend)
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
CoffeesModule,
TypeOrmModule.forRoot({ // use Method3 strategy
type: 'postgres',
host: 'localhost',
port: 5433,
username: 'postgres',
password: 'pass123',
database: 'postgres',
autoLoadEntities: true,
synchronize: true,
}),
],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
await app.init();
httpServer = app.getHttpServer();
});
it('Create [POST /]', () => {
return request(httpServer)
.post('/coffees')
.send(coffee as CreateCoffeeDto)
.expect(HttpStatus.CREATED)
.then(({ body }) => {
expect(body).toEqual(expectedPartialCoffee);
});
});
it('Get All [GET /]', () => {
return request(httpServer)
.get('/coffees')
.then(({ body }) => {
expect(body.length).toBeGreaterThan(0);
expect(body[0]).toEqual(expectedPartialCoffee);
});
});
it('Get One [GET /:id]', () => {
return request(httpServer)
.get('/coffees/1')
.then(({ body }) => {
expect(body).toEqual(expectedPartialCoffee);
});
});
it('Update One [PATCH /:id]', () => {
const updatedCoffeeDto: UpdateCoffeeDto = {
...coffee,
name: 'New and Improved Shipwreck Roast',
};
return request(httpServer)
.patch('/coffees/1')
.send(updatedCoffeeDto)
.then(({ body }) => {
expect(body.name).toEqual(updatedCoffeeDto.name);
return request(httpServer)
.get('/coffees/1')
.then(({ body }) => {
expect(body.name).toEqual(updatedCoffeeDto.name);
});
});
});
it('Delete One [DELETE /:id]', () => {
return request(httpServer)
.delete('/coffees/1')
.expect(HttpStatus.OK)
.then(() => {
return request(httpServer)
.get('/coffees/1')
.expect(HttpStatus.NOT_FOUND);
});
});
afterAll(async () => {
await app.close();
});
});
```
## MongoDB
### Git Checkout branch
* 回到剛建制CRUD前的分支 main
```
git checkout main
```
### ReBuild
* 因為dist沒有改變,編譯會出問題
* 重新建置編譯
```
npm run build
```
### Docker-compose.yml
```>>
>> /docker-compose.yml
version: "3"
services:
db:
...
mongodb:
image: mongo
restart: always
ports:
- 27017:27017
environment:
MONGODB_DATABASE: nest-course
```
### MongoDB Schema and Document
* MongoDB屬於No SQL,是以Document Mapping

* 所有資料集(collection)是按照Schema Definition 做區分

### Entity (Schema)
* 所有Schema definition類別都要實作 Document interface
* @Prop() 可以設定預設值 例如 Array[], {default: type, index: true},
* SchemaFactory() 產生Schema Instance
```>>
>> /src/coffees/entity/coffees.entity.ts
// Definition of coffee data entity.
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
@Schema()
export class Coffee extends Document {
@Prop()
name: string;
@Prop()
brand: string;
@Prop({ default: 0 })
recommendation: number;
@Prop([String])
flavors: string[];
}
export const CoffeesSchema = SchemaFactory.createForClass(Coffee);
// DefinitionsFactoryClass 建立一列(raws)的 Schema instances
>> /src/events/entities/event.entity.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import * as mongoose from 'mongoose';
@Schema()
export class Event extends mongoose.Document {
@Prop()
type: string;
// @Prop({ index: true })
name: string;
// 如果Schema的prop 有any type, 使用Mixed,讓所有東西通過檢查
@Prop(mongoose.SchemaTypes.Mixed)
payload: Record<string, any>;
}
export const EventSchema = SchemaFactory.createForClass(Event);
// 設定name為index 並且為升冪排列
// 設定type為index 並且為降冪排列
EventSchema.index({ name: 1, type: -1 });
```
### Connect MongoDB
#### AppModule imports MongoModule
* forRoot() 連線到DB,整個APP只需要連一次
```>>
>> /src/app.module.ts
import ...
import { MongooseModule } from '@nestjs/mongoose';
@Module({
imports: [
MongooseModule.forRoot('mongodb://__HOST__:port/DB_NAME'),
],
controllers: [...],
providers: [...],
})
export class AppModule {}
```
#### Module imports model
* forFeature() 連線到特定的Document
* 使用Schema token
```
{
name: schema.name,
schema: SchemaInstance
}
```
```>>
>> /src/coffees/coffees.module.ts
import ...
import { MongooseModule } from '@nestjs/mongoose';
import { Coffee, CoffeesSchema } from './entities/coffee.entity';
import { Event, EventSchema } from 'src/events/entities/event.entity.ts';
@Module({
imports: [
MongooseModule.forFeature([
{ name: Coffee.name, schema: CoffeesSchema },
{ name: Event.name, schema: EventSchema },
]),
],
controllers: [...],
providers: [...],
})
export class CoffeesModule {}
```
#### Service Inject Model
* @InjectModel(Schema.name) :Model\<Schema>
```>>
>> /src/coffees/coffees.service.ts
import { InjectConnection, InjectModel } from '@nestjs/mongoose';
import { Connection, Model } from 'mongoose';
import { Coffee } from './entities/coffee.entity';
import { Event } from 'src/events/entities/event.entity.ts';
import ...
@Injectable()
export class CoffeesService {
constructor(
@InjectModel(Coffee.name) private readonly coffeeModel: Model<Coffee>,
@InjectModel(Event.name) private readonly eventModel: Model<Event>,
) {}
business logic..
}
```
## Service CRUD
* exec()
* save()
* remove()
```>>
>> /src/coffees/coffees.service.ts
import ...
@Injectable()
export class CoffeesService{
constructor(
@InjectModel() ...
){}
findAll(paginationQuery: PaginationQueryDto) {
const { limit, offset } = paginationQuery;
return this.coffeeModel.find().skip(offset).limit(limit).exec();
}
async findOne(id: string) {
const coffee = await this.coffeeModel.findById({ _id: id }).exec();
if (!coffee) {
// If Not Found
throw new HttpException(`Coffee #${id} not found`, HttpStatus.NOT_FOUND);
}
return coffee;
}
create(createCoffeeDto: CreateCoffeeDto) {
const coffee = new this.coffeeModel(createCoffeeDto);
return coffee.save();
}
async update(id: string, updateCoffeeDto: UpdateCoffeeDto) {
const existingCoffee = await this.coffeeModel
.findByIdAndUpdate({ _id: id }, { $set: updateCoffeeDto }, { new: true }) // findOneAndUpdate({QUERY_ID},{MONGOOSE_UPDATE_OBJ},{AFTER_FINISH_ACTION} )
.exec();
if (!existingCoffee) {
throw new NotFoundException(`Coffee #${id} not found`);
}
return existingCoffee;
}
async remove(id: string) {
const coffee = await this.findOne(id);
return coffee.remove();
}
}
```
## Pagination-query
* Limit 限制一次Query多少資料量
* Offset 跳過多少資料量
### pagination-query dto
* 使用class-validtor 檢查資料
```>>
>> /src/common/dto/pagination-query.dto.ts
import { IsOptional, IsPositive } from 'class-validator';
export class PaginationQueryDto {
@IsOptional() // this property is optional
@IsPositive() // this property must be a nature number
limit: number;
@IsOptional()
@IsPositive()
offset: number;
}
```
### 實作
```>>
>> /src/coffees/coffees.service.ts
import...
@Injectable()
export class CoffeesService{
constructor(...){}
findAll(paginationQuery: PaginationQueryDto) {
const { limit, offset } = paginationQuery;
return this.coffeeModel.find().skip(offset).limit(limit).exec();
}
}
```
## Transaction
* 如同Postgres,Event 一般來說要以獨立Module實作
* 步驟
1. 建立session連線
2. 開始transactoin
3. try transaction logic
4. commit transaction
5. catch(err) abortTransaction()
6. finally endSession()
```>>
>> /src/coffees/coffee.service.ts
@Injectable()
export class CoffeesService {
constructor(
@InjectModel(Event.name) private readonly eventModel: Model<Event>,
@InjectConnection() private readonly connection: Connection,
) {}
// CRUD logic ...
async recommendCoffee(coffee: Coffee) {
const session = await this.connection.startSession();
session.startTransaction();
try {
coffee.recommendation++;
const recommendEvent = new this.eventModel({
name: 'recommend_coffee',
type: 'coffee',
payload: { coffeeId: coffee.id },
});
await recommendEvent.save({ session });
await coffee.save({ session });
await session.commitTransaction();
} catch (err) {
await session.abortTransaction(); // Rollback the transaction
} finally {
session.endSession();
}
}
}
```
## 指令Note
```
## Installation
```bash
$ npm install
```
## Running the app
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
## Test
bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
# Nest-cli-command:
npm run start:dev // start dev module
nest g controller/service/module path/path --dry-run // 只顯示結果,常測試檔案名有無衝突
nest g controller/service/module _name_ // Create nest file
nest g class path/{create/update}-{name}.dto --no-spec // Create DTO file
nest g class path/{name}.entity --no-spec // Create new entity file, Need to rename module name
# docker-compose command:
docker-compose up -d // Start up all container
docker-compose up db -d // Only start up db container
docker-compose down // Close all container
# docker exec container-compose
docker exec -it __Pid__ bash
# Postgers
psql -U postgres
DB Migration: (TypeOrm)
* Migration 可以更輕鬆修改欄位名稱,避免修改時欄位資料消失或錯誤
#preprocessing
npm run build // build whole project to dist folder
npx typeorm migration:create -n _file_name_ // Create migration file
npx typeorm migration:run // Do update
npx typeorm migration:revert // Undo update
npx typeorm migration:generate -n _file_name_ // auto catch schema changed and generate a particular migration file
```