yarn workspaceとyupを使ったフロントエンド・バックエンドでのスキーマ共通化を考えてみた

こんちは、最近なにわ男子を見かけることが多くて名前を覚えてきました。大橋くんはいい子そうやと思ってます

今日はフロントエンド・バックエンドでのAPIのスキーマ共通化についてyarn workspaceとNode.jsのバリデーションライブラリyupを使った実現方法を考えたので記載します。

まとめると

フロントエンドとバックエンドの両方でTypeScriptを利用していれば yarn workspaceとyupを使いAPIのスキーマを(OpenAPIに比べて)楽に共通化することができる、なおかつ、バリデーションもフロントエンド・バックエンドで共通化することができるためとても便利という話です

背景

バックエンドが用意するRESTFulなAPIを管理し、フロントエンドから正確に呼び出すための仕組みはいくつかあります。

例えばOpenAPIというyaml/jsonのフォーマットで、APIの情報を記述することができます。このファイルを利用してopenapi-generatoraspidaを使いフロントエンドのコードを出力してそれを呼び出すことができます。

openapi: "3.0.0"
info:
  version: 1.0.0
  title: Swagger Petstore
  license:
    name: MIT
servers:
  - url: http://petstore.swagger.io/v1
paths:
  /pets:
    get:
      summary: List all pets
      operationId: listPets
  # ...

このOpenAPIはとても有名ですが、僕が上記の仕組みを使っているなかでいくつか不満をがありました

  1. yaml/jsonのフォーマットを暗記できるほどよく触るものでもないので毎回ググって思い出す
  2. 特定のschemaを使い回す際の表現が乏しい(allOf/anyOf/oneOfはあるけどTSでいうOmitがない)
  3. null/undefinedの取り扱いをどのように扱うか毎回忘れる
  4. 各プロパティ(Schema Object)の表現が少ない
  5. これらをopenapi-generatorなどで出力しても、OpenAPIの一部の内容がコードに反映されない場合がある

そこで、フロントエンドとバックエンドの両方でTypeScriptを利用していればもっと楽に共通化できないかと思い、yarn workspaceとyupを使ってこれら全ての問題を解決できるか試してみました、

どうやるの?

まず以下のようなモノレポをyarn workspaceで構築しました。

  • frontend:フロントエンドのパッケージ(例えばReact)。schemaパッケージをyarn add してる
  • backend:バックエンドのパッケージ(例えばExpress)。schemaパッケージをyarn add してる
  • schema:frontend/backendから共通的に利用されるパッケージ。ここに型とyupのスキーマを配置する

commonではyupを利用して、バックエンドが提供するエンドポイントの型およびyupのスキーマを定義します。

import * as yup from "yup";

const EmailAndPassword = {
    email: yup.string().required().email(),
    password: yup.string().required().max(20)
}

export type Endpoint = {
    path: string
    request: yup.AnySchema
    response: yup.AnySchema
}

//////////
// ログイン
//////////
export const ReqBodyLogin = yup.object({
    ...EmailAndPassword
});
export type TypeReqBodyLogin = yup.InferType<typeof ReqBodyLogin>
export const ResBodyLogin = yup.object({
    accessToken: yup.string()
})
export type TypeResBodyLogin = yup.InferType<typeof ResBodyLogin>
export const EndpointLogin: Endpoint = {
    path: "/login",
    request: ReqBodyLogin,
    response: ResBodyLogin
}


//////////
// 会員登録
//////////
export const ReqBodySignUp = yup.object({
    ...EmailAndPassword,
    dateOfBirth: yup.date().required().max(new Date())
});
export type TypeReqBodySignUp = yup.InferType<typeof ReqBodySignUp>
export const ResBodySignUp = yup.object({
    ...EmailAndPassword,
    dateOfBirth: yup.date().required().max(new Date())
});
export type TypeResBodySignUp = yup.InferType<typeof ResBodySignUp>

export const EndpointSignUp: Endpoint = {
    path: "/signup",
    request: ReqBodySignUp,
    response: ResBodySignUp
}

このリクエストおよびレスポンスの型・yupのバリデーションスキーマをフロントエンド・バックエンド両方ともが参照し、これに従った実装をすることで共通化できる、という仕組みです。

具体的にはバックエンドがExpressだと、以下のように利用することができます。

import * as Schema from "monorepo-with-yup-schema" // schemaパッケージ

// ...

app.post(Schema.User.EndpointLogin.path, async (req: Request<Schema.User.TypeReqBodyLogin>, res: Response<Schema.User.TypeReqBodySignUp>) => {
  try {
    await Schema.validate(Schema.User.ReqBodyLogin, req.body)
    // ...
  } catch (err) {
    return res.status(400).json(err as any);
  }
})

フロントエンドでは以下のように利用することができます、

import * as Schema from 'monorepo-with-yup-common'; // schemaパッケージ

// ...

const body = {
  email,
  password
}

await Schema.validate(Schema.User.ReqBodyLogin, body)
const responseData = await axiosInstance.post<Schema.User.TypeResBodyLogin>(Schema.User.EndpointLogin.path, body)
console.log(responseData.accessToken)

結果としてはOpenAPIを使って出てきた不満は大体解消されました。

OpenAPIの不満 どうなったか
yaml/jsonのフォーマットを暗記できるほどよく触るものでもないので毎回ググって思い出す 慣れているTypeScriptの書き方なので忘れない・yupはTS対応されているので補完が効くので時間を浪費しない
特定のschemaを使い回す際の表現が乏しい(allOf/anyOf/oneOf) TSのUtilityTypesを使い特定のスキーマを使い回すのが簡単にできる
null/undefinedの取り扱いをどのように扱うか毎回忘れる TypeScriptで扱うことができる
各プロパティ(Schema Object)の表現が少ない yupで柔軟に表現できる
これらをopenapi-generatorなどで出力しても、OpenAPIの一部の内容がコードに反映されない場合がある バックエンドとフロントエンドで確実に同じ型・バリデーションを扱うことができる

特に、最後の バックエンドとフロントエンドで確実に同じ型・バリデーションを扱うことができるというのが強力で、「あるプロパティのバリデーションが変わったときにフロントのバリデーションが変わってなくて不整合が起こる」みたいなことが100%起きないののでとても安心感があります。また、OpenAPIはAPIの仕様を共通化するためだけにしか利用できませんが、上記仕組みだとschemaパッケージに書かれたコードはバックエンドで利用するので二重で書く必要がなくなるという点もとても気に入ってます。

また、yupの機能である、yupのバリデーションスキーマから型を推論する機能(InferType)を使うことによって、yupのバリデーションスキーマのみを定義しなおかつ型は推論することでschemaパッケージがとてもスマートになりました(おそらくJoiを使った場合だとそのような機能はなく手動で二重で定義する必要がある)

ただし、代わりにフロントエンド・バックエンド両方で型の記述が都度必要になってしまいコードボリュームが増えてしまうというデメリットが出てきました(OpenAPIのopenapi-codegeneratorであれば、型定義済みのコードを出力してくれるので使う側のコードでここまで自分で型を使うことはないです)

ですが、それ以上に、型とバリデーションが完全に一致する仕組みになったので気持ちよく開発ができそうな構成になったのではないかなと思います。

再度まとめると

フロントエンドとバックエンドの両方でTypeScriptを利用していれば yarn workspaceとyupを使いAPIのスキーマを(OpenAPIに比べて)楽に共通化することができる、なおかつ、バリデーションもフロントエンド・バックエンドで共通化することができるためとても便利でした

検証に使ったソースコードはこちらです

tech  Node.js  yup 

See also