🔍

Parameter validationの特徴比較

2024/12/07に公開

はじめに

HTTP リクエストには,どのようなパラメタが含まれているでしょうか?
以下のリクエストの例を見てみましょう.

POST /v1/users/123/profile?lang=ja HTTP/1.1
Host: example.com
Content-Type: application/json
X-Auth-Token: 456abcd
Cookie: session_id=789efgh

{
  "name": "たろう",
  "age": 30,
  "email": "taro@example.com"
}

このリクエストには,以下のパラメタが含まれています:

  • パスパラメタ:123
  • クエリパラメタ:lang=ja
  • ヘッダー:X-Auth-Token: 456abcd
  • Cookie:session_id=sid-789efgh
  • リクエストボディ[1]{"name": "たろう", "age": 30, "email": "taro@example.com"}

ご覧の通り,HTTP リクエストはテキストベースであり,パラメタもすべて文字列として表現されます.
そのため,サーバーサイドでパラメタを利用する際には,パラメタ検証(parameter validation によって期待通りの形式であるかどうかを確認することが重要となります.

本記事では,パラメタ検証の特徴を比較するために,いくつかのプログラミング言語やフレームワークにおけるパラメタ検証機能を取り上げます.
上記の例について,それぞれどのようにパラメタを検証するのか見ていきましょう.

パラメタ検証ライブラリの比較

FastAPI/Pydantic

FastAPI は Python 製の Web フレームワークであり,Pydantic は FastAPI で使われるデータ検証ライブラリです.
コントローラを実装するとき,引数に型ヒントを記述することでパラメタを検証できます.

from typing import Literal
from fastapi import Body, Cookie, FastAPI, Header, Path, Query, Request
from pydantic import BaseModel, EmailStr, Field

class User(BaseModel):
    # User name should be a string of 1 to 100 characters.
    name: str = Field(min_length=1, max_length=100)
    # User age should be a non-negative integer.
    age: int = Field(ge=0)
    # User email should be a valid email address.
    # This works after installing `pydantic[email]` extra package.
    email: EmailStr


app = FastAPI(root_path="/v1")


@app.post("/users/{user_id}/profile")
async def update_user_profile(
    # Path parameter `user_id` should be an positive integer.
    user_id: int = Path(gt=0),
    # Query parameter `lang` should be "ja" or "en" if given.
    lang: Literal["ja", "en"] | None = Query(None, regex=r"^(ja|en)$"),
    # Header `X-Auth-Token` should be a string of 7 characters.
    x_auth_token: str = Header(min_length=7, max_length=7),
    # Cookie `session_id` should be match the pattern `sid-[a-z0-9]{7}`.
    session_id: str = Cookie(regex=r"^sid-[a-z0-9]{7}$"),
    # Request body should be a JSON object of `User` model.
    user: User,
) -> dict:
    # Implementation of the controller here...

検証に失敗した場合は,FastAPI が自動的に 422 Unprocessable Entity エラーを返します.
エラーハンドラを実装することでこの挙動を変更できます.
https://fastapi.tiangolo.com/tutorial/handling-errors/#override-request-validation-exceptions

from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
    return JSONResponse(status_code=400, content={"error": str(exc)})

Express/express-validator

Express は Node.js 製の Web フレームワークであり,express-validator は Express で使われるデータ検証ライブラリです.
コントローラを実装するとき,ミドルウェアとして express-validator を用いることでパラメタを検証できます.

const express = require('express')
const { body, cookie, header, param, query, validationResult } = require('express-validator')
const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser')

const app = express()
app.use(bodyParser.json())
app.use(cookieParser())
app.post(
  '/v1/users/:user_id/profile',
  param('user_id').isInt({ min: 1 }),
  query('lang').optional().isIn(['ja', 'en']),
  body('name').isString().isLength({ min: 1, max: 100 }),
  body('age').isInt({ min: 0 }),
  body('email').isEmail(),
  header('x-auth-token').isLength({ min: 7, max: 7 }),
  cookie('session_id').matches(/^sid-[a-z0-9]{7}$/),
  (req, res) => {
    const errors = validationResult(req)
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() })
    }
    // Implementation of the controller here...
  }
)

検証に失敗した場合の挙動は,開発者が明示的に実装する必要があります.
上記のように validationResult 関数でエラーを取得し,それに応じたレスポンスを返します.

また,上記の例ではパスパラメタ user_id に対して正の整数であることを検証していますが,値を取得する際には再度文字列を数値に変換する必要があることに注意が必要です.

import { matchedData } from 'express-validator'

app.post(..., (req, res) => {
  const user_id = matchedData(req).user_id
  // `user_id` is a string, not a number.
})

Hono/Zod

Hono も同じく Node.js 製の Web フレームワークです.
Zod は TypeScript の汎用的なスキーマ検証ライブラリであり,Hono では推奨ライブラリとして公式ドキュメントで紹介されています.
コントローラを実装するとき,ミドルウェアとして zValidator を用いることで Zod を用いたパラメタ検証を容易に実装できます.

import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'

const app = new Hono()

enum SupportedLang {
  ja = 'ja',
  en = 'en',
}

const User = z.object({
  name: z.string().min(1).max(100),
  age: z.number().int().min(0),
  email: z.string().email(),
})

app.post(
  '/v1/users/:user_id/profile',
  zValidator('param', z.object({ user_id: z.coerce.number().int().positive() })),
  zValidator('query', z.object({ lang: z.nativeEnum(SupportedLang).optional() })),
  zValidator('json', User),
  zValidator('header', z.object({ 'x-auth-token': z.string().length(7) })),
  zValidator('cookie', z.object({ session_id: z.string().regex(/^sid-[a-z0-9]{7}$/) })),
  async (c) => {
    const { user_id } = c.req.valid('param')
    // Implementation of the controller here...
  }
)

