zukucode
主にWEB関連の情報を技術メモとして発信しています。

ASP.NET Core 入力フォームを作る② サーバーで入力を検証する

フォームから受け取った値を検証し、正しい値だけを保存するAPIを作ります。

画面の文字数制限やボタンの無効化は、入力を助けるための機能です。APIへ直接リクエストを送ることもできるため、保存する条件はサーバーで確認します。

  1. 入力値と保存値を設計する
  2. サーバーで入力を検証する(この記事)
  3. 入力を保持するReactフォーム
  4. API接続とエラー表示
  5. 保存競合と入力内容の保護

前回のProfileInput、ProfileValues、IProfileStoreを使用します。

正規化と検証の順番を決める

今回は、前後の空白を取り除いた値で検証します。

  1. 表示名、自己紹介、目標日数の前後の空白を取り除く
  2. 必須、長さ、入力形式を検証する
  3. 空欄の任意項目をnullへ変換する
  4. 検証に成功した値だけを保存層へ渡す

入力のたびに画面でtrim()すると、利用者が意図して入力した空白が消えたり、編集しにくくなったりします。今回は保存時にサーバーで正規化し、その結果を画面へ返します。

検証を小さな関数にまとめる

ProfileValidation.cs
using 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.cs
using 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や8weeklyGoalのエラー
古いVersion409、保存しない

入力エラー時には、DBの内容やVersionが変わっていないことも確認します。次は入力を保持するReactフォームを作ります。


関連記事