はじめからはじめる @ngrx/data 第3話(Angular In Memory Web API 設定編)

@ngrx/data の連載も 3 回目を迎えました。

前回は、エンティティの CRUD 操作に必要な NgRx まわりのコーディングを終えたのですが(実質10行)、肝心の DB 環境がないというところで終わってしまっていました。

今回は簡単のため、Web API と DB をエミュレートする Angular In Memory Web API を使って、続きをすすめていきたいと思います。

前回の話
tercel-tech.hatenablog.com

Angular In Memory Web API とは

山田よしひろ著『Angular アプリケーションプログラミング』によると、DB や API 用にサーバを用意できない開発の初期段階で使えるお手軽な API エミュレータだよ。

(略) Angular では、Web API をエミュレートするためのライブラリ (angular-in-memory-web-api) を提供しています。 これを使うと、Http / Json サービス[原文ママ]が利用するバックエンドを置き換え、(ネットワーク上のデータではなく)メモリ上のデータを読み書きするようになります。 本来の Web API に代わって擬似データを返す、テストダブルの一種と言っても良いでしょう。

f:id:tercel_s:20200215125243j:plain:w400
Angular In Memory Web API の仕組み

外部環境に依存せずに Web API が試せるのはいいね。

そうだね。

たとえばチュートリアルに出てくる DBMS が自分のよく知らないやつだったりすると、認知負荷が上がってやる気なくすでしょ。

確かに。。。



Angular In Memory Web API を使ってみよう

Angular In Memory Web API はオプションなので、別途インストールが必要だよ。

とりあえず入れてしまおう。

$ npm i angular-in-memory-web-api -S


続いて、擬似データベースを作る。

これは InMemoryDbService実装implementsしたクラスを作るだけでいい。

$ ng g class MockWebApi


src/app/mock-web-api.ts

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

export class MockWebApi implements InMemoryDbService {
  private api = {
    heroes: [
      { id: 1, name: 'Tercel' }, 
      { id: 2, name: 'Bob'    }
    ]
  };

  createDb() {
    return this.api;
  }
}

createDb() メソッドは必ず書く必要があるんだね。

createDb() は、エンティティ名: データ のオブジェクトを返すメソッドで、これがないとコンパイルエラーになるからね。

ふむ。

続いて、これを app.module.ts に登録する。

差分を示すとこんな感じ。

src/app/app.module.ts (差分のみ)

+ import { InMemoryWebApiModule } from 'angular-in-memory-web-api';
+ import { MockWebApi } from './mock-web-api';

  @NgModule({
    declarations: [
      AppComponent
    ],
    imports: [
      BrowserModule,
      AppRoutingModule,
      HttpClientModule,
+     InMemoryWebApiModule.forRoot(MockWebApi),
      EffectsModule.forRoot([AppEffects]),
      StoreModule.forRoot(reducers, {
        metaReducers,
        runtimeChecks: {
          strictStateImmutability: true,
          strictActionImmutability: true
        }
      }),
      StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: environment.production }),
      EntityDataModule.forRoot(entityConfig)
    ],
    providers: [],
    bootstrap: [AppComponent]
  })
  export class AppModule { }

これで準備OK?

ひとまずはね。

これで以下のような HTTP リクエストを投げることで、擬似データベースに対して CRUD が行えるようになる。 URL さえ合っていればね。

HTTP METHOD MEANING
POST api/heroes 新しいエンティティを追加します
DELETE api/heroes/5 主キーの値でエンティティを削除します
GET api/heroes このエンティティタイプのすべてのインスタンスを取得します
GET api/heroes/5 主キーでエンティティを取得します
GET api/heroes?name=bombasto クエリを満たすエンティティを取得します
PUT api/heroes/5 既存のエンティティを更新します


ただ、@ngrx/data が投げる API URL と、Angular In Memory Web API が受けられる API URL は微妙に異なっているのがちょっと厄介なんだ。

具体的には、Angular In Memory Web API は単数形と複数形を区別できない

@ngrx/data Angular In Memory Web API
POST api/hero/ POST api/heroes
DELETE api/hero/5 DELETE api/heroes/5
GET api/heroes/ GET api/heroes
GET api/hero/5 GET api/heroes/5
GET api/heroes/?name=bombasto GET api/heroes?name=bombasto
PUT api/hero/5 PUT api/heroes/5

