MEANスタックはもう古い？Angular+NestJS+OpenAPIでTypescriptオンリー環境を構築する

MEANスタック（MongoDB＋Express.js＋Angular＋Node.js）良いですよね。
でも、Express.jsがJavascriptなので、Angularとのギャップにモヤモヤしませんか？

それを解決するため、[NestJS](https://nestjs.com/)を導入します。
NestJSはExpress.jsをベースに作られていて、Angularにインスパイアされたというだけあって、Angularのようなコードで書くことができます。

MEANスタックを使ったことがある人であれば、すんなり使えるフレームワークだと思います。


今回は１つのプロジェクトでAngularとNestJSをまとめたMEANスタックのような環境を１から構築してみます。
ついでにクライアント⇔サーバー間はOpenAPIでタイプセーフにアクセスできるようにもします。


（追記）
この記事ではモノリスな環境を構築します。
そのため、package.jsonにクライアント／サーバーのパッケージが混在したり管理が複雑になります。
AngularとNestJSを独立しつつ1プロジェクトで管理したい場合は以下の記事をご参照ください。
[Angular＋Nest.jsのモノリスプロジェクトを、Lernaを使いプロジェクト内でクライアント／サーバーのコード分割をしてみる](https://qiita.com/teracy55/items/4f8e30e3be4bc99d62eb)

（さらに追記）
シンプルに作る場合の記事を新たに執筆しました。
[Angular＋NestJSのモノリポ環境をなるべくシンプルに作る](https://qiita.com/teracy55/items/4429645ad1fef081f6db)


# 前提

- Node.jsインストール済み
- nestjs/cliインストール済み
- angular/cliインストール済み


# テンプレートプロジェクトの生成



## サーバーサイド（NestJS）のテンプレートプロジェクト生成

`nest new`コマンドでテンプレートプロジェクトを生成します。

```
nest new angular-nest
```

※途中でnpmかyarnか聞かれますが、今回はとりあえずnpmを採用します




## クライアントサイド（Angular）のテンプレートプロジェクト生成

`ng new`コマンドでテンプレートプロジェクトを生成します

```
ng new client --routing --style=scss
```

# クライアントとサーバーのコードをマージする

## サーバーサイドの編集

### srcディレクトリのリネーム

- クライアントサイドのコードを`src`にしたいので、サーバーサイドのコードは`api`ディレクトリに移動します。

```
mv ./angular-nest/src ./angular-nest/api
```

### nest-cli.jsonの修正

- `sourceRoot`の値を`api`に変更します

```angular-nest/nest-cli.json
{
  "collection": "@nestjs/schematics",
  "sourceRoot": "api"
}
```

### ビルド設定の修正

#### tsconfigの修正

- `tsconfig.json`の`compilerOptions`を`tsconfig.build.json`に移動します。
    - ※tsconfig.jsonはAngularと共通になるため
- `outDir`を`./dist/server`に変更します
- `include`で`api`ディレクトリ下のtsファイルを指定
- `exclude`で`src`を対象外に指定

```angular-nest/tsconfig.build.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "target": "es2017",
    "sourceMap": true,
    "outDir": "./dist/server",
    "baseUrl": ".",
    "incremental": true
  },
  "include": ["api/**/*.ts"],
  "exclude": ["node_modules", "dist", "test", "src"]
}
```

- この時点では`tsconfig.json`はexcludeだけとなります

```angular-nest/tsconfig.json
{
  "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
```

### 起動設定の変更

- `package.json`の`start:prod`スクリプトの実行ファイルを`dist/server/main`に変更します

```angular-nest/package.json
  "scripts": {
    "prebuild": "rimraf dist",
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "start": "nest start",
    "start:dev": "nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "node dist/server/main",
    "lint": "tslint -p tsconfig.json -c tslint.json",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json"
  },
```

### 起動できるか確認

- この時点で一旦起動できるか確認しておきましょう

```
npm run build
npm run start:prod
```
　↓　こんな感じで起動出来たら
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/d156a27b-b7a9-6752-6574-cd42c7f9d66a.png)
　↓　ブラウザで「http://{ホスト}:3000」にアクセス
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/a1cdecee-b2b1-5655-b8b7-e95614d1af39.png)


## クライアントサイドの編集

### srcディレクトリを移動

```
mv client/src angular-nest/src
```


### tsconfigの修正

- `tsconfig.json`の内容を`tsconfig.app.json`に持ってきます

```clienc/tsconfig.app.json
{
  "extends": "./tsconfig.json",
  "compileOnSave": false,
  "compilerOptions": {
    "baseUrl": "./",
    "outDir": "./dist/out-tsc",
    "sourceMap": true,
    "declaration": false,
    "downlevelIteration": true,
    "experimentalDecorators": true,
    "module": "esnext",
    "moduleResolution": "node",
    "importHelpers": true,
    "target": "es2015",
    "typeRoots": [
      "node_modules/@types"
    ],
    "lib": [
      "es2018",
      "dom"
    ]
  },
  "angularCompilerOptions": {
    "fullTemplateTypeCheck": true,
    "strictInjectionParameters": true
  },
  "files": [
    "src/main.ts",
    "src/polyfills.ts"
  ],
  "include": [
    "src/**/*.ts"
  ],
  "exclude": [
    "src/test.ts",
    "src/**/*.spec.ts"
  ]
}
```

### 必要なファイルを移動

```
mv client/angular.json angular-nest/
mv client/browserslist angular-nest/
mv client/tsconfig.app.json angular-nest/
```

### package.jsonのマージ

- 依存パッケージ情報のマージ
    - `@angular/**`系と`tslib`、`zone.js`を持っていきます

```angular-nest/package.json
  "dependencies": {
    "@angular/animations": "~8.2.13",
    "@angular/common": "~8.2.13",
    "@angular/compiler": "~8.2.13",
    "@angular/core": "~8.2.13",
    "@angular/forms": "~8.2.13",
    "@angular/platform-browser": "~8.2.13",
    "@angular/platform-browser-dynamic": "~8.2.13",
    "@angular/router": "~8.2.13",
    ・・・
    "tslib": "^1.10.0",
    "zone.js": "~0.9.1"
  },
  "devDependencies": {
    ・・・
    "typescript": "~3.5.3"
```

- スクリプトのマージ
    - `build`を`build:server`にリネーム
    - `build:client`を追加
    - `build`を追加

```angular-nest/package.json
  "scripts": {
    "prebuild": "rimraf dist",
    "build:client": "ng build --prod",
    "build:server": "nest build",
    "build": "npm run build:client && npm run build:server",
```

## ビルド確認

```
npm install
npm run build
```
→distディレクトリ下にserverとclientができていればOKです


# Angularのルーティング有効化

- `api/main.ts`を修正し、サーバーとクライアントそれぞれのルーティングが通るようにします
    - /api => サーバーサイド
    - /api以外 => クライアントサイド

```api/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
// add start
import * as express from 'express';
// add end

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // add start
  // サーバーサイドのルーティングを/apiで始まるURLのみに適用
  app.setGlobalPrefix('api');

  // /apiから始まらないURLの場合はクライアントサイドのルーティングを適用
  const clientPath = __dirname + '/../client';
  app.use(express.static(clientPath));
  app.use(/^(?!\/api).*$/, express.static(clientPath + '/index.html'));
  // add end
  
  await app.listen(3000);
}
bootstrap();
```

## ルーティングの確認

### ビルド&起動

```
npm install
npm run build
npm run start:prod
```

### サーバーサイドの確認

- `http://{ホスト}:3000/api`にアクセスします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/0cfff319-a15c-fa98-42ea-7c98199dffff.png)

