OpenAPIからフォームのバリデーションまでの型とコードを生成する

 
0
このエントリーをはてなブックマークに追加
Hakucho
Hakucho (白鳥)

この記事はTypeScriptアドベントカレンダー21日目の記事です。

はじめに

弊社ではいろいろ生成して開発の効率化を図っています。
今回はその中でも、OpenAPIからフォームまで一気通貫で型を合わせる方法を紹介します。

この記事の内容を反映したサンプルプロジェクトを以下に置いておきます。

https://github.com/tentaShiratori/between-api-and-form/tree/main

前提知識

  • React
  • OpenAPI

やること一覧

やることは以下です。

  • OpenAPIからRequest・Responseの型と、aspidaの型とコードを生成
  • Request・Responseの型から、zodのコードを生成
  • zodのコードをフォーム用に変換

生成の起点となるOpenAPI

生成の起点はOpenAPIなのでまずOpenAPIを作ります。

実際はバックエンドの人が手で書くか、生成をします。

バックエンドでのOpenAPIの生成方法はフレームワーク次第なので、お使いのフレームワークを調査して下さい。

今回使用するOpenAPIは次です。

{
  "openapi": "3.0.0",
  "info": {
    "version": "1.0.0",
    "title": "Sample"
  },
  "servers": [
    {
      "url": "http://localhost:8000",
      "description": "development"
    }
  ],
  "components": {
    "schemas": {
      "sample": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "email": {
            "type": "string"
          },
          "age": {
            "type": "number"
          },
          "is_adult": {
            "type": "boolean"
          },
          "gender": {
            "type": "number",
            "enum": [1, 2, 3]
          },
          "hobby": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": ["game", "books", "sports"]
            }
          }
        },
        "required": ["name", "email", "age", "is_adult", "gender", "hobby"]
      }
    },
    "parameters": {}
  },
  "paths": {
    "/sample": {
      "get": {
        "parameters": [
          {
            "name": "name",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/sample"
                }
              }
            }
          }
        }
      },
      "post": {
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/sample"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    }
  }
}

ライブラリのインストール

今回使用するライブラリをインストールします。

aspida・zodは必須です。

react-hook-formはzodをバリデーションとして使えるライブラリなら互換可能だと思います。

pnpm install @aspida/fetch aspida zod react-hook-form @hookform/resolvers

細かい使い方についてはリポジトリを参照して下さい。

OpenAPIからRequest・Responseの型と、aspidaの型と実装を生成

以下のツールを使います。

https://github.com/aspida/openapi2aspida

以下の設定ファイルを作成し、コマンドを実行します。

aspida.config.js
module.exports = {
    input: "lib/api",
    openapi: { inputFile: "./openapi.json" }
}
npx openapi2aspida

Request・Responseの型から、zodのコードを生成

以下のツールを使います。

https://github.com/fabien0102/ts-to-zod

以下の設定ファイルを作成し、コマンドを実行します。

ts-to-zod.config.js
module.exports = {
    input: "./lib/api/@types/index.ts",
    output: "./lib/validator.ts",
}
npx ts-to-zod

zodのコードをフォーム用に変換

ここはこれまでのように生成するのではなくランタイムで変換を行います。

forFormがこの章の肝となる関数です。

  const [stringify, validator] = forForm(sampleSchema)

  useEffect(() => {
    apiClient.sample.get().then((res) => {
      const stringifiedBody = stringify.parse(res.body)
      for (const key in stringifiedBody) {
        setValue(
          key as keyof typeof stringifiedBody,
          stringifiedBody[key as keyof typeof stringifiedBody],
        );
      }
    });
  }, []);
  const {
    register,
    handleSubmit,
    watch,
  } = useForm<zod.infer<typeof stringify>, any, zod.infer<typeof validator>>({
    resolver: zodResolver(validator),
  });

Request・Responseの型から、zodのコードを生成で生成されるsampleSchemaというzodのコードを変換してます。

なぜこのような変換をするのかというと、入力系の要素(inputやselectなど)は文字列しか扱えないからです。

また、以下の点に注意してください。

  • useFormの型パラメータに変換後のコードから生成する型zod.infer<typeof stringify>, any, zod.infer<typeof validator>を渡す
  • APIからのデータをフォームに入れるときはstringify.parseで変換して入れる