検証に失敗した場合は,Hono が自動的に 400 Bad Request エラーを返します.
zValidator() 関数の第 3 引数にフックとしてエラー処理を行うこともできます.

zValidator('json', User, (result, c) => {
  if (!result.success) {
    return c.json({ error: result.errors }, 400)
  }
})

Phoenix/Params

Phoenix は Elixir 製の Web フレームワークです.
ParamsEcto.Schema を利用したパラメタ検証ライブラリであり,Phoenix におけるパラメタ検証としても利用できます.
defparams マクロと Ecto.Schema によってパラメタのスキーマを定義し,Ecto.Changeset を取得することでパラメタを検証できます.
パスパラメタ,クエリパラメタ,リクエストボディは区別されず,すべて同列に扱われます.
また,この方法ではヘッダーやクッキーの検証は行えません.

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  use Params

  defparams change_profile_params %{
    user_id!: :integer,
    lang: :string,
    name!: :string,
    age!: :integer,
    email!: :string
  }

  def change_profile(conn, params) do
    changeset = change_profile_params(params)
    if changeset.valid? do
      validated_params = Params.data(changeset)
      # Implementation of the controller here...
    else
      conn
      |> put_status(400)
      |> json(%{error: inspect(changeset.errors)})
    end
  end
end

Params は Phoenix とは独立の汎用的なライブラリであるため,エラーハンドリングなどの連携は弱く,基本的には開発者が明示的に実装する必要があります.

Antikythera/ParamsValidator

Antikythera は,当社で開発を進めている Web フレームワークです.
複数の Web アプリケーション(gear)を 1 つの Erlang ノードクラスタ内で管理し動作させることができる点が特徴です.
この度,Antikythera.Plug.ParamsValidator というパラメタ検証機能が追加されました.
このプラグを導入することで,コントローラの実装を大きく変えることなくパラメタ検証の機能を追加できます.
パラメタの形式は,リクエストボディであれば Antikythera.BodyJsonStruct,それ以外であれば Antikythera.ParamStringStruct を用いて定義します.
各フィールドは,Croma ライブラリを用いて形式を指定できます.

use Croma
defmodule MyGear.Controller.User do
  use Antikythera.Controller

  defmodule PathMatches do
    use Antikythera.ParamStringStruct, fields: [user_id: Croma.PosInteger]
  end

  defmodule QueryParams do
    defmodule SupportedLang do
      use Croma.SubtypeOfAtom, values: [:ja, :en]
      def from_string(x) when x in ~w(ja en), do: {:ok, String.to_atom(x)}
      def from_string(_), do: {:error, :invalid}
    end
    use Antikythera.ParamStringStruct, fields: [lang: {Croma.TypeGen.nilable(SupportedLang), &SupportedLang.from_string/1}]
  end

  defmodule BodyJson do
    defmodule Name, do: use Croma.SubtypeOfString, pattern: ~r/\A.{1,100}\z/
    defmodule Age, do: use Croma.SubtypeOfInt, min: 0
    defmodule Email, do: use Croma.SubtypeOfString, pattern: ~r<\A[A-Za-z0-9.!#$%&’*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z>
    use Antikythera.BodyJsonStruct,
      fields: [
        name: Name,
        age: {Age, &String.to_integer/1},
        email: Email
      ]
  end

  defmodule Headers do
    defmodule XAuthToken, do: use Croma.SubtypeOfString, pattern: ~r/\A[A-Za-z0-9]{7}\z/
    use Antikythera.HeaderStruct, fields: ["x-auth-token": XAuthToken]
  end

  defmodule Cookies do
    defmodule SessionId, do: use Croma.SubtypeOfString, pattern: ~r/\Asid-[a-z0-9]{7}\z/
    use Antikythera.CookieStruct, fields: ["session_id": SessionId]
  end

  plug Antikythera.Plug.ParamsValidator, :validate, [
    path_matches: PathMatches,
    query_params: QueryParams,
    body: BodyJson,
    headers: Headers,
    cookies: Cookies
  ]

  defun update_user_profile(%Conn{assigns: %{validated: validated}}) :: v[Conn.t] do
    %PathMatches{user_id: user_id} = validated.path_matches
    # Implementation of the controller here...
  end
end

検証に失敗した場合は,Antikythera が自動的に 400 Bad Request エラーを返します.
Gear アプリケーションごとにエラーハンドリングをカスタマイズできます.

defmodule MyGear.Controller.Error do
  use Antikythera.Controller

  def parameter_validation_error(conn, parameter_type, {reason_type, _mods}) do
    Conn.json(conn, 400, %{
      error: "Parameter validation failed",
      parameter_type: parameter_type,
      reason: reason_type
    })
  end
end

まとめ

ここまでパラメタ検証機能をいくつかのプログラミング言語・フレームワークで比較しました.
個人的には,FastAPI/Pydantic は型ヒントで手軽にパラメタ検証ができる点,Hono/Zod は汎用的なライブラリである Zod をミドルウェアによって自然に組み込める点が利用しやすく,非常に参考になると感じました.
パラメタ検証機能の選定の基準として,また新たな Web フレームワークにおけるパラメタ検証機能の開発においても,この比較が参考になれば幸いです.

参考

脚注
  1. この例では JSON ですが,フォームデータやマルチパートデータなど他の形式の場合もあります. ↩︎

Discussion