422 Unprocessable Entityエラーの原因と解決方法|バリデーションエラーの正しい返し方
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はインターセプターで処理済み
}
}
この記事で使えるテストファイル(無料)
- → テスト画像一覧 — MIMEタイプ・拡張子バリデーションのテストに
- → 境界値テスト用ファイル一覧 — サイズ上限バリデーションの境界値テストに
- → 1MBテストPNG画像 — 画像アップロードの基本テストに
- → PDFテストファイル一覧 — ファイル形式バリデーションのテストに