Help us understand the problem. What is going on with this article?

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

概要

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

既存の課題

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

↓もともとの課題例

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のリクエストボディとレスポンスボディを定義します。

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に推論させることができます。

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のパスを特定できるようにします。

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を呼び出していきます。
ここでようやく、型推論のありがたさを実感することができます。

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を定義していきます。

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[];
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を定義していきます。

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

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

};

// 置換する文字列のマッピング
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の必須パラメータを推論できるようにします。

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が推論されるので、誤った値が指定されるのを防ぐことが出来ます。

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の型定義~

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away