Closed7

型安全なHTTPリクエストを考えてみる

よしよし

型安全な HTTP リクエストというと、aspida が割と有名な気がするが、自分で試せてなかったので試す。
https://github.com/aspida/aspida

また、この aspida の特徴の一つとして、レスポンスの型をつけられるというものがあるが、
型ガードのように想定したデータがちゃんと返ってきているか検査する仕組みが動いているのかどうかが疑問だった。
それも試してみて、もしそこまではしていないのであれば、別途型ガードができるようなライブラリとの組み合わせを試してみる。

候補としてはこちら
https://github.com/colinhacks/zod
https://github.com/ianstormtaylor/superstruct

最近よく聞くのは Zod の印象。

Zod メモ
https://zenn.dev/uttk/scraps/a4da447adc5dcb
https://zenn.dev/uttk/articles/bd264fa884e026

よしよし

aspida/ky について

個人的に最近使っている HTTP クライアントライブラリが ky であるため、ky のサポートが気になった。

調べている時に ky にも対応しているとの文言を見かけたが、現在リポジトリを見ても packages 配下に存在しない。
作者の方のツイートを見るに、どうも aspida のv1.1リリース時にサポート終了したらしい。

https://twitter.com/m_mitsuhide/status/1338257674043936769

一応、npm パッケージとしては残っているようである

https://www.npmjs.com/package/@aspida/ky

aspida/fetch で代替

ky の恩恵の一つとして、ステータスコード400、500番台が返ってきたときに自動で HTTPError をスローしてくれるというものがあった。
これに関しては、aspida/fetch のオプションとして、スローしてくれるようにするものがあるので、代替てしまってもいいかもしれない。

const fetchConfig: FetchConfig = {
  credentials: 'include',
  baseURL: '/api',
  throwHttpErrors: true, // throw an error on 4xx/5xx, default is false
};
よしよし

セットアップ

インストール

aspida のインストール

yarn add @aspida/fetch

直接 aspida と関係ないが、コマンド並列実行のためにインストール

yarn add -D npm-run-all

設定

src 配下にapiディレクトリを作成(お好みで)
aspida の設定ファイルで、このディレクトリを指定

aspida.config.js
module.exports = {
  input: 'src/api',
};

このディレクト配下に各 API の型情報を作成。
api/usersの GET と POST であれば、以下のような感じ。
(モデルの型は別途 models 配下に作成しておいた)
※追記...クエリパラメータやリクエストボディの型も models 定義の方がいいかも

src/api/users/index.ts
import { User, Users } from '@/models/User';

export type Methods = {
  get: {
    query?: {
      limit: number;
    };

    resBody: Users;
  };

  post: {
    reqBody: {
      name: string;
    };

    resBody: User;
    /**
     * reqHeaders(?): ...
     * reqFormat: ...
     * status: ...
     * resHeaders(?): ...
     * polymorph: [...]
     */
  };
};

実行

実行すると、定義した型情報を元にロジックを生成してくれる

# 単純な実行
yarn run -s aspida

# 監視モード
yarn run -s aspida --watch

ローカルサーバ起動時に、並列で監視モード起動するようにしておけばよさそう。

package.json
 "scripts": {
    "dev": "run-p dev:*",
    "dev:next": "next dev",
    "dev:aspida": "aspida --watch",
    "build": "aspida && next build",
    ...
  },
よしよし

クライアント

lib/aspida.tsを作成しておいて、ここでエクスポートしたクライアントを各ドメインで使う感じでよさそう。

lib/aspida/ts
import aspida, { FetchConfig } from '@aspida/fetch';
import api from '@/api/$api';

const fetchConfig: FetchConfig = {
  credentials: 'include',
  baseURL: '/api',
  throwHttpErrors: true, // throw an error on 4xx/5xx, default is false
};

const client = api(aspida(fetch, fetchConfig));

export { client };
よしよし

試しに定義した型以外の値も返すように API を変えてみたが、特に問題なく動作してしまったので、やはりレスポンスデータの型検査は行われてないっぽい。

よしよし

Zod

インストール

yarn add Zod

strict モードを有効化しておく

tsconfig.json
// tsconfig.json
{
  // ...
  "compilerOptions": {
    // ...
    "strict": true
  }
}

型チェック実行

スキーマを定義しておいて、そのスキーマでparse()を使うと、型チェック実行。
問題なければ通過したものを返す。問題あればZodErrorをスローする。

import { z } from "zod";

// creating a schema for strings
const mySchema = z.string();
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws ZodError

スキーマから型定義生成

inferを使うことで、このスキーマから型定義を生成することも出来るとのこと。

import { z } from "zod";

const User = z.object({
  username: z.string(),
});

User.parse({ username: string });

// extract the inferred type
type User = z.infer<typeof User>;
// { username: string }

すでにある型定義からスキーマを作る

逆にすでにある型定義を元にスキーマを作りたい時はどうするか?と思っていたら、こんな感じにすればいいらしい
型定義と違う型のスキーマにしようとすると、ちゃんとエラーが出る。

import * as z from "zod";

// zodに渡せる型に変換する型
type toZod<T extends Record<string, any>> = {
  [K in keyof T]-?: z.ZodType<T[K]>;
}

interface Hoge {
  hello: string;
  world: string;
}

// Hogeを型引数として渡す
const HogeSchema = z.object<toZod<IHoge>>({
  hello: z.string(),
  world: z.string()
})

こちらより
https://zenn.dev/uttk/articles/bd264fa884e026#型引数の渡し方

check() について

型ガード的なやつ。どうやら v3 リリース時に削除されたらしく使えなくなっていた。
parse でよしなにやってねってことかもしれない。

実際に使ってみる

こんな感じかなぁ。
型ガードの if ブロックなしで書ける分、いくらかスッキリ見えるかもね。

lib/zod.ts
import { z } from 'zod';

export type ToZod<T extends Record<string, any>> = {
  [K in keyof T]-?: z.ZodType<T[K]>;
};
models/User.ts
export type User = {
  id: number;
  name: string;
};

export const userSchema = z
  .object<ToZod<User>>({
    id: z.number(),
    name: z.string(),
  })
  .strict();

export type Users = User[];

export const usersSchema = z.array(userSchema);
domains/getUsers
import { GetListRequestQuery } from '@/models/User';
import { usersSchema } from '@/models/User';
import { client } from '@/lib/aspida';

const getUsers = async (query?: GetListRequestQuery) => {
  const res = await client.users.get({ query });
  return usersSchema.parse(res.body);
};

export default getUsers;
hooks/useUsers.ts
import { useState, useEffect, useCallback } from 'react';
import { ZodError } from 'zod';
import { HTTPError } from '@aspida/fetch';
import { Users } from '@/models/User';
import getUsers from '@/domains/getUsers';

const useUsers = () => {
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [errorMessage, setErrorMessage] = useState<string>();
  const [users, setUsers] = useState<Users>();

  useEffect(() => {
    setIsLoading(true);
    getUsers()
      .then((data) => {
        setUsers(data);
      })
      .catch((err) => {
        if (err instanceof HTTPError) {
          setErrorMessage('ユーザ情報の取得に失敗しました');
        } else if (err instanceof ZodError) {
          setErrorMessage('想定しないデータの取得が行われました');
        }
      })
      .finally(() => {
        setIsLoading(false);
      });
  }, []);

  return { isLoading, errorMessage, users };
};

export default useUsers;

このスクラップは2022/02/13にクローズされました