この記事は WHITEPLUS Advent Calendar 2020 - Qiita 21日目になります。
こんにちは。株式会社ホワイトプラス、エンジニアの杉山です。
ホワイトプラスではリネットの フロントエンドからサーバーサイドまで広く担当しています。
リネットではReact + Redux + TypeScriptを用いてフロントエンドの開発を行っております。 私自身プログラミング言語としてはTypeScriptを最も好んでおり、TypeScriptのもたらす補完機能がもっぱらの好物です。
さて、サーバーサイドとフロントエンドの通信にはRESTful APIが使用されることが多いかと思います。 フロントエンドにて特定のデータを新たに表示する際、エンジニアはサーバーにはなんのAPIが定義されているのか、APIからはどんなデータが帰ってくるのかを都度確認し実装する必要があります。 そんな手間をTypeScriptの機能を使い、型安全かつガツガツ補完がされコーディングを楽にできるRestAPI Clientの実装をしていこうと思います。
- 完成ソースコード: https://github.com/NozomiSugiyama/wp-advent-calendar-2020
- 使用するRest API Server: {JSON} Placeholderjsonplaceholder.typicode.com
Chapter1: エンドポイント毎の型の違いを考慮せずRestAPI Clientを実装
1. API Request/Response Bodyの型定義
今回利用する{JSON} PlaceholderのTodo型を定義します。
// type.ts export type Todo = { userId: number, id: number, title: string, completed: boolean } export type TodoUpdate = Pick<Todo, "id"> & Partial<Todo>; export type TodoCreate = Omit<Todo, "id">;
2. API Clientを作成
- ブラウザでのコード記述が普段メインのため、
node-fetch
にてFetch APIを使用しています。 - Endpoint(Origin), HTTP Method, URL Path, Request Bodyを引数にとるClientを作成します。
- 成功か失敗かはDiscriminated Unionを採用(succeed, fail)しています
// rpc.ts import fetch from "node-fetch"; type Method = "POST" | "GET" | "PUT" | "DELETE" type Path = string; const succeed = <A>(a: A) => ({ type: "succeeded" as const, data: a, }); const fail = <A>(a: A) => ({ type: "failed" as const, data: a, }); export default (endpoint: string) => ({ call: async <Response>(method: Method, path: Path, body?: unknown) => { const data = await fetch(endpoint + path, { method: method, headers: { "Content-Type": "application/json", }, ...(body ? { body: JSON.stringify(body) } : {}) }); if (data.status === 404) { return fail({ type: "not-found", data: "Not Found", }); } try { return succeed(await data.json() as Response); } catch (e) { return fail({ type: "parse-error" as const, data: e, }); } } });
3. 呼び出し部分実装
// main.ts import { Todo, TodoUpdate, TodoCreate } from "./type"; import rpc from "./rpc"; const endpoint = "https://jsonplaceholder.typicode.com" const main = async () => { const apiClient = rpc(endpoint); const result = await apiClient.call<Todo>("POST", "/todos", { userId: 1, title: "test", completed: false }); if (result.type === "succeeded") { result.data.id; } } main();
この段階では、HTTP Methodにて定義された型以外補完が効きません。 呼び出し元ではなんのエンドポイントが存在し、エンドポイントに対してなんの引数が必要であり、なんの返り値が帰っているかがわかりません。
Chapter2: エンドポイント毎に型を定義
エンドポイント毎の型定義用Schema Typeを用意し、Method: Path毎にエンドポイントを定義します。 このSchema型を元にそれぞれのエンドポイントの型を定義し、補完を付けられるようにしていきます。
type Schema = { resource: { [path: string]: { [method: string]: { body?: {}; response: unknown; }; }; }; };
1. API Clientの変更
- 型引数(Schema型を継承した型)を受け取り、Method, Path, Body, Responseの型を効かせるようにします。
- 型引数からMethod, Path, Bodyの型を引き抜くために GrandChildren, Owns, Get型関数を使用しています。
- Bodyが存在しない場合(HTTP GET Methodの際など)に、引数毎省略できるようExcludeUndefined型関数を使用しています。
// rpc.ts import fetch from "node-fetch"; type GrandChildren<A extends {}> = { [I in keyof A]: keyof A[I] }[keyof A]; type Owns<A extends {}, S extends string | symbol | number> = { [I in keyof A]: S extends keyof A[I] ? I : never; }[keyof A]; type Get<A, K> = K extends keyof A ? A[K] : undefined; type ExcludeUndefined<X> = X extends [undefined] ? [] : X extends [infer A] ? [A] : [] const succeed = <A>(a: A) => ({ type: "succeeded" as const, data: a, }); const fail = <A>(a: A) => ({ type: "failed" as const, data: a, }); export default <A extends Schema>(endpoint: string) => ({ call: async < Method extends GrandChildren<A["resource"]>, Path extends Owns<A["resource"], Method>, Body extends Get<A["resource"][Path][Method], "body"> >( method: Method, path: Path, ...body: ExcludeUndefined<[Body]> ) => { const data = await fetch(endpoint + path, { method: method as string, headers: { "Content-Type": "application/json", }, ...(body ? { body: JSON.stringify(body) } : {}) }); if (data.status === 404) { return fail({ type: "not-found", data: "Not Found", }); } try { return succeed(await data.json() as A["resource"][Path][Method]["response"]); } catch (e) { return fail({ type: "parse-error" as const, data: e, }); } } }); export type Schema = { resource: { [path: string]: { [method: string]: { body?: {}; response: unknown; }; }; }; };
2. 呼び出し部分変更
Schma型に則りエンドポイント毎の型を定義していきます。
// main.ts import { Todo, TodoUpdate, TodoCreate } from "./type"; import rpc from "./rpc"; const endpoint = "https://jsonplaceholder.typicode.com" type Schema = { resource: { "/todos": { GET: { response: Todo[] }, POST: { body: TodoCreate, response: Todo }, }, "/todos/1": { GET: { response: Todo }, PUT: { body: TodoUpdate, response: Todo }, DELETE: { body: { id: Todo["id"] }, response: Todo }, }, }, }; const main = async () => { const apiClient = rpc<Schema>(endpoint); const result = await apiClient.call("POST", "/todos", { userId: 1, title: "test", completed: false }); if (result.type === "succeeded") { result.data.id; } } main();
Schama型を参照し、引数によって適切な補完及び返り値の型が定義されたRest API Clientができました。
- 第一引数(HTTP Method)を指定すると、そのHTTP Methodを実装しているエンドポイントのみが第二引数(URL Path)の補完に表示されます。
- 第二引数が指定されると、そのエンドポイントに対して必要な第三引数(Request Body)が指定されます。
- Request Bodyが必要な場合は第三引数に引数を求められます。
Chapter3: URL Pathに変数を含める
Rest APIではリソースに対してのIDを指定する場合が存在(/todos/:id
など)し、こちらに対応していきます。
- Schema.resource.pathで
/todos/:id
のように指定可能にし、必要な変数をSchema.resource.path.method.params
に定義します
export type Schema = { resource: { [path: string]: { [method: string]: { params?: {}; // 追加 body?: {}; response: unknown; }; }; }; };
1. API Clientの変更
- Paramsの指定が会った場合に、第三引数にParamsを受け取るようにします。
- Paramsがない場合、第三引数ではRequest Bodyを受け取ります。
// rpc.ts import fetch from "node-fetch"; type GrandChildren<A extends {}> = { [I in keyof A]: keyof A[I] }[keyof A]; type Owns<A extends {}, S extends string | symbol | number> = { [I in keyof A]: S extends keyof A[I] ? I : never; }[keyof A]; type Get<A, K> = K extends keyof A ? A[K] : undefined; type ExcludeUndefined<X> = X extends [undefined, undefined] ? [] : X extends [infer A, undefined] ? [A] : X extends [undefined, infer B] ? [B] : X extends [infer A, infer B] ? [A, B] : never; const succeed = <A>(a: A) => ({ type: "succeeded" as const, data: a, }); const fail = <A>(a: A) => ({ type: "failed" as const, data: a, }); export default <A extends Schema>(schema: A) => ({ call: async < Method extends GrandChildren<A["resource"]>, Path extends Owns<A["resource"], Method>, Params extends Get<A["resource"][Path][Method], "params">, Body extends Get<A["resource"][Path][Method], "body"> >( method: Method, path: Path, ...rest: ExcludeUndefined<[Params, Body]> ) => { try { let appliedPath = path.toString(); const paramExists = /:[a-zA-Z0-9]+/.test(appliedPath); if (paramExists) { for (const name in rest[0]) { appliedPath = appliedPath.replace(new RegExp(`:${name}`), (rest[0] as any)[name]); } } const data = await fetch(schema.endpoint + appliedPath, { method: method as string, headers: { "Content-Type": "application/json", }, ...(rest.length != 1 || paramExists ? {} : { body: JSON.stringify(rest[0]), }), ...(rest.length != 2 ? {} : { body: JSON.stringify(rest[1]), }), }); if (data.status === 404) { return fail({ type: "not-found", data: "Not Found", }); } try { return succeed(await data.json() as A["resource"][Path][Method]["response"]); } catch (e) { return fail({ type: "parse-error" as const, data: e, }); } } catch (e) { return fail({ type: "network-error" as const, data: e, }); } }, }); export type Schema = { resource: { [path: string]: { [method: string]: { params?: {}; body?: {}; response: unknown; }; }; }; };
2. 呼び出し部分変更
/todos/:id
エンドポイントを作成し、Params型を定義します。
// main.ts import { Todo, TodoUpdate, TodoCreate } from "./type"; import rpc from "./rpc"; const endpoint = "https://jsonplaceholder.typicode.com" type Schema = { resource: { "/todos": { GET: { response: Todo[] }, POST: { body: TodoCreate, response: Todo }, }, "/todos/:id": { GET: { params: { id: number; }, response: Todo }, PUT: { body: TodoUpdate, response: Todo }, DELETE: { body: { id: Todo["id"] }, response: Todo }, }, }, }; const main = async () => { const apiClient = rpc<Schema>(endpoint); const result = await apiClient.call( "GET", "/todos/:id", { id: 1 } ) if (result.type === "succeeded") { console.log(result.data) } else if (result.type === "failed") { console.log(result.data) } } main();
まとめ
- TypeScriptの最新の機能は今回使用していませんが、既存の機能を組み合わせることによって汎用的で強力なRestAPI Clientを実装することができたかと思います。
- 記事に対するレビューありましたらガンガン受け付けております。
最後に
明日は弊社生産開発部 𠮷川さんの「筋トレで仕事の生産性を上げる話」です!