「Hello World」が表示されます！


### クライアントサイドの確認

- `http://{ホスト}:3000`にアクセスします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/b9aeefce-7184-ebbc-92a3-710c51b1c836.png)

Angularの画面が出ます！





# サーバーサイドにOpenAPIを導入する

[リファレンス](https://docs.nestjs.com/recipes/swagger)に従って導入していきます

## 導入

- 必要なパッケージのインストール

```
npm install --save @nestjs/swagger swagger-ui-express
```

## main.tsの修正

- SwaggerUIの表示URLは`/api/docs`にしておきます

```api/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
// add start
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import * as express from 'express';
// add end

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // add start
  // サーバーサイドのルーティングを/apiで始まるURLのみに適用
  app.setGlobalPrefix('api');

  const options = new DocumentBuilder()
  .setTitle('Cats example')
  .setDescription('The cats API description')
  .setVersion('1.0')
  .addTag('cats')
  .build();
  const document = SwaggerModule.createDocument(app, options);
  SwaggerModule.setup('api/docs', app, document);

  // /apiから始まらないURLの場合はクライアントサイドのルーティングを適用
  const clientPath = __dirname + '/../client';
  app.use(express.static(clientPath));
  app.use(/^(?!\/api).*$/, express.static(clientPath + '/index.html'));
  // add end
  
  await app.listen(3000);
}
bootstrap();
```

## 再起動

```
npm run build:server
npm run start:prod
```

## 確認

- `http://{ホスト}:3000/api/docs`にアクセスします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/2e8e4cfe-9f79-74bd-49a1-ec2b4a761c8f.png)

SwaggerUIが表示されました！！

