Angular In Memory Web API を使ってモックサーバを立てる

Angular アプリの開発中、Web API サーバをモック化する時は、angular-in-memory-web-api を使うと簡単にモック API サーバが用意できる。

少々つまづいたところがあったので、実装の仕方を確認していこう。

目次

プロジェクトを用意する

まずは Angular CLI の ng new コマンドで Angular プロジェクトの雛形を作ろう。今回は @angular/cli@1.6.4 を使用して、Angular5 系の雛形を作成した。

$ ng new api-example

雛形ができたら、Angular In Memory Web API をインストールする。

$ npm install angular-in-memory-web-api --save

コレでひとまず、$ npm start でサーバが起動するか確認しておこう。

HTTP 通信する画面を作る

今回は AppComponent にて、POST 送信を行う簡単なプログラムを用意する。今回は HttpClient で実装したが、以前の Http モジュールを利用しても問題ない。

import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';

import { environment } from '../environments/environment';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  /** 通信結果を表示するための項目 */
  public result: any;
  
  /**
   * コンストラクタ
   * 
   * @param http HttpClient
   */
  constructor(private http: HttpClient) {}
  
  /**
   * GET 通信する
   * 
   * @param path ドメイン以下のパス
   */
  doGet(path: string): void {
    // サーバ URL : 環境変数によって実際のサーバ URL にするか、モックサーバを示す文字列にするか切り替える
    const serverUrl = environment.production ? 'http://example.com/' : 'mock-server/';
    // サーバ URL とパスを結合して URL を生成し GET 通信する
    this.http.get(serverUrl + path)
      .toPromise()
      .then((response) => {
        this.result = response;
      })
      .catch((error) => {
        this.result = error;
      });
  }
}

HTML 側は以下のとおり。

<h1>Angular In Memory Web API Example</h1>
<ul>
  <li><button type="button" (click)="doGet('users')">ユーザ情報を取得する</button></li>
</ul>
<div *ngIf="result">
  <h2>結果</h2>
  <p>{{ result | json }}</p>
</div>

今回は簡単にするため、サービスクラスも作っていないし、HTML 中に doGet('users') と URL 文字列の一部をもたせたりしていて、かなりお行儀の悪いコードだが、お許しいただきたい。

ポイントは、HttpClient で通信するサーバ URL を開発中だけ http://example.com/ ではなく mock-server/ となるよう切り替えているところ。コレにより、「ユーザ情報を取得する」ボタンを押下すると、開発中は mock-server/users という URL に HTTP 通信を試みるワケである。

コンポーネントができたら、AppModuleimportsHttpClientModule を追加しておく。コレでひとまず HTTP 通信を行おうとする画面ができあがる。

import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';

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

Web API モックを構成するサービスクラスを作成する

次に、Web API のモックとして、URL やレスポンスデータを定義するサービスクラスを作成する。

$ npm run ng generate service mock-web-api

このコマンドで src/app/mock-web-api.service.ts を作成したら、以下のように実装する。

import { Injectable } from '@angular/core';

import { InMemoryDbService } from 'angular-in-memory-web-api';

@Injectable()
export class MockWebApiService implements InMemoryDbService {
  /** モックデータ : 標準的な Web API の URL と対応させるため、データは配列で定義し、各要素は id プロパティが必須 */
  private api: any = {
    // ユーザ情報とか
    users: [
      { id: 1, name: 'Marty' },
      { id: 2, name: 'Jennifer' }
    ]
  };
  
  /**
   * InMemoryDbService から継承 : モックデータを作成する
   * 
   * @return モックデータ
   */
  public createDb(): any {
    return this.api;
  }
}

ポイントは InMemoryDbService を継承している点だ。createDb() もオーバーライドしているメソッドで、このメソッドで返却したオブジェクトが Web API の URL と自動的に対応するようになる。

つまり、この設定だけで以下の URL でそれぞれデータをアクセスできるようになる、ということだ。

この「オブジェクトと URL の自動マッピング」については、あとで詳しく詳細する。

サービスクラスができたら、コレをモックサーバとして動作させるために、AppModule に以下のように追加する。

import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';

import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';

