コンテンツにスキップ

422 Unprocessable Entityエラーの原因と解決方法|バリデーションエラーの正しい返し方

カテゴリ:HTTP・API設計

API を開発していると、クライアントから送信されたデータが構文的には正しいものの、ビジネスロジック上の要件を満たさないケースに頻繁に遭遇します。このとき返すべきHTTPステータスコードが 422 Unprocessable Entity です。本記事では 422 と 400 の違い、主要フレームワークでの実装パターン、ファイルアップロードにおける422の活用法、そしてRFC 7807に準拠したエラーレスポンスの設計まで解説します。

422と400の違い: 構文エラー vs 意味エラー

HTTP 400(Bad Request)と 422(Unprocessable Entity)は混同されやすいステータスコードですが、明確な使い分けの基準があります。

ステータス 名称 意味 具体例
400 Bad Request リクエストの構文が不正でサーバーが解析できない 不正なJSON、必須ヘッダーの欠落、Content-Type不一致
422 Unprocessable Entity 構文は正しいが、含まれるデータの意味が不正 メールアドレスの形式不正、範囲外の数値、不正なファイル形式

簡潔に言えば、400はパースエラー(JSONが壊れている等)、422はバリデーションエラー(JSONは正しいが中身が要件を満たさない)です。422は元々WebDAV拡張(RFC 4918)で定義されましたが、現在では広くREST APIで採用されています。

// 400 Bad Request の例: JSONの構文が壊れている
// リクエストボディ: {"name": "太郎", "email": }  ← JSONパースエラー

// 422 Unprocessable Entity の例: JSONは正しいがバリデーション失敗
// リクエストボディ: {"name": "", "email": "not-an-email"}
// レスポンス:
{
    "message": "The given data was invalid.",
    "errors": {
        "name": ["名前は必須です。"],
        "email": ["有効なメールアドレスを入力してください。"]
    }
}

Laravel での 422 の使い方

Laravel はバリデーション失敗時に自動的に 422 レスポンスを返します。これは FormRequest$request->validate() を使った場合のデフォルト動作です。

// Laravel: バリデーション失敗時に自動で422を返す
class StoreUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name'  => ['required', 'string', 'max:255'],
            'email' => ['required', 'email', 'unique:users,email'],
            'age'   => ['required', 'integer', 'min:0', 'max:150'],
            'avatar' => ['nullable', 'image', 'mimes:jpg,png,webp', 'max:5120'],
        ];
    }

    public function messages(): array
    {
        return [
            'name.required'  => '名前は必須です。',
            'email.required' => 'メールアドレスは必須です。',
            'email.email'    => '有効なメールアドレスを入力してください。',
            'email.unique'   => 'このメールアドレスは既に登録されています。',
            'avatar.max'     => 'アバター画像は5MB以下にしてください。',
        ];
    }
}

// コントローラー
class UserController extends Controller
{
    public function store(StoreUserRequest $request)
    {
        // バリデーション通過済み - ここに到達した時点で422は返らない
        $user = User::create($request->validated());
        return response()->json($user, 201);
    }
}

Laravel がバリデーション失敗時に返すレスポンスは、リクエストが JSON を期待しているか(Accept: application/json)どうかで変わります。JSON リクエストの場合は 422 ステータスと共にエラーの JSON が返され、通常のフォームリクエストの場合はセッションにエラーを入れて元のページにリダイレクトします。

Django での 422 の使い方

Django REST Framework(DRF)ではデフォルトでバリデーション失敗時に 400 を返しますが、422 を使うようにカスタマイズできます。

# Django REST Framework: カスタム例外ハンドラーで422を返す
from rest_framework.views import exception_handler
from rest_framework.exceptions import ValidationError
from rest_framework import status

def custom_exception_handler(exc, context):
    response = exception_handler(exc, context)

    if isinstance(exc, ValidationError) and response is not None:
        # バリデーションエラーのステータスを422に変更
        response.status_code = status.HTTP_422_UNPROCESSABLE_ENTITY

    return response

# settings.py に設定
# REST_FRAMEWORK = {
#     'EXCEPTION_HANDLER': 'myapp.utils.custom_exception_handler'
# }

# シリアライザーでのバリデーション
from rest_framework import serializers

class UserSerializer(serializers.Serializer):
    name = serializers.CharField(max_length=255)
    email = serializers.EmailField()
    avatar = serializers.ImageField(required=False)

    def validate_email(self, value):
        if User.objects.filter(email=value).exists():
            raise serializers.ValidationError(
                "このメールアドレスは既に登録されています。"
            )
        return value

Rails での 422 の使い方

