ASP.NET Core 入力フォームを作る② サーバーで入力を検証する
フォームから受け取った値を検証し、正しい値だけを保存するAPIを作ります。
画面の文字数制限やボタンの無効化は、入力を助けるための機能です。APIへ直接リクエストを送ることもできるため、保存する条件はサーバーで確認します。
前回のProfileInput、ProfileValues、IProfileStoreを使用します。
正規化と検証の順番を決める
今回は、前後の空白を取り除いた値で検証します。
- 表示名、自己紹介、目標日数の前後の空白を取り除く
- 必須、長さ、入力形式を検証する
- 空欄の任意項目をnullへ変換する
- 検証に成功した値だけを保存層へ渡す
入力のたびに画面でtrim()すると、利用者が意図して入力した空白が消えたり、編集しにくくなったりします。今回は保存時にサーバーで正規化し、その結果を画面へ返します。
検証を小さな関数にまとめる
ProfileValidation.csusing System.Globalization;
public static class ProfileValidation
{
public static (ProfileValues Values, long Version, Dictionary<string, string[]> Errors)
Validate(ProfileInput input)
{
var errors = new Dictionary<string, string[]>();
var name = input.DisplayName?.Trim() ?? "";
var bio = input.Bio?.Trim() ?? "";
var goalText = input.WeeklyGoal?.Trim() ?? "";
int? goal = null;
if (name.Length is < 1 or > 40)
errors["displayName"] = ["表示名は1〜40文字で入力してください。"];
if (bio.Length > 300)
errors["bio"] = ["自己紹介は300文字以内で入力してください。"];
if (goalText.Length > 0)
{
if (goalText.Length != 1 || goalText[0] < '1' || goalText[0] > '7')
errors["weeklyGoal"] = ["目標は半角数字の1〜7、または空欄にしてください。"];
else goal = goalText[0] - '0';
}
if (!long.TryParse(input.ExpectedVersion, NumberStyles.None,
CultureInfo.InvariantCulture, out var version) || version < 1)
errors["expectedVersion"] = ["画面を読み直してから保存してください。"];
return (new(name, bio.Length == 0 ? null : bio, goal), version, errors);
}
}入力エラーのキーは、画面の項目名と同じdisplayName、bio、weeklyGoalにします。日本語のラベルをキーにすると、表示文言を変えた際に対応関係も変わってしまいます。
目標日数は1文字の半角数字に限定しています。1.5、01、1e0、全角の3は受け付けません。表記を広く許容する場合は、その正規化ルールを別途決めてください。
expectedVersionは画面の入力項目ではありませんが、APIへ送られる値なので検証します。不正な場合は、画面全体に再取得の案内を表示する想定です。
取得・保存APIを作る
ProfileController.csusing System.Security.Claims;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Authorize]
[AutoValidateAntiforgeryToken]
[Route("api/me/profile")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class ProfileController(IProfileStore store) : ControllerBase
{
[HttpGet("csrf")]
public IActionResult Csrf([FromServices] IAntiforgery antiforgery) =>
Ok(new { token = antiforgery.GetAndStoreTokens(HttpContext).RequestToken });
[HttpGet]
public async Task<IActionResult> Get(CancellationToken ct)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
return Unauthorized();
var row = await store.FindAsync(userId, ct);
return row is null ? NotFound() : Ok(ProfileResult.From(row));
}
[HttpPut]
public async Task<IActionResult> Save(ProfileInput input, CancellationToken ct)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
return Unauthorized();
var validated = ProfileValidation.Validate(input);
if (validated.Errors.Count > 0)
{
var problem = new ValidationProblemDetails(validated.Errors)
{
Status = 400, Title = "入力内容を確認してください。",
};
problem.Extensions["code"] = "validation_failed";
return new ObjectResult(problem) { StatusCode = 400 };
}
var saved = await store.TrySaveAsync(userId, validated.Version, validated.Values, ct);
if (saved is null)
return Problem(statusCode: 409, title: "最新の内容を確認してください。");
return Ok(ProfileResult.From(saved));
}
}ログイン済みの利用者IDは、ClaimTypes.NameIdentifierから取得します。この例は会員IDがGUIDの場合です。アプリのID形式が異なる場合は合わせてください。
/api/me/profileというURLを使っていても、認証済み利用者の確認は必要です。[Authorize]と、認証情報からのID取得を組み合わせています。
入力エラーはValidationProblemDetailsのerrorsへ入れます。形式が壊れたJSONなどは、[ApiController]がアクション実行前に400を返すこともあります。この違いはASP.NET Coreのモデル検証で確認できます。
このPUTは、編集可能な3項目全体を更新する契約です。任意項目を省略すると空欄として扱われます。一部の項目だけを更新するPATCHのような使い方はしません。
保存の成功結果をそのまま返す
TrySaveAsyncは、更新してから別のSELECTで読み直すのではなく、次のSQLのRETURNINGで更新した行を返す契約にします。
Versionが一致する場合だけ保存するUPDATE user_profile
SET display_name = @display_name,
bio = @bio,
weekly_goal = @weekly_goal,
version = version + 1
WHERE user_id = @user_id
AND version = @expected_version
RETURNING user_id, display_name, bio, weekly_goal, version;値はパラメーターで渡します。取得した行をProfileRowへ変換し、0件ならnullを返します。成功結果はコミットが完了してから呼び出し元へ返してください。
プロフィールが存在しない場合も更新件数は0件です。この例のPUTでは競合と同じ409とし、再取得を案内します。GETが404なら、会員作成時のプロフィール初期化や削除状態を確認します。
Versionによる同時更新の仕組みは第5回で詳しく説明します。検証したあとに無条件のUPDATEを実行する形には変更しないでください。
既存の認証設定に組み込む
既存のProgram.csへ、必要な設定を統合します。
サービス登録の例builder.Services.AddControllersWithViews();
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
// IProfileStore の実装を Scoped として登録する。AutoValidateAntiforgeryTokenに必要なフィルターを登録するため、ここではAddControllersWithViewsを使います。画面はReactで描画しますが、AddControllersだけではこのフィルターの登録が不足します。
Cookie認証、認可、UseAuthentication、UseAuthorization、MapControllersも既存のアプリで設定してください。APIの未認証時はログインHTMLへリダイレクトせず401、権限不足なら403を返す構成にします。
画面とAPIは同じOriginで公開し、HTTPSで動かします。ReactはCSRF取得APIを呼び、更新時にX-CSRF-TOKENヘッダーを送ります。詳しくはSPAでのCSRF対策を参照してください。
検証だけでなく、保存しないことも確認する
| 入力 | 結果 |
|---|---|
| 表示名が空白だけ | displayNameのエラー |
| 表示名が41文字 | displayNameのエラー |
| 自己紹介が空欄 | nullとして保存 |
| 目標日数が空欄 | nullとして保存 |
目標日数が3 | 整数の3として保存 |
目標日数が0や8 | weeklyGoalのエラー |
| 古いVersion | 409、保存しない |
入力エラー時には、DBの内容やVersionが変わっていないことも確認します。次は入力を保持するReactフォームを作ります。