import { AppComponent } from './app.component';
import { MockWebApiService } from './mock-web-api.service';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule,
    HttpClientInMemoryWebApiModule.forRoot(MockWebApiService)  // ← 追加
  ],
  providers: [
    MockWebApiService  // ← 追加
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

コレで完了。

開発中は mock-server/users にアクセスすることになり、MockWebApiService にて定義した users のデータが返却されるようになる。

「ユーザ情報を取得する」ボタン

  ↓ 以下が取得・画面表示される

[ { "id": 1, "name": "Marty" }, { "id": 2, "name": "Jennifer" } ]

ID を指定して特定データのみ取得する

さて、標準的な RESTful API の URL 設計に基づけば、/users はデータを一覧形式で取得でき、/users/2 のように ID を指定すれば、個別の ID が取れるはずだ。

Angular In Memory Web API はこうした RESTful API の URL を自動的に判別してデータを返してくれるので、いきなり /users/2 にアクセスして Jennifer のデータのみを取得することができる。

実際にやってみよう。HTML に以下の1行を追加する。

<h1>Angular In Memory Web API Example</h1>
<ul>
  <li><button type="button" (click)="doGet('users')">ユーザ情報を取得する</button></li>
  <li><button type="button" (click)="doGet('users/2')">ID : 2 のユーザ情報を取得する</button></li>  <!-- ←追加 -->
</ul>
<div *ngIf="result">
  <h2>結果</h2>
  <p>{{ result | json }}</p>
</div>

コレだけで、「ID : 2 のユーザ情報を取得する」ボタンを押すと、以下のようなデータが取得できる。

{ "id": 2, "name": "Jennifer" }

その他にも、クエリパラメータなども受け付けてくれるようだ。詳しくは以下の「HTTP request handling」を参照。

コレは便利だ。しかし問題が…。

モックデータオブジェクト・URL に制約ができる

createDb() で返却したオブジェクトが自動的に URL とマッピングされるのは便利だが、この機能のためにオブジェクトの内容に以下のような制約が発生する。

そして、このようなオブジェクトの制約により、http://example.com/book/genres のように2階層以上くだるパスが扱えないという問題が発生している。標準的な RESTful API の URL 設計に基づいていればこのような URL になることは珍しいかもしれないが、それにしてもモックサーバ用ライブラリの都合で URL が決まってしまうのはつらい。

2階層以上のパスを解釈させる方法

では、どうやって /book/genres のような2階層以上のパスを解釈させるか、というと、InMemoryDbService が持つ parseRequestUrl() というメソッドをオーバーライドし、リクエスト URL を内部的に1階層のパスに変換することで対処できる。

実際にやってみる。MockWebApiService に以下のように bookGenres データと parseRequestUrl() メソッドを追加する。

import { InMemoryDbService, ParsedRequestUrl, RequestInfoUtilities } from 'angular-in-memory-web-api';

@Injectable()
export class MockWebApiService implements InMemoryDbService {
  /** モックデータ : 標準的な Web API の URL と対応させるため、データは配列で定義し、各要素は id プロパティが必須 */
  private api: any = {
    // ユーザ情報とか
    users: [ /* …中略… */ ],
    // 本のジャンル名 : '/book/genres' でアクセスされた時に対応させるデータ
    bookGenres: [
      { id: 1, genre: 'Sience Fiction' },
      { id: 2, genre: 'Drama' },
      { id: 3, genre: 'History' }
    ],
  };
  // …中略…
  
  /**
   * InMemoryDbService から継承 : リクエスト URL を変換する
   * 
   * @param url リクエスト URL
   * @param utils リソース情報のユーティリティ
   * @return 変換された URL 情報
   */
  public parseRequestUrl(url: string, utils: RequestInfoUtilities): ParsedRequestUrl {
    // 'book/genres' という URL を 'bookGenres' に変換する
    const replacedUrl = url.replace('book/genres', 'bookGenres');
    // リクエスト情報を変換する
    return utils.parseRequestUrl(replacedUrl);
  }

parseRequestUrl() の第1引数の URL 文字列を replace() で変換し、第2引数の RequestInfoUtilities を使って ParsedRequestUrl 型に変えてやれば良い。

ココでの URL 置換処理は全ての通信に影響するので、慎重に変換したい。例えば以下のような汎用的な置換処理でも同等の結果は得られるが、こうすると今度は /users/2 といった URL が /users2 のようになってしまうため、あまり使えない。

const replacedUrl = url
  .replace('mock-server/', '')  // 先頭の 'mock-server/' を除去する : これによりトップレベルのパスは置換対象外にする
  .replace(/\/./g, (match) => {
    // '/a' 部分を取得し、'A' と大文字化する : これにより 'book/genres' は最終的に 'bookGenres' と置換される
    return match.replace(/\//g, '').toUpperCase();
  })
  .replace(/^/, 'mock-server/');  // 先頭に 'mock-server/' を再度付与する

面倒だが、必要なパスだけ置換するようにした方が良いかも。

実際にデータを取得してみる

それでは実際にデータを取得してみよう。コンポーネントの HTML に以下を追加する。

<li><button type="button" (click)="doGet('book/genres')">本のジャンル情報を取得する</button></li>

これで mock-server/book/genres への HTTP 通信が発生するが、parseRequestUrl() が URL を /mock-server/bookGenres に変換するため、api に宣言された bookGenres オブジェクトが取得され、以下の結果が得られる。

[ { "id": 1, "genre": "Sience Fiction" }, { "id": 2, "genre": "Drama" }, { "id": 3, "genre": "History" } ]

思ったように動いてくれた。

レスポンスデータをカスタマイズする

今度は全く別の例。

次は、/blood-type というパスにアクセスした時に、配列形式ではなく単一のオブジェクトを返却するようにしたい。

Angular In Memory Web API の標準仕様では、前述の /users のように、配列形式でしかデータを取得できないはずだ。しかし、responseInterceptor() というメソッドをオーバーライドすると、特定のパスへのアクセス時にレスポンスデータをカスタマイズできるのだ。

実際に実装してみる。MockWebApiService に以下のように blood-type モックデータと responseInterceptor() メソッドを追加する。

import { Injectable } from '@angular/core';

import { InMemoryDbService, ParsedRequestUrl, RequestInfoUtilities } from 'angular-in-memory-web-api';

@Injectable()
export class MockWebApiService implements InMemoryDbService {
  /** モックデータ : 標準的な Web API の URL と対応させるため、データは配列で定義し、各要素は id プロパティが必須 */
  private api: any = {
    users: [ /* 中略 */ ],
    bookGenres: [ /* 中略 */ ],
    // 血液型情報 : '/blood-type' で内部のオブジェクト部分のみ返却させる
    'blood-type': [
      {
        id: 0,
        // この部分を返却する
        data: {
          A: 'A型',
          B: 'B型',
          AB: 'AB型',
          O: 'O型'
        }
      }
    ]
  };
  
  // …中略…
  
  
  /**
   * InMemoryDbService から継承 : データを返却する処理
   * 
   * @param responseOptions レスポンスデータを含むオブジェクト
   * @param requestInfo リクエスト情報
   */
  public responseInterceptor(responseOptions: any, requestInfo: any): any {
    // '/blood-type' へのアクセス時は内部のオブジェクトデータを返却する
    if(requestInfo.collectionName === 'blood-type') {
      responseOptions.body = this.api['blood-type'][0].data;
    }
    
    return responseOptions;
  }

responseInterceptor()responseOptions.body にお好みのデータを代入し直してやれば、レスポンスデータを変更できる。ココでは、キー blood-type の配列0番目の要素が持つ data プロパティの値、つまり連想配列オブジェクトを返却するようにした。

なお、ココで参照している requestInfo.collectionName は、parseRequestUrl() メソッドで変換した後の URL となる。つまり、先程の書籍一覧のレスポンスデータを操作したい場合は、'book/genres' ではなく 'bookGenres' であるかどうかでチェックしないといけない。

HTML 側にこのパスを叩くボタンを追加して、実際に動作させてみよう。

<li><button type="button" (click)="doGet('blood-type')">血液型情報を取得する</button></li>

これで、以下のような結果が得られるはずだ。

{ "A": "A型", "B": "B型", "AB": "AB型", "O": "O型" }

まとめ

かなり機能が多く、まだまだ紹介しきれていないノウハウも多々ある。設定の癖も強いので、一つずつ調べながら実装しよう。