Ruby on Rails では、モデルのバリデーション失敗時に :unprocessable_entity シンボルで 422 を返すのが一般的です。

# Rails: バリデーションエラー時に422を返す
class UsersController < ApplicationController
  def create
    user = User.new(user_params)

    if user.save
      render json: user, status: :created
    else
      render json: {
        message: "バリデーションエラー",
        errors: user.errors.full_messages
      }, status: :unprocessable_entity  # 422
    end
  end

  private

  def user_params
    params.require(:user).permit(:name, :email, :avatar)
  end
end

# モデルのバリデーション
class User < ApplicationRecord
  validates :name, presence: true, length: { maximum: 255 }
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :email, uniqueness: true

  has_one_attached :avatar
  validates :avatar, content_type: ['image/jpeg', 'image/png', 'image/webp'],
                     size: { less_than: 5.megabytes }
end

ファイルアップロードでの 422

ファイルアップロード機能では、さまざまなバリデーションで 422 を返すケースがあります。413(サイズ超過)とは異なり、422 はサーバーがリクエストを正常に受信したうえで、ファイルの内容が要件を満たさない場合に返します。

バリデーション項目 説明 ステータスコード
MIMEタイプ不正 拡張子は.jpgだがMIMEタイプがtext/plainの場合 422
拡張子不正 許可されていないファイル形式(.exeなど) 422
画像寸法の範囲外 最小サイズ未満、最大サイズ超過 422
ファイル破損 画像として読み込めない壊れたファイル 422
ウイルス検出 ClamAV等でマルウェアが検出された場合 422
サイズ超過 アプリケーション側の上限超過 422 or 413
// ファイルアップロードの詳細バリデーション例(Laravel)
public function uploadAvatar(Request $request)
{
    $request->validate([
        'avatar' => ['required', 'file'],
    ]);

    $file = $request->file('avatar');

    // MIMEタイプの二重チェック(拡張子偽装対策)
    $allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
    $detectedMime = $file->getMimeType(); // finfo による判定

    if (!in_array($detectedMime, $allowedMimes)) {
        return response()->json([
            'message' => 'バリデーションエラー',
            'errors' => [
                'avatar' => [
                    "許可されていないファイル形式です。検出されたMIMEタイプ: {$detectedMime}"
                ]
            ]
        ], 422);
    }

    // 画像として正常に読み込めるか確認
    $imageInfo = @getimagesize($file->getRealPath());
    if ($imageInfo === false) {
        return response()->json([
            'message' => 'バリデーションエラー',
            'errors' => [
                'avatar' => ['ファイルが破損しているか、有効な画像ファイルではありません。']
            ]
        ], 422);
    }

    // 画像寸法チェック
    [$width, $height] = $imageInfo;
    if ($width < 100 || $height < 100) {
        return response()->json([
            'message' => 'バリデーションエラー',
            'errors' => [
                'avatar' => ["画像は100x100px以上である必要があります。現在: {$width}x{$height}px"]
            ]
        ], 422);
    }

    // バリデーション通過 - 保存処理
    $path = $file->store('avatars', 'public');
    return response()->json(['path' => $path], 201);
}

API での 422 レスポンスフォーマット(RFC 7807 Problem Details)

API のエラーレスポンスを標準化するために RFC 7807(Problem Details for HTTP APIs)が策定されています。この仕様に準拠することで、クライアント側でのエラーハンドリングが統一的に行えるようになります。

// RFC 7807 準拠の 422 レスポンス例
// Content-Type: application/problem+json

{
    "type": "https://example.com/problems/validation-error",
    "title": "バリデーションエラー",
    "status": 422,
    "detail": "送信されたデータに2件のエラーがあります。",
    "instance": "/api/users",
    "errors": [
        {
            "field": "email",
            "message": "有効なメールアドレスを入力してください。",
            "code": "invalid_format"
        },
        {
            "field": "avatar",
            "message": "ファイル形式はJPEG・PNG・WebPのみ対応しています。",
            "code": "invalid_mime_type"
        }
    ]
}
// Laravel で RFC 7807 準拠のレスポンスを返す
use Symfony\Component\HttpFoundation\Response;

class ApiController extends Controller
{
    protected function validationProblem(
        array $errors,
        string $detail = 'バリデーションエラーが発生しました。'
    ): Response {
        $formattedErrors = [];
        foreach ($errors as $field => $messages) {
            foreach ($messages as $message) {
                $formattedErrors[] = [
                    'field'   => $field,
                    'message' => $message,
                ];
            }
        }

        return response()->json([
            'type'   => 'https://example.com/problems/validation-error',
            'title'  => 'Unprocessable Entity',
            'status' => 422,
            'detail' => $detail,
            'errors' => $formattedErrors,
        ], 422, [
            'Content-Type' => 'application/problem+json',
        ]);
    }
}

