WHITEPLUS TechBlog

株式会社ホワイトプラスのエンジニアによる開発ブログです。

強力な型補完を行うRestAPI ClientをTypeScriptで実装した

この記事は WHITEPLUS Advent Calendar 2020 - Qiita 21日目になります。

こんにちは。株式会社ホワイトプラス、エンジニアの杉山です。

ホワイトプラスではリネットの フロントエンドからサーバーサイドまで広く担当しています。

リネットではReact + Redux + TypeScriptを用いてフロントエンドの開発を行っております。 私自身プログラミング言語としてはTypeScriptを最も好んでおり、TypeScriptのもたらす補完機能がもっぱらの好物です。

さて、サーバーサイドとフロントエンドの通信にはRESTful APIが使用されることが多いかと思います。 フロントエンドにて特定のデータを新たに表示する際、エンジニアはサーバーにはなんのAPIが定義されているのか、APIからはどんなデータが帰ってくるのかを都度確認し実装する必要があります。 そんな手間をTypeScriptの機能を使い、型安全かつガツガツ補完がされコーディングを楽にできるRestAPI Clientの実装をしていこうと思います。 f:id:wp-rioc:20201216083720g:plain

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();

f:id:wp-rioc:20201220081845g:plain この段階では、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();

f:id:wp-rioc:20201220092036g:plain

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();

f:id:wp-rioc:20201221103511g:plain

まとめ

  • TypeScriptの最新の機能は今回使用していませんが、既存の機能を組み合わせることによって汎用的で強力なRestAPI Clientを実装することができたかと思います。
  • 記事に対するレビューありましたらガンガン受け付けております。

最後に

明日は弊社生産開発部 𠮷川さんの「筋トレで仕事の生産性を上げる話」です!

ホワイトプラスでは、ビジョンバリューに共感していただけるエンジニアを募集しています。

open.talentio.com open.talentio.com