Open7

RFC: ブラウザとサーバーで共有できるREST API特化型TypeScript製バリデータの仕様を考えてライブラリを公開する

SolufaSolufa

このスクラップの目的は、読者の意見を集めながらaspida/frourioと共存できるバリデータライブラリの仕様を決めて実装しnpmに公開すること。
普通OSSのRFCはGitHubのIssueでやるんだろうけど、リポジトリどころか名前すら決まってないし日本語で気軽に書きたいのでZennを使ってみることにした。

RFCを公開した動機は、frourioがclass-validatorに依存しているのを自前のライブラリに変えたいと去年から考えていたものの要件も仕様もまとまらないまま時間だけが経過してしまっているからである。
春先は何かと忙しいので実装に着手出来るのは早くてゴールデンウイーク、最悪でもお盆休みというイメージ。
それまでにコメントを受け付けながら要件と仕様をきっちり固めておきたい。
着手してからnpmに初公開するまでの期間は1か月くらいだと思う。

SolufaSolufa

class-validatorから自前のライブラリに変えたい理由を書いておく。

現状、frourioのバリデーションはaspida由来のAPI型定義を魔改造して実現している。

server/validators/index.ts
import { MinLength, IsString } from 'class-validator'

export class UserCreation {
  @IsString()
  @MinLength(5)
  name: string

  @IsString()
  @MinLength(8)
  pass: string
}

上記のようにデコレータ付きのクラスを定義して、下記のAPI型定義にセット。

server/api/users/index.ts
import { UserCreation } from '$/validators'

export type Methods = {
  post: {
    reqBody: UserCreation
  }
}

これでfrourioは POST: /users のリクエストボディをバリデーションするコードを自動生成するという手順になっている。
この手法の問題点は大きく3つ。

  1. クラスとデコレータに依存している
  2. APIの型定義がランタイムコードに依存している
  3. 外部ライブラリに依存していて速度最適化が困難

このスクラップを読むレベルのエンジニアにデコレータ依存が問題である理由を今さら説明する必要はないと思ってる。
frourioは関数型プログラミングに影響を受けていてミドルウェアもコントローラーもDIも関数で記述できる最高にクールなフレームワークを目指しているのに、バリデーションだけクラスが必要で一貫性がなくてダサい。
(フレームワークとしての一貫性がないのがダサいというだけでクラス自体を批判する意図はない)

ランタイムコードに依存していると、サーバー固有のコードを巻き込んでaspida経由でフロントが死んだりOpenAPIからの変換で毎回コードが壊れてしまうという問題がある。
純粋な型定義駆動開発の体験を損ねるというのも地味に痛い。

デコレータの複雑さが原因でもあるんだけど、外部ライブラリのclass-validatorに依存していると速度最適化が困難になる。
「TypeScript製で世界最速」というfrourioの特徴を維持し続けるためには速さをアイデンティティにしているfastifyエコシステム以外の部分は自前でハンドリングしたい。

uttkuttk

frourioは関数型プログラミングに影響を受けていてミドルウェアもコントローラーもDIも関数で記述できる最高にクールなフレームワークを目指しているのに、バリデーションだけクラスが必要で一貫性がなくてダサい。
(フレームワークとしての一貫性がないのがダサいというだけでクラス自体を批判する意図はない)

確かに、私もfrourioを使っていて同じことを思っていました。
なので、今回のRFCには大いに賛成です。

zodような関数型でTypeScript Firstなライブラリは参考になるかもしれませんね。

https://github.com/colinhacks/zod

又は、型定義そのものがバリデーションとなっていると面白いかもしれません。
勝手な思い付きで恐縮ですが、一応サンプルコード以下に示しておきます。
参考になれば幸いです。

サンプルコード
./validator/user.ts
import type { IsString, IsNumber } from "frourio-validator"

export interface UserValidator {
  name: IsString;
  age: IsNumber;

  // optionはジェネリクスでやるといいかも
  address: IsString<{ maxLength: 10 }>;
  task_count: IsNumber<{ min: 0; max: 10; defaultValue: 0; }>;
}
./api/user/index.ts
import { UserValidator } from "$/validator/user"

export type Methods = {
  get: {
    reqBody: UserValidator  // 現在のclass-validatorと同じような感じで使えると嬉しい
    resBody: string
  }
}
anyFile.ts
// ./validator/user.tsを解析して自動生成されたファイル
import { UserValidator } from "$/validator/$user" 

/**
 * @note バックエンドでもフロントエンドでも使えるバリデーション
 */
const validateUser = ( formValue: any ) => UserValidator.parse( formValue );
SolufaSolufa

おお、こんなに早くコメントもらえて嬉しいです!
zod初めて見ました。スター多くてチームも活発に動いてそう。

サンプルコードまで書いていただきありがとうございます。
型だけでバリデーションを定義すると最適化処理を完全にfrourioのコード生成アルゴリズムでハンドリングできますね。

SolufaSolufa

OpenAPIからの変換

はfrourioを使う場合には関係ないことだけど、新しいバリデータの要件としてopenapi2aspidaと競合しないことを想定しているので後々重要になる

SolufaSolufa

注意点は、フロントとバックエンドで「バリデーションの目的がそもそも異なる」ので共通化できないケースが多いこと。

一見同じ値を同じルールで検証するのでフロントとバックエンドで同じコードを使えそうに思える。
例えばユーザーIDを検証する場合、「5文字から15文字の半角英数字」というバリデータは両方で同じコードを使えそうに見える。
ところが実際にはIDが重複していないかどうかをチェックする際に、フロントからはAPIリクエストで検証するのに対して、バックエンドではDBにアクセスして検証を行うため処理が全く異なる。

フロントは「ユーザー体験の向上」、バックエンドは「コントローラの責務軽減やアプリケーションの保護」がバリデーションの目的なのでどうやっても共通化出来ない部分が発生してしまう。

SolufaSolufa

aspida v2を作っていて思いついた
aspida/frourioともにunknownな外部入力だけバリデーションすればいいのではないか

aspidaはresponseのみ、frourioはrequestのみ
その逆のバリデーションはユーザー自身でバリデータを流用させる

クライアントでバリデーションする瞬間はGETパラメータ取得やフォーム入力時で、リクエストに使う値をバリデーションする必要性は薄い

サーバー視点で考えても、コントローラーからのレスポンス以降でバリデーションが欲しいケースは少ない