Typesciprtを活用してAPI呼び出し処理を共通化する

## 概要
AngularのHttpClientを使ったAPI呼び出しServiceを、Typescriptを活用して実装してみました。
少し複雑すぎる気もしますが、とりあえずやりたかったことはできたので、このタイミングでまとめてみます。
もっとシンプルにできるとか、そもそも使い方間違ってるなどありましたら、コメントいただけると助かります。

## 既存の課題
もともと、APIごとにHttpClientを使ったAPI呼び出しServiceを定義していました。
それをやめて、純粋なAPI呼び出し処理を共通化し、APIの追加修正を楽にしつつ、型推論もいい感じにできないかと考えたのがことの発端です。

↓もともとの課題例

```ts:api.service.ts

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

import { POST } from 'src/app/interface/api';
import { PATH } from 'src/app/const/api';

@Injectable({
  providedIn: 'root'
})
export class ApiService {

  constructor(private http: HttpClient) { }

  getItems(queryStrings: GetItemsQueryStrings, options: GetOptions) {
    return this.http.get<GetItemsResponseBody>('/items', options);
  }

  getItemsItemId(itemId: number, queryStrings: GetItemsQueryStrings, options: GetOptions) {
    return this.http.get<GetItemsItemIdResponseBody>(`/items/${itemId}`, options);
  }

}


```


## ゴール
目指したところは下記です。これらを実現するための実装を後述していきます。

- 利用する側で、APIのパス(またはAPIを一意に特定できるkey)を指定すると、自動でrequestBody、queryStrinig、responseBodyなどの型が推論されて、型の恩恵を十分に受けることが出来る。
- API呼び出し処理を共通化。API追加時にはAPIの定義やリクエスト・レスポンス型の定義を増やすだけでよい。
- APIに変更が入ったときには、型定義を修正するだけでよい。あとはそれによるコンパイルエラーを解決していく。

## 実現方法

### APIの型を定義
まずはAPIの型を定義していきます。
ここでは、`Post /item` というAPIを例に記述していきます。

まずAPIのリクエストボディとレスポンスボディを定義します。

```ts:interface/items.ts

export interface Item {
  id: number;
  name: string;
  tags: string[];
}

export type PostItemsRequestBody = Omit<Item, 'id'>;

export type PostItemsResponseBody = Item;
```

次にMethodごとに、Lookup Typesを活用し下記のようなintefaceを定義していきます。今回はPostだけ定義します。
この定義を行うことによって、Keyをもとに、queryStrinigやrequestBody、responseBodyをTypescriptに推論させることができます。

```ts:interface/api.ts
import { PostItemsRequestBody, PostItemsResponseBody } from 'src/app/interface/items';

export interface POST {
  'Post-/items': { // この'Post-/items'が、型を推論していくためのKeyとなる
    requestBody: PostItemsRequestBody;
    responseBody: PostItemsResponseBody
  }
}

```

### APIのパスを定義
次にAPIのパスを定義をしていきます。
先ほどのLookup TypesのKeyをもとにAPIのパスを特定できるようにします。

```ts:const/api.ts
import { POST } from 'src/app/interface/api';

const ENDPOINT = 'http://hoge.com/api/v1';
const items = `${ENDPOINT}/items`;

// { [K in keyof POST]: string } ←これでPath定義にLookup Typesで定義したKeyのPathが必ず定義されることを保証
export const PATH: { [K in keyof POST]: string } = {
  'Post-/items': items, // この定義がないとtsのコンパイルエラーになる
}

```

パスの定義もLookup Typesのなかで定義させてもいいのですが、それだとpahtのなかで変数を利用したくなったときなどに利用できないので、定数としてPathの定義を行っています。

ここまで定義すると、`Post-/items` というKeyをもとに、requestBody、responseBody、pathを推論で導きだすことができるようになります。


### ApiServiceを実装
今回の一番のメインになる部分です。
これまでの定義をもとにAPI呼び出しメソッドを実装します。
下記のように、POST APIを呼び出すための共通メソッドを定義しておくことで、API呼び出しを共通化できつつ、型推論を活用することができます。