forFormの実装は以下です。

基本的にas neverを使って型エラーを握りつぶしてる点に注意して下さい。

import zod, {
  ZodArray,
  ZodFirstPartyTypeKind,
  ZodLiteral,
  ZodObject,
  ZodRawShape,
  ZodString,
  ZodType,
  ZodTypeDef,
  ZodUnion,
} from "zod";

type ZodUnknownDef = { typeName: ZodFirstPartyTypeKind } & ZodTypeDef;
type ZodUnknown = ZodType<any, ZodUnknownDef>;

function isObject(
  schema: ZodUnknown,
): schema is ZodObject<ZodRawShape> {
  return schema._def.typeName === ZodFirstPartyTypeKind.ZodObject;
}

function isArray(
  schema: ZodUnknown,
): schema is ZodArray<ZodUnknown> {
  return schema._def.typeName === ZodFirstPartyTypeKind.ZodArray;
}

function isUnion(
  schema: ZodUnknown,
): schema is ZodUnion<[ZodUnknown, ZodUnknown]> {
  return schema._def.typeName === ZodFirstPartyTypeKind.ZodUnion;
}

function isLiteral(
  schema: ZodUnknown,
): schema is ZodLiteral<unknown> {
  return schema._def.typeName === ZodFirstPartyTypeKind.ZodLiteral;
}

type Stringify<T extends ZodUnknown> = T extends ZodObject<infer U> ? ZodObject<{[K in keyof U]: Stringify<U[K]>}>: T extends ZodArray<infer U> ? ZodArray<Stringify<U>> : ZodString;

function coerce<T extends ZodUnknown>(schema: T): T {
  if (schema._def.typeName === ZodFirstPartyTypeKind.ZodString) {
    return zod.coerce.string().pipe(schema) as never;
  }
  if (schema._def.typeName === ZodFirstPartyTypeKind.ZodNumber) {
    return zod.coerce.number().pipe(schema) as never;
  }
  if (schema._def.typeName === ZodFirstPartyTypeKind.ZodBoolean) {
    return zod
      .boolean()
      .or(zod.literal("true"))
      .or(zod.literal("false"))
      .transform((value) => (value == "false" ? false : !!value)) as never;
  }
  if (isLiteral(schema)) {
    if (typeof schema.value === "number") {
      return coerce(zod.number()).pipe(schema) as never;
    }
    if (typeof schema.value === "string") {
      return coerce(zod.string()).pipe(schema) as never;
    }
    if (typeof schema.value === "boolean") {
      return coerce(zod.boolean()).pipe(schema) as never;
    }
  }
  if (isObject(schema)) {
    return zod.object(
      Object.fromEntries(
        Object.entries(schema.shape).map(
          ([key, value]): [
            string,
            ZodType<unknown, { typeName: ZodFirstPartyTypeKind } & ZodTypeDef>,
          ] => {
            return [key, coerce(value)];
          },
        ),
      ),
    ) as never;
  }
  if (isArray(schema)) {
    return zod.array(coerce(schema.element)) as never;
  }
  if (isUnion(schema)) {
    return zod.union(schema.options.map((value) => coerce(value)) as never) as never;
  }
  throw new Error("not implemented");
}

function stringify<T extends ZodUnknown>(schema: T): Stringify<T> {
  if (isObject(schema)) {
    return zod.object(
      Object.fromEntries(
        Object.entries(schema.shape).map(
          ([key, value]): [
            string,
            ZodType<unknown, { typeName: ZodFirstPartyTypeKind } & ZodTypeDef>,
          ] => {
            return [key, stringify(value)];
          },
        ),
      ),
    ) as never
  }

  if (isArray(schema)) {
    return zod.array(stringify(schema.element)) as never
  }

  return zod.coerce.string() as never;
}

export function forForm<T extends ZodUnknown>(schema: T): [stringify:Stringify<T>,validator:T] {
  return [stringify(schema),coerce(schema)]
}

最後に

このようにして、一気通貫で型だったりコードだったりをコネコネして開発効率化してます。

TSの型コネコネは楽しいですね。

info-outline

お知らせ

K.DEVは株式会社KDOTにより運営されています。記事の内容や会社でのITに関わる一般的なご相談に専門の社員がお答えしております。ぜひお気軽にご連絡ください。