- ちなみに`http://{ホスト}:3000/api/docs-json`にアクセスすると、specファイルの中身を出力することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/b39f6b4b-6134-e8df-ac61-d111584e87bc.png)



#　APIクライアントを自動生成する

[openapi-generator](https://github.com/OpenAPITools/openapi-generator)を使うと、specファイルからAPIクライアントを自動生成することができます。


## openapi-generatorの導入

openapi-generatorはJavaで作られているツールのため、普通に動かそうとするとJREが必要でめんどくさいので、[docker方式](https://github.com/OpenAPITools/openapi-generator#16---docker)を採用します。

- 毎回コマンドを打つのはめんどくさいので、package.jsonにスクリプトを登録しておきます。
    - `-i` 先ほどのspecファイル表示URLを指定します
    - `-g` 出力形式（`typescript-angular`）を指定します。
    - `-o` docker内での出力ディレクトリを指定します

```package.json
"scripts": {
    ・・・
    "generate:api-client": "docker run --rm -v ${PWD}/src/app/api-client:/local/api-client openapitools/openapi-generator-cli generate -i http://192.168.33.10:3000/api/docs-json -g typescript-angular -o /local/api-client"
}
```

Angularの説明は[ここ](https://github.com/OpenAPITools/openapi-generator/blob/master/docs/generators/typescript-angular.md)に書いてあります

## サーバーサイドでDTOファイルを最低１つは作成する

Modelも生成されるのですが、これが１つもないとAngular側のビルドがエラーとなってしまうため、DTOファイルを作成しておきます。

```api/dto/hello.dto.ts
import { ApiModelProperty } from "@nestjs/swagger";

export class HelloDto {
    @ApiModelProperty()
    message: string;
}
```

- controllerの`@ApiResponse`で型を指定することで、SwaggerUIでModelとして認識されるようになります。

```api/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { HelloDto } from './dto/hello.dto';
import { ApiResponse } from '@nestjs/swagger';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) { }

  @Get()
  @ApiResponse({ status: 200, type: HelloDto})
  getHello(): HelloDto {
    const message = this.appService.getHello();
    return { message };
  }
}
```

- サーバーサイドをビルドしなおしておきます

```
npm run build:server
```


## APIクライアントの生成

- ターミナルを２つ開き、片方のターミナルで`npm run start:prod`でサーバーを起動します
- もう片方のターミナルで以下を実行します

```
npm run generate:api-client
```
→src/app/api-clientにAPIクライアントコードが生成されます。




# APIコール

## モジュールのインポート

- app.module.tsで`ApiModule`をインポートします
    - basePathを指定します。
    - クライアントとサーバーが同一サーバー内なので、ホストやポートは特に指定せず、`/api`だけにします
- HttpClientModuleも必要になるので、忘れずにインポートします

```src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { ApiModule, Configuration } from './api-client';
import { HttpClientModule } from '@angular/common/http';

export function apiConfigFactory(): Configuration {
  return new Configuration({ basePath: '/api' });
}

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,

    HttpClientModule,
    ApiModule.forRoot(apiConfigFactory),
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
```

## DefaultServiceをDI

- constructorでDefaultServiceをDIします
- 好きなタイミングでAPIクライアントを使ってAPIを呼び出します

```src/app/app.component.ts
import { Component } from '@angular/core';
import { DefaultService } from './api-client';
import { HelloDto } from './api-client/model/helloDto';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title = 'client';

  constructor(private api: DefaultService) {}

  async onClickTest() {
    const result: HelloDto = await this.api.rootGet().toPromise();
    this.title = result.message;
  }
}
```

- `this.api.rootGet`が自動生成されたAPIクライアントです
    - この関数を呼ぶだけで対象のAPIがコールされます
- サーバーサイドで作成したHelloDtoもAPIクライアントと同時に生成されているため、クライアントサイドでも使用することができます。


```src/app/app/component.html
<button (click)="onClickTest()">test</button>
```

## 動作確認

- まずは普通に`http://{ホスト}:3000`にアクセスします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/546c0080-8874-dfa7-451d-dcbfafbe19ad.png)

- ボタンを押下します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/799892e6-db60-be10-8d14-382e06fb54ff.png)

サーバーから取得した値で更新されました！！
　


# 最後に

構築は結構めんどくさいですが、作ってしまえばタイプセーフだし、APIクライアントも自動生成できるので、型周りでやきもきすることがなくなるかと思います。

今回作ったものは以下に置いてあります。
https://github.com/teracy55/angular-nest



# 補足

このままだと開発しづらいので、concurrency入れるとかして、サーバーとクライアントをウォッチモードでビルドするようにすると良いと思います。