```core/api.service.ts

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

import { POST } from 'src/app/interface/api';
import { PATH } from 'src/app/const/api';

@Injectable({
  providedIn: 'root'
})
export class ApiService {

  constructor(private http: HttpClient) { }

  // POST APIを呼び出すときはこのメソッドをKey指定して実行する。必要な引数の型は自動で推論される。
  post<T extends keyof POST>(key: T, requestBody: POST[T]['requestBody'], options: PostOptions) {
    return this.http.post<POST[T]['responseBody']>(PATH[key], requestBody, options);
  }

}

export type PostOptions = HttpClient['post'] extends (arg1: any, arg2: any, options: infer U) => any ? U : never;

```

### 実際にAPIを呼び出す
先ほど実装したAPI Serviceのメソッドを使って、APIを呼び出していきます。
ここでようやく、型推論のありがたさを実感することができます。

```ts:app.component.ts

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

import { ApiService } from './core/api.service';

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

  constructor(private api: ApiService) {}

  call() {
    // Lookup TypesのPOSTで定義していないKeyを指定するとコンパイルエラー
    this.api.post<'Post-/items'>(
      'Post-/items', // 推論されるので、VS Codeの補完で一発入力できる
      { name: 'hoge-item', tags: ['angular', 'typescript'] }, // PostItemsRequestBodyであることが推論されるので、その型にそったObjectでないとコンパイルエラー
      {}
    ).subscribe(res => {
      console.log('item: ', res); // PostItemsResponseBodyであることが推論される
    });
  }
}


```

今後、APIが増えた際には、APIの型定義を追加していくだけで、型推論を実現しつつ、上記と同様にAPIを呼び出すことができます。


### [Option] PathParameter問題を解決する

AngularのHttpClientでは、APIのPathを指定する時点で、PathParameterが置換された状態にしておく必要があります。
このPathParameterを置換する処理も共通化していきましょう。
ここでは、`GET /items/:id` というAPIを例に記述していきます。

#### APIの型を定義

まず、この例では、新しいAPIを使うので、そのAPIの型と、GETのLookup Typesを定義していきます。

```ts:interface/items.ts

export interface Item {
  id: number;
  name: string;
  tags: string[];
}

export type PostItemsRequestBody = Omit<Item, 'id'>;
export type PostItemsResponseBody = Item;

// 追加
export type GetItemsItemIdQueryStrings = { limit?: number; };
export type GetItemsItemIdResponseBody = Item[];
```


```ts:interface/api.ts
import {
  PostItemsRequestBody,
  PostItemsResponseBody,
  GetItemsItemIdQueryStrings,
  GetItemsItemIdResponseBody,
} from 'src/app/interface/items';

export interface POST {
  'Post-/items': {
    requestBody: PostItemsRequestBody;
    responseBody: PostItemsResponseBody
  }
}

// 追加
export interface GET {
  'Get-/items/:id': {
    queryStrings: GetItemsItemIdQueryStrings,
    responseBody: GetItemsItemIdResponseBody
  }
}

```

#### PathParameterの型を定義
次に、すべてのAPIで指定しうるPathParameterや置換する文字列、APIで必須になるPathParameterを定義していきます。

```ts:const/api.ts
// すべてのAPIで指定しうるPathParameterを定義。APIによって不要だったりするのでundefinedを許容します
export interface PathParams {
  itemId?: number;
}

// APIごとに必須PathParameterを指定するためのMapped Type
export type RequiredPathParams<T extends keyof PathParams> = {
  [K in T]: PathParams[K];
};

// 置換する文字列のマッピング
export const PATH_PARAMS_REPLACE_MAPPING: { [K in keyof PathParams]-?: string; } = {
  itemId: ':ITEM_ID:',
};

const ENDPOINT = 'http://hoge.com/api/v1';
const items = `${ENDPOINT}/items`;
const items__item_id = `${ENDPOINT}/items/${PATH_PARAMS_REPLACE_MAPPING.itemId}`; // ApiServiceで:ITEM_ID:が置換されるようにする

exporot const PATH: { [K in keyof GET | keyof POST]: string } = {
  'Get-/items/:id': items__item_id,
  'Post-/items': items,
};

```

PathParameterまわりの定義ができたら、GETのLookup Typesを拡張して、APIごとにPathParameterの必須パラメータを推論できるようにします。

