概要
AngularのHttpClientを使ったAPI呼び出しServiceを、Typescriptを活用して実装してみました。
少し複雑すぎる気もしますが、とりあえずやりたかったことはできたので、このタイミングでまとめてみます。
もっとシンプルにできるとか、そもそも使い方間違ってるなどありましたら、コメントいただけると助かります。
既存の課題
もともと、APIごとにHttpClientを使ったAPI呼び出しServiceを定義していました。
それをやめて、純粋なAPI呼び出し処理を共通化し、APIの追加修正を楽にしつつ、型推論もいい感じにできないかと考えたのがことの発端です。
↓もともとの課題例
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のリクエストボディとレスポンスボディを定義します。
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に推論させることができます。
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のパスを特定できるようにします。
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呼び出しを共通化できつつ、型推論を活用することができます。
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を呼び出していきます。
ここでようやく、型推論のありがたさを実感することができます。
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を定義していきます。
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[];
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を定義していきます。
// すべての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の必須パラメータを推論できるようにします。
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の置換を行うようにします。
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が推論されるので、誤った値が指定されるのを防ぐことが出来ます。
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の型定義~