Angular6 で俺式 KOAN スタック Webアプリを構築する、ほぼ全手順（3）

## 概要
Angular6で「俺式 KOAN スタック」で Web アプリ構築するための個人的な備忘録記事。
概要については「[こちら](https://qiita.com/tomy0610/items/9e948154f9b8f56f28cf)」を参照。
事前に「[ほぼ全手順（2）](https://qiita.com/tomy0610/items/2ca951f8e511f9f477e8)」が完了していることが前提。

今回の内容は「画面のボタンから API を呼べるようにする」コーディングを行う。

## フロントエンド対応

Web ブラウザで表示するボタンを作り、そこから複数の内部 API が呼び出せるようにする。

### 1．ホーム画面用 コンポーネント生成

client/app の配下に 新たにホーム画面となるコンポーネントを作成する。ただし、先にホーム画面用モジュールを作成し、そこにコンポーネントをぶら下げる方式にする。

通常、コンポーネントは、AppModule の `declarations` に対して追加するが、SPA ではなく複数画面にするつもりなので、別モジュールにした方が後でメンテしやすい。

#### モジュール作成

```bash
# client/app に移動
cd client/app

# modules ディレクトリを作成し、そこに移動
mkdir modules
cd modules

# home モジュール作成
ng generate m home
```

home というディレクトリが自動生成され、その中に `home.module.ts`, `home.module.spec.ts` ができあがる。

#### コンポーネント作成

```bash
# コンポーネント作成 (-m オプションでモジュール指定)
ng generate c home -m home
```

home ディレクトリ内に `home.component.html`, `home.component.scss`, `home.component.ts`, `home.component.spec.ts` ができあがる。

`home.module.ts` には、コンポーネントが自動インポートされる。

### 2．ホーム画面 コンポーネント修正

ボタン押下処理を追加

```ts:client/app/modules/home/home.component.ts
...

export class HomeComponent implements OnInit {

  constructor() { }

  ngOnInit() {
  }

  // メソッド追加
  async testGet() {
    console.log('HomeComponent #testGet 呼出');
  }

  // メソッド追加
  async testPost() {
    console.log('HomeComponent #testPost 呼出');
  }

}
```

ボタン要素を追加

```html:client/app/modules/home/home.component.html
<p>
  home works!
</p>

<!-- ボタンを追加 -->
<div>
  <button (click)="testGet()">GET TEST</button>
</div>
<div>
  <button (click)="testPost()">POST TEST</button>
</div>
```

### 3．画面遷移できるようにする

#### AppModule にホーム画面モジュール追加

```ts:client/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 { HomeModule } from './modules/home/home.module'; // 追加

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HomeModule // 追加
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
```

#### 画面ルーティング情報にホーム画面コンポーネント追加

path の文字列がURLの末尾に指定されたら、作成したコンポーネントが表示されるように修正。

```ts:client/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { HomeComponent } from './modules/home/home.component'; // 追加

// 修正： const routes: Routes = []; を以下のように変更する
const routes: Routes = [
  {
    path: '',
    redirectTo: 'home',
    pathMatch: 'full'
  },
  {
    path: 'home',
    component: HomeComponent
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }
```
#### App コンポーネント修正

後で表示確認する時に、既存の記載が邪魔なので、必要なもの以外は決しておく。

```html:client/app/app.component.html
<!-- 以下のルーティング記載だけ残して、あとは消す -->
<router-outlet></router-outlet>
```

### 4．いったん確認

#### ローカル環境でアプリ起動

```bash
npm run local
```

#### Web ブラウザでアクセス
`http://localhost:3000` にアクセスし、以下のとおりボタンが表示されていればOK.
この時、ブラウザに搭載の「開発者ツール」などでコンソール表示しておく。

<img width="1552" alt="スクリーンショット 2018-11-25 20.27.09.png" src="https://qiita-image-store.s3.amazonaws.com/0/199602/e2bcea69-0d47-5aed-2484-bf60542feb32.png">

殺風景ですが、無事ホーム画面が出ました。。。
URL は自動的に `/home` にリダイレクトされています。

#### 画面のボタンを押してみる

まず、画面の「GET TEST」ボタンを押した結果（ブラウザ コンソール）

<img width="333" alt="スクリーンショット 2018-11-25 20.28.56.png" src="https://qiita-image-store.s3.amazonaws.com/0/199602/9e4d5267-f39a-6e3b-e5cc-0bfc2aeb0afc.png">

続けて、画面の「POST TEST」ボタンを押した結果（ブラウザ コンソール）

<img width="334" alt="スクリーンショット 2018-11-25 20.29.12.png" src="https://qiita-image-store.s3.amazonaws.com/0/199602/0c51ce32-0f0e-50b0-704c-1366cc6c256d.png">

こんな風に処理に埋め込んだコンソールが呼ばれていればOK.

### 5．HTTP 通信サービス生成

画面のボタンを押したら、HTTP 通信でサーバサイドにリクエストするサービスを追加する。

#### サービス作成

モジュール、コンポーネントをコマンド生成した時と同じ階層から、以下のコマンドを実行

```bash
# home ディレクトリに移動
cd home

# サービス生成
ng generate s home
```

home ディレクトリ配下に `home.service.ts`, `home.service.spec.ts` ができあがる。

#### モジュール修正

```ts:client/app/modules/home/home.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http'; // 追加
import { HomeComponent } from './home.component';
import { HomeService } from './home.service'; // 追加

@NgModule({
  imports: [
    CommonModule,
    HttpClientModule // 追加
  ],
  declarations: [HomeComponent],
  providers: [HomeService] // 追加
})
export class HomeModule { }
```

#### サービスに HTTP 通信処理を追加

`home.service.ts` を以下のとおりに書き換える。

```ts:client/app/modules/home/home.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';

@Injectable()
export class HomeService {

  constructor(protected http: HttpClient) { }

  async getTest<Req>(request: Req): Promise<any> {
    let response: any;
    const url = '/api/sample';

    try {
      // GET リクエスト生成
      let httpParams: HttpParams = new HttpParams();
      const keys = Object.keys(request);
      for (const key of keys) {
        httpParams = httpParams.append(key, request[key]);
      }

      response = await this.http.get<any>(url, {
        headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
        params: httpParams
      }).toPromise();
    } catch (error) {
      response = {
        val: 'GET リクエストでエラーが発生しました。',
        error: error
      };
    }

    return response;
  }

  async postTest<Req>(request: Req): Promise<any> {
    let response: any;
    const url = '/api/sample';

    try {
      response = await this.http.post<any>(url, request, {
        headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
      }).toPromise();
    } catch (error) {
      response = {
        val: 'POST リクエストでエラーが発生しました。',
        error: error
      };
    }

    return response;
  }
}
```

#### コンポーネント修正

サービスの HTTP 処理を呼び出すように修正する

`home.component.ts` を以下のとおりに書き換える。

```ts:client/app/modules/home/home.component.ts
import { Component, OnInit } from '@angular/core';

import { HomeService } from './home.service';

@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.scss']
})
export class HomeComponent implements OnInit {

  constructor(private service: HomeService) { }

  ngOnInit() {
  }

  async testGet() {
    console.log('HomeComponent #testGet 呼出');

    const request = {testKey: 'aaa', otherKey: 'bbb'};
    const response: any = await this.service.getTest(request);
    console.log('[response]\n', response);
  }

  async testPost() {
    console.log('HomeComponent #testPost 呼出');

    const request = {testKey: 'ccc', otherKey: 'ddd'};
    const response: any = await this.service.getTest(request);
    console.log('[response]\n', response);
  }

}
```

バックエンド作らないと動作確認ができないので、一旦これで、画面側の HTTP 通信ロジックは完了。

## バックエンド対応

複数の内部 API を、画面のボタンに合わせて呼び出せるようにする。

### 1.バックエンドのコントローラを作成

server ディレクトリ配下に controllers というディレクトリを作成し、
その配下に以下のとおりに sample.controller.ts というファイルを作る。

```ts:server/controllers/sample.controller.ts
/** サンプル コントローラ */
export class SampleContoroller {

  public async test(request: any): Promise<any> {

    // リクエスト内容にコメントを追加しただけの JSON レスポンス
    return {
      statusCode: 200,
      body: { result: `Your Request is\n${JSON.stringify(request)}` }
    };
  }

}
```

### 2.API のパスを管理するクラスを作成

server ディレクトリ配下に managers というディレクトリを作成し、
その配下に以下のとおりに api-path.manager.ts というファイルを作る。

```ts:server/managers/api-path.manager.ts
import { SampleContoroller } from '../controllers/sample.controller';

/** API パス インターフェース */
interface IApiInfo {
  /** HTTP メソッド */
  method: string;
  /** API パス */
  path: string;
  /** 実行処理 */
  process: (request: any) => Promise<any>;
}

/**
 * API パス 管理クラス
 *
 * HTTP メソッド、API パス、バックエンド コントローラの関係を管理する)
 */
export class ApiPathManager {

  public static readonly apis: IApiInfo[] = [
    { method: 'GET', path: '/api/sample', process: new SampleContoroller().test },
    { method: 'POST', path: '/api/sample', process: new SampleContoroller().test },
  ];

}
```

### 3.API のルーティングを管理するクラスを作成

server/managers ディレクトリ配下に、以下のとおりに middleware-router.manager.ts というファイルを作る。

```ts:server/managers/middleware-router.manager.ts
import * as KoaRouter from 'koa-router';

import { ApiPathManager } from './api-path.manager';

/**
 * ミドルウェア ルータ 管理クラス
 *
 * ミドルウェアのルーティングを設定し, APIを提供する
 */
export class MiddlewareRouterManager {

  public routes(): KoaRouter.IMiddleware {
    const koaRouter = new KoaRouter();

    // 各 API に対応したミドルウェアを提供
    for (const api of ApiPathManager.apis) {
      if (api.method === 'GET') {
        koaRouter.get(api.path, this.controller(api.process));
      } else if (api.method === 'POST') {
        koaRouter.post(api.path, this.controller(api.process));
      } else if (api.method === 'PUT') {
        koaRouter.put(api.path, this.controller(api.process));
      } else {
        koaRouter.delete(api.path, this.controller(api.process));
      }
    }

    return koaRouter.routes();
  }

  /** コントローラ呼出 */
  private controller(controll: (request: any) => Promise<any>) {

    return async function (ctx: KoaRouter.IRouterContext) {
      // GET, DELETE リクエストの時は、クエリから要求データを生成. それ以外の場合は、ボディから要求データを生成
      const ctxRequest = ctx.request;
      const request = (ctxRequest.method === 'GET' || ctxRequest.method === 'DELETE')
        ? ctxRequest.query : ctxRequest.body;

      // コントローラの結果を返却
      const response = await controll(request);
      ctx.status = response.statusCode;
      ctx.body = response.body;
    };
  }

}
```

先ほど作った api-path.manager.ts の情報を利用して、サービスから投げられる HTTP メソッドと URL を判定し、紐づく各コントローラに分岐するようなロジック。
GET や POST の HTTP メソッドによって、リクエストの取得元が異なる。

### 4.web-server.ts を修正

今作った middleware-router.manager.ts をインポートし、そこからルーティングされたバックエンドを、 `app.use()` を使ってアプリに提供するように変更する。
また、このファイルでは koa-router を使わなくなったので、インポートから消す。

```ts:server/web-server.ts
import * as Koa from 'koa';
import * as koaStatic from 'koa-static';
// import * as KoaRouter from 'koa-router'; // この行を削除
import * as bodyParser from 'koa-bodyparser';
import * as config from 'config';
import { join } from 'path';
import { MiddlewareRouterManager } from './managers/middleware-router.manager'; // 追加

...

app.use(bodyParser({
  // application/x-javascript 型のリクエストを JSON として処理できるようにする
  extendTypes: {
    json: ['application/x-javascript']
  }
}));

// ***** ↓↓ 削除 ↓↓ *****
// サーバサイド側 ミドルウェア ルーティングを定義
// const koaRouter = new KoaRouter();
// koaRouter.get('/api/*', (ctx) => {
//   ctx.status = 200;
//   ctx.body = { test: 'API が呼ばれました' };
// });
// ***** ↑↑ 削除 ↑↑ *****

// ***** ↓↓ 追加 ↓↓ *****
// サーバサイド側 ミドルウェアの提供
app.use(new MiddlewareRouterManager().routes());
// ***** ↑↑ 追加 ↑↑ *****
```

### 5.確認

##### アプリ起動

```bash
npm run local
```

アプリ起動したら、Web ブラウザから `http://localhost:3000` にアクセス。
「開発者ツール」を開きコンソール表示しておく。

<img width="1552" alt="スクリーンショット 2018-11-25 20.27.09.png" src="https://qiita-image-store.s3.amazonaws.com/0/199602/e2bcea69-0d47-5aed-2484-bf60542feb32.png">

##### ブラウザから API アクセス

画面の「GET TEST」ボタンを押した結果（ブラウザ コンソール）
GET リクエストのサービスが正常に呼ばれ、サンプルコントローラを通過してレスポンスが返却された。

<img width="285" alt="test_get_01.png" src="https://qiita-image-store.s3.amazonaws.com/0/199602/8daf5dec-6918-854c-21d4-35ac0065f49b.png">

続けて、画面の「POST TEST」ボタンを押した結果（ブラウザ コンソール）
POST リクエストのサービスが正常に呼ばれ、サンプルコントローラを通過してレスポンスが返却された。

<img width="285" alt="test_post_01.png" src="https://qiita-image-store.s3.amazonaws.com/0/199602/aa79f8ee-9745-d164-a271-067fc284342a.png">

#### 結果
これで、API を画面から呼び出せるようになった。
`/api/` に続く、別の API を追加する場合、処理を行うバックエンドを用意した後、`server/managers/api-path.manager.ts` にコントローラをインポートして、パスと HTTP メソッドを指定するだけで良い。

必要あれば、パスやメソッドを定数にしておくと良い。

## この後やること

ロギング機構を作る

→ 「[Angular6 で俺式 KOAN スタック Webアプリを構築する、ほぼ全手順（4）](https://qiita.com/tomy0610/items/b042290322527c627633)」に執筆