微妙に合ってないね。 これじゃまだ動作確認してもレスポンスエラーになるね。


とりあえずの動作確認

まぁ、今のところデータの全件取得くらいなら URL が一緒なので試せるよ。 やってみようか。


src/app/app.component.ts

import { Component, OnInit } from '@angular/core';
import { EntityCollectionService, EntityServices } from '@ngrx/data';
import { Observable } from 'rxjs';
import { Hero } from './hero';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  private heroService: EntityCollectionService<Hero>;

  heroes$: Observable<Hero[]>;

  constructor(entityServices: EntityServices) {
    this.heroService = entityServices.getEntityCollectionService<Hero>('Hero');
    this.heroes$ = this.heroService.entities$;
  }

  ngOnInit() {
    this.heroService.getAll()
      .subscribe(
        () => console.log('エンティティを取得しました'),
        err => console.error(`エンティティの取得に失敗しました: ${err}`));
  }
}


src/app/app.component.html

<table>
  <tr>
    <th>id</th>
    <th>name</th>
  </tr>
  <tr *ngFor="let hero of heroes$ | async">
    <td>{{hero.id}}</td>
    <td>{{hero.name}}</td>
  </tr>
</table>


src/app/app.component.scss

table, th, td {
  border: 1px darkgray solid;
  border-collapse: collapse;
}

th, td {
  padding-left: 1rem;
  padding-right: 1rem;
}

th {
  background-color: lightgray;
}


ここまで書けたら、動かしてみるのだ。

CSS は面倒なら書かなくていいのだ。

口調がハム太郎かよ。

$ ng serve


f:id:tercel_s:20200215100129p:plain
おー、擬似データベースに登録した初期データがロードされてる。

そうだね。

それに、@ngrx/store-devtools を入れたおかげで、Redux DevTools が使えるようになっている。


f:id:tercel_s:20200215100153p:plain:w300

これはなにもの?

エンティティのキャッシュが、いつどうやって更新されたかを容易にデバッグできるツールだよ。

[Hero] @ngrx/data/query-all がクエリ開始、[Hero] @ngrx/data/query-all/success がクエリ正常終了という具合に、アプリケーションの内部状態の変化を観察できる

── と同時に、NgRx が管理している情報がどう変化したか、差分を確認することもできるんだ。


ふむ。


API URL のアダプタを作ろう

さっきも話した通り、このままだと @ngrx/data が投げる API URL と、Angular In Memory Web API が受けられる API URL が異なっているので、ちょっと手当てをしないといけない。

URL の文字列を変換するメソッドがあればいいのかな?

そうだね、、ひとまず変換用の urlTranslate() メソッドをガワだけ作っておこう。

src/app/mock-web-api.ts

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

  export class MockWebApi implements InMemoryDbService {
    private api = {
      heroes: [
        { id: 1, name: 'Tercel' }, 
        { id: 2, name: 'Bob'    }
      ]
    };

    createDb() {
      return this.api;
    }

+   urlTranslate(url: string): string {
+     throw new Error('まだ実装していません');
+   }
  }

今回ばかりはテストコードを書いておこう。

変換のパターンが複雑だからね。

@ngrx/data が放つリクエス Angular In Memory Web API が放つリクエス
POST api/hero/ POST api/heroes
DELETE api/hero/5 DELETE api/heroes/5
GET api/heroes/ GET api/heroes
GET api/hero/5 GET api/heroes/5
GET api/heroes/?name=bombasto GET api/heroes?name=bombasto
PUT api/hero/5 PUT api/heroes/5

単数形を複数形にすればいいというものじゃないね。

初めから複数形が飛んでくることもあるし、間のスラッシュを抜かなきゃいけないパターンもある。

テストコードはこんな感じでよかろう。

src/app/mock-web-api.spec.ts

import { MockWebApi } from './mock-web-api';

describe('MockWebApi', () => {
  it('should create an instance', () => {
    expect(new MockWebApi()).toBeTruthy();
  });

  describe('urlTranslate() [ EntityName: Hero ]', () => {
    let mockWebApi: MockWebApi;
    beforeEach(() => mockWebApi = new MockWebApi());

    it('api/hero/ -> api/heroes', () => {
      expect(mockWebApi.urlTranslate('api/hero/')).toEqual('api/heroes');      
    });

    it('api/hero/5 -> api/heroes/5', () => {
      expect(mockWebApi.urlTranslate('api/hero/5')).toEqual('api/heroes/5');
    });

    it('api/heroes/ -> api/heroes', () => {
      expect(mockWebApi.urlTranslate('api/heroes/')).toEqual('api/heroes');
    });

    it('api/heroes/?name=bombasto -> api/heroes?name=bombasto', () => {
      expect(mockWebApi.urlTranslate('api/heroes/?name=bombasto'))
        .toEqual('api/heroes?name=bombasto');
    });
  });
});