```ts:interface/api.ts
import {
  PostItemsRequestBody,
  PostItemsResponseBody,
  GetItemsItemIdQueryStrings,
  GetItemsItemIdResponseBody,
} from 'src/app/interface/items';
import { RequiredPathParams } from 'src/app/const/api' ;

export interface POST {
  'Post-/items': {
    requestBody: PostItemsRequestBody;
    responseBody: PostItemsResponseBody
  }
}

export interface GET {
  'Get-/items/:id': {
    queryStrings: GetItemsItemIdQueryStrings,
    pathParams: RequiredPathParams<'itemId'>, // 追加。指定するkey名は推論されるので、PathParamsで定義していないkeyを指定するとコンパイルエラー
    responseBody: GetItemsItemIdResponseBody
  }
}
```

#### ApiServiceを拡張
ApiServiceを拡張し、API呼び出し時にPathParameterを指定できるようにします。
また、ApiService内で、PathParameterの置換を行うようにします。

```core/api.service.ts

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

import { POST, GET } from 'src/app/interface/api';
import { PATH, PathParams, PATH_PARAMS_REPLACE_MAPPING } from 'src/app/const/api';

@Injectable({
  providedIn: 'root'
})
export class ApiService {

  constructor(private http: HttpClient) { }

  post<T extends keyof POST>(key: T, requestBody: POST[T]['requestBody'], options: PostOptions) {
    return this.http.post<POST[T]['responseBody']>(PATH[key], requestBody, options);
  }
  
  // 追加。APIごとにpathParams、queryStringsの型が推論される。
  get<T extends keyof GET>(key: T, pathParams: GET[T]['pathParams'], queryStrings: GET[T]['queryStrings'], options: GetOptions) {
    const path = this.replacePathParams(PATH[key], pathParams); // pathParamsを置換
    return this.http.get<GET[T]['responseBody']>(path, options);
  }
  
  // 追加。pathParamsの置換を行うメソッド。strict:trueのため冗長な実装になってる。もっとシンプルにかきたい
  private replacePathParams(path: string, params: PathParams | null): string {
    if (params == null) { return path; }
    for (const [key, value] of Object.entries(params)) {
      const target = Object.entries(PATH_PARAMS_REPLACE_MAPPING).find(([k, v]) => k === key);
      if (target == null) { continue; }
      const [replaceKey, replaceValue] = target;
      path = path.replace(replaceValue, String(value));
    }
    return path;
  }

}

export type PostOptions = HttpClient['post'] extends (arg1: any, arg2: any, options: infer U) => any ? U : never;
export type GetOptions = HttpClient['get'] extends (arg1: any, options: infer U) => any ? U : never;
```

#### 実際にAPIを呼び出す
先ほど実装したAPI Serviceのメソッドを使って、APIを呼び出します。
APIごとに必要なPathParameterやQueryStringが推論されるので、誤った値が指定されるのを防ぐことが出来ます。  

```ts:app.component.ts

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

import { ApiService } from './core/api.service';

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

  constructor(private api: ApiService) {}

  call() {
    this.api.get<'Get-/items/:id'>(
      'Get-/items/:id', // 値が推論されるので、VS Codeの補完で一発入力できる
      { itemId: 1 }, // 必須のPathParameterが推論されるので、置換必要なPathParameterがちゃんと置換されてリクエストされることを保証できる。
      { limit: 1 },  // 指定可能なqueryStrinigsも推論されるので、想定外のQueryStringが指定されないことを保証できる。
      {}
    ).subscribe(res => {
      console.log('item: ', res); // GetItemsItemIdResponseBodyであることが推論される
    });
  }
}


```

## まとめ
- Typescriptをうまく活用すれば、API呼び出しServiceを共通化したときにも、APIごとに型を推論させることができるので、API呼び出しが安心安全楽にできそう。
- APIが追加になったときには、APIの型定義を追加すればいいだけなので、メンテしやすそう。
- Typescript難しいけど楽しい


## 参考
こちらの書籍を大変参考にさせていただきました。
[実践TypeScript ~ BFFとNext.js&Nuxt.jsの型定義~](https://www.amazon.co.jp/%E5%AE%9F%E8%B7%B5TypeScript-BFF%E3%81%A8Next-js-Nuxt-js%E3%81%AE%E5%9E%8B%E5%AE%9A%E7%BE%A9-%E5%90%89%E4%BA%95-%E5%81%A5%E6%96%87/dp/483996937X)