フロントエンドでのエラー表示パターン

422 レスポンスを受け取ったフロントエンドでは、フィールドごとのエラーメッセージをユーザーに分かりやすく表示する必要があります。

// fetch API での 422 エラーハンドリング
async function submitForm(formData) {
    try {
        const response = await fetch('/api/users', {
            method: 'POST',
            headers: {
                'Accept': 'application/json',
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(formData),
        });

        if (response.status === 422) {
            const data = await response.json();

            // フィールドごとにエラーを表示
            clearErrors();
            for (const [field, messages] of Object.entries(data.errors)) {
                const input = document.querySelector(`[name="${field}"]`);
                if (input) {
                    input.classList.add('border-red-500');
                    const errorDiv = document.createElement('div');
                    errorDiv.className = 'text-red-500 text-sm mt-1';
                    errorDiv.textContent = messages[0];
                    input.parentNode.appendChild(errorDiv);
                }
            }
            return;
        }

        if (!response.ok) {
            throw new Error('サーバーエラーが発生しました。');
        }

        const result = await response.json();
        showSuccess('登録が完了しました。');
    } catch (error) {
        showError(error.message);
    }
}

function clearErrors() {
    document.querySelectorAll('.border-red-500').forEach(el => {
        el.classList.remove('border-red-500');
    });
    document.querySelectorAll('.text-red-500').forEach(el => {
        el.remove();
    });
}
// axios での 422 エラーハンドリング(Vue.js / React 等で利用)
import axios from 'axios';

// グローバルインターセプターで422を処理
axios.interceptors.response.use(
    response => response,
    error => {
        if (error.response && error.response.status === 422) {
            // バリデーションエラーをストアに保存
            const errors = error.response.data.errors;
            store.commit('setValidationErrors', errors);
        }
        return Promise.reject(error);
    }
);

// コンポーネントでの使用例
async function handleSubmit() {
    try {
        store.commit('clearValidationErrors');
        const response = await axios.post('/api/users', formData);
        // 成功処理
    } catch (error) {
        if (error.response?.status !== 422) {
            // 422以外のエラーはグローバルで処理
            alert('予期しないエラーが発生しました。');
        }
        // 422はインターセプターで処理済み
    }
}

この記事で使えるテストファイル(無料)

📚 関連記事

PNG vs WebP vs AVIF|画像フォーマットの選び方と変換方法

PNG / JPEG / WebP / AVIF の特徴・用途・ブラウザ対応状況を比較。picture 要素での出し分け、DevLab の画像フォーマット変換ツールの使い方も解説。

2026-04-18

Whois でドメイン情報を調べる方法|有効期限・ネームサーバー・登録者

Whois でわかること (登録者・有効期限・レジストラ・NS)、GDPR によるプライバシー保護の影響、ドメイン管理の実務的な使い方を解説。

2026-04-18

HTTP ステータスコード完全ガイド|よくあるエラーの原因と対処法

開発者が頻出する HTTP ステータスコード (200/301/302/400/401/403/404/413/422/429/500/502/503/504) の意味・原因・対処法を解説。301 vs 302 の SEO 影響、400 vs 422 の使い分けも。

2026-04-18

cURL コマンドを JavaScript fetch・Python requests に変換する方法|DevTools 連携

Chrome DevTools の Copy as cURL を fetch / axios / Python requests / PHP cURL / Go net/http に変換する手順を解説。主要 cURL オプション (-X / -H / -d / -F / -u / -b / -L) の変換パターン、認証トークンの扱い、注意点まで。

2026-04-16

Cookie のセキュリティフラグ完全ガイド|Secure / HttpOnly / SameSite / __Host-

Cookie のセキュリティ属性 Secure / HttpOnly / SameSite (Strict/Lax/None) / __Host- __Secure- プレフィックス / 4096 バイト制限を解説。CSRF / XSS / セッションハイジャック対策と、Laravel / Express の実装例。

2026-04-16

JWT のセキュリティベストプラクティス|alg none 攻撃 / 有効期限 / 署名検証

JWT (JSON Web Token) の代表的な脆弱性 6 種類 (alg none 攻撃 / 鍵混同 / 無期限トークン / payload への機密情報 / 失効不可 / 弱いシークレット) と対策。リフレッシュトークンパターン、失効リスト、HttpOnly Cookie 格納まで。

2026-04-16