はい、じゃあこれらのテストをパスするように urlTranslate() メソッドを書いてください。

了解、テストを通せばいいんだね。

src/app/mock-web-api.ts

import { InMemoryDbService } from 'angular-in-memory-web-api';
import { entityConfig } from './entity-metadata';

export class MockWebApi implements InMemoryDbService {
  private api = {
    heroes: [
      { id: 1, name: 'Tercel'}, 
      { id: 2, name: 'Bob'   }
    ]
  };

  createDb() {
    return this.api;
  }

  private get lowerCasePluralNames(): {} | { [name: string]: string } {
    return !entityConfig.pluralNames ? {} :
      Object.entries(entityConfig.pluralNames)
        .reduce((x, y) => 
          Object.assign(x, { [y[0].toLowerCase()]: y[1].toLowerCase() }), {});
  }

  private toPlural(singluarName: string): string {
    return this.lowerCasePluralNames[singluarName] || `${singluarName}s`;
  }

  private convertApiEntityName(entityName: string): string {
    return Object.keys(this.api)
      .map(x => x.toLowerCase())
      .includes(entityName) ? entityName : this.toPlural(entityName);
  }

  urlTranslate(url: string): string {
    const regexp = /(\/?[^\/]+\/)([^\/]+)(\/?.*)/g;
    const match = regexp.exec(url);

    if(!match || match.length !== 4 ) { throw new Error('URL形式が不正です')}

    const requestEntityName = match[2];
    const apiEntityName = this.convertApiEntityName(requestEntityName);

    return url.replace(regexp, `$1${apiEntityName}$3`)
      .replace(/\/\?/g, '?')
      .replace(/\/$/g, '');
  }
}

ちなみに、テストを実行するには以下のコマンドを打てばいい。

$ ng test


f:id:tercel_s:20200215120015p:plain

全部通りました。

これしきのテストを通すためだけにあそこまで書く必要はなかったと思うけど……まぁいいか。


インターセプタ

じゃあ、いま作った urlTranslate() を使って、リクエストが飛んできたタイミングで API URL を変換するインターセプタ迎撃機を作ろう。

どうするの?

こんなふうに、InMemoryDbServiceparseRequestUrl() メソッドをオーバーライドすればいいよ。

src/app/mock-web-api.ts(差分のみ)

- import { InMemoryDbService } from 'angular-in-memory-web-api';
+ import { InMemoryDbService, RequestInfoUtilities, ParsedRequestUrl } from 'angular-in-memory-web-api';
  import { entityConfig } from './entity-metadata';

  export class MockWebApi implements InMemoryDbService {
    private api = {
      heroes: [
        { id: 1, name: 'Tercel'}, 
        { id: 2, name: 'Bob'   }
      ]
    };

    createDb() {
      return this.api;
    }

    /* 略 */

    urlTranslate(url: string): string {
      /* 略 */
    }

+   parseRequestUrl(url: string, utils: RequestInfoUtilities): ParsedRequestUrl {
+     return utils.parseRequestUrl(this.urlTranslate(url));    
+   }
  }

ふむ。

これで、@ngrx/data が放つ基本的な API URL と、Angular In Memory Web API が受ける URL が一致したはずなので、全件取得だけでなく追加・更新・削除もいけるはず。

実際にやるのは次回だけど。

今回はほとんど NgRx 関係なかったね。

Angular In Memory Web API とか Jasmine とか……。

あの、アダプタを作るところで思ったんだけど、たーせるくん面倒くさいコード書きすぎ

Angular In Memory Web API なんて環境を用意できない開発の初期にちょっとだけ使ってすぐ捨てるモノだから、変に汎用性とか意識しなくていいのに。

……。

……ゴメン。

次回は、コンポーネントから CUD の API を叩けるようにしていこう。

完結編につづく
tercel-tech.hatenablog.com

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.