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

ASP.NET Core 申請・承認機能を作る② 下書き保存と申請API

ASP.NET Coreで、下書きの作成・保存と、申請するAPIを作ります。

下書きは途中までしか書いていなくても保存できるようにし、申請時には必要な項目がそろっていることを確認します。保存と申請で同じ検証を使うと、書きかけの内容を保存できなくなるためです。

  1. 状態とDBを設計する
  2. 下書き保存と申請API(この記事)
  3. 承認・却下と権限の確認
  4. 状態に応じた画面を作る
  5. 同時操作と履歴を扱う

前回の記事で作ったApprovalRequestとApprovalPolicyを使用します。

保存処理をトランザクション単位で扱う

サービスからDBを操作するためのインターフェースを用意します。

ApprovalStore.cs
public interface IApprovalStore
{
    Task<IApprovalSession> OpenAsync(
        Guid organizationId, Guid actorId, Guid? requestId, CancellationToken ct);
}

public interface IApprovalSession : IAsyncDisposable
{
    Member? Member { get; }
    ApprovalRequest? Request { get; }
    Task InsertAsync(ApprovalRequest item, CancellationToken ct);
    Task SaveAsync(ApprovalRequest before, ApprovalRequest after, CancellationToken ct);
    Task AppendHistoryAsync(ApprovalHistory history, CancellationToken ct);
    Task CommitAsync(CancellationToken ct);
}

IApprovalSessionは、1回のAPI処理で使うDB接続とトランザクションを表します。ログインセッションとは別物です。

保存層は次の契約で実装します。特に、OpenAsyncを単にSELECTするだけの処理にしないことが大切です。

処理保存層が保証すること
OpenAsyncトランザクションを開始し、有効な所属を共有ロックして取得する。申請IDがあれば組織IDと合わせて検索し、行を更新用にロックする
Member有効な所属がなければnull。権限もDBの現在値を返す
Request指定組織内に申請がなければnull
InsertAsync同じトランザクションで下書きを追加する
SaveAsync更新前のVersionと状態が一致するときだけ更新する。0件なら競合エラーにする
AppendHistoryAsync同じトランザクションで履歴を追加する
CommitAsync申請と履歴をまとめて確定する
DisposeAsync未確定ならロールバックし、接続とロックを解放する

読み取りだけで終わる場合も、破棄時にトランザクションを終了します。SQLとロックの詳しい実装は第5回で説明します。この契約を満たす保存層まで実装してから、APIを動かします。

下書きと申請のサービスを作る

次のファイルに、詳細取得、下書き作成、保存、申請の処理をまとめます。

ApprovalService.cs
public sealed record EditApproval(string Title, string Description, long ExpectedVersion);
public sealed record VersionInput(long ExpectedVersion);
public sealed record RejectApproval(long ExpectedVersion, string Reason);
public sealed record ApprovalDetail(
    Guid Id, string Title, string Description, string Status,
    long Version, string? DecisionReason, AllowedActions AllowedActions);

public sealed partial class ApprovalService(IApprovalStore store)
{
    private static Member RequireMember(IApprovalSession session) =>
        session.Member ?? throw new WorkflowError(403, "membership_required");

    private static ApprovalRequest RequireVisible(IApprovalSession session, Member member)
    {
        var item = session.Request;
        if (item is null || !ApprovalPolicy.CanView(item, member))
            throw new WorkflowError(404, "request_not_found");
        return item;
    }

    private static ApprovalDetail ToDetail(ApprovalRequest item, Member member) =>
        new(item.Id, item.Title, item.Description, item.Status.ToString(),
            item.Version, item.DecisionReason, ApprovalPolicy.GetActions(item, member));

    private static void CheckVersion(ApprovalRequest item, long expectedVersion)
    {
        if (item.Version != expectedVersion)
            throw new WorkflowError(409, "request_changed");
    }

    public async Task<ApprovalDetail> GetAsync(
        Guid org, Guid actor, Guid id, CancellationToken ct)
    {
        await using var session = await store.OpenAsync(org, actor, id, ct);
        var member = RequireMember(session);
        return ToDetail(RequireVisible(session, member), member);
    }

    public async Task<ApprovalDetail> CreateAsync(Guid org, Guid actor, CancellationToken ct)
    {
        await using var session = await store.OpenAsync(org, actor, null, ct);
        var member = RequireMember(session);
        var item = new ApprovalRequest(Guid.NewGuid(), org, actor, "", "",
            ApprovalStatus.Draft, 1, null);
        await session.InsertAsync(item, ct);
        await session.AppendHistoryAsync(new(item.Id, 1, actor, "created",
            ApprovalStatus.Draft, ApprovalStatus.Draft, null, DateTimeOffset.UtcNow), ct);
        await session.CommitAsync(ct);
        return ToDetail(item, member);
    }

    public async Task<ApprovalDetail> EditAsync(
        Guid org, Guid actor, Guid id, EditApproval input, CancellationToken ct)
    {
        await using var session = await store.OpenAsync(org, actor, id, ct);
        var member = RequireMember(session);
        var item = RequireVisible(session, member);
        CheckVersion(item, input.ExpectedVersion);
        if (!ApprovalPolicy.GetActions(item, member).CanEdit)
            throw new WorkflowError(403, "edit_not_allowed");
        var title = input.Title?.Trim() ?? "";
        var description = input.Description?.Trim() ?? "";
        if (title.Length > 100 || description.Length > 2000)
            throw new WorkflowError(400, "input_too_long");
        var next = item with { Title = title, Description = description,
            Version = item.Version + 1 };
        await SaveAsync(session, item, next, actor, "edited", null, ct);
        return ToDetail(next, member);
    }

    public async Task<ApprovalDetail> SubmitAsync(
        Guid org, Guid actor, Guid id, VersionInput input, CancellationToken ct)
    {
        await using var session = await store.OpenAsync(org, actor, id, ct);
        var member = RequireMember(session);
        var item = RequireVisible(session, member);
        CheckVersion(item, input.ExpectedVersion);
        if (!ApprovalPolicy.GetActions(item, member).CanSubmit)
            throw new WorkflowError(403, "submit_not_allowed");
        if (string.IsNullOrWhiteSpace(item.Title) || string.IsNullOrWhiteSpace(item.Description))
            throw new WorkflowError(400, "submission_incomplete");
        var next = item with { Status = ApprovalStatus.Submitted, Version = item.Version + 1 };
        await SaveAsync(session, item, next, actor, "submitted", null, ct);
        return ToDetail(next, member);
    }

    private static async Task SaveAsync(
        IApprovalSession session, ApprovalRequest before, ApprovalRequest after,
        Guid actor, string action, string? reason, CancellationToken ct)
    {
        await session.SaveAsync(before, after, ct);
        await session.AppendHistoryAsync(new(after.Id, after.Version, actor, action,
            before.Status, after.Status, reason, DateTimeOffset.UtcNow), ct);
        await session.CommitAsync(ct);
    }
}

partialは、1つのクラスを複数ファイルへ分ける指定です。次回の承認・却下処理を、同じサービスに追加します。

空の下書きを作る理由

CreateAsyncは空の下書きを作り、サーバーが採番したIDを返します。画面はそのIDを使って保存や申請を行います。

本文のapplicantIdを受け取る代わりに、引数のactorを申請者にしています。この値は、後述するコントローラーが認証済みの利用者IDから取得します。

組織IDも入力された値を無条件に信頼しません。OpenAsyncでその組織の有効な所属を調べ、なければ403にします。

作成時にはcreatedの履歴も記録します。初期状態を表すため、この履歴だけは変更前と変更後の両方がDraftです。

下書き保存は、入力途中でも受け付ける

EditAsyncでは、件名と内容の前後の空白を除き、長すぎる値を拒否します。空文字は許可します。

同時に、次の条件を確認します。

  1. 申請を閲覧できる
  2. 画面が持つVersionと現在のVersionが一致する
  3. 本人の下書きである

Versionが古ければ409、現在の状態や権限で編集できなければ403を返します。

利用者が閲覧できない申請は、存在しない申請と同じ404にします。IDを変えてAPIを呼ぶだけで、他人の下書きの存在や内容を調べられないようにするためです。

申請は、DBに保存済みの内容を対象にする

SubmitAsyncは、リクエストから件名や内容を受け取りません。DBに保存されている本文を確認し、その内容を申請します。

これにより、「保存したもの」と「申請したもの」の境界が分かりやすくなります。画面で未保存の変更がある場合は、先に保存してもらいます。

申請では、件名と内容の両方を必須にしています。保存時の長さ制限に加えて、空白だけの入力も拒否します。

SaveAsyncは、内容の更新、履歴の追加、コミットを順に行う共通処理です。履歴追加に失敗したら内容だけが確定することのないよう、保存層は同じトランザクションを使います。

HTTPの入口を作る

コントローラーでは、認証済みの利用者IDを取り出してサービスへ渡します。この例は、ClaimTypes.NameIdentifierにGUID形式の会員IDが入っている前提です。

ApprovalsController.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Authorize]
[AutoValidateAntiforgeryToken]
[Route("api/organizations/{org:guid}/approvals")]
public sealed partial class ApprovalsController(ApprovalService service) : ControllerBase
{
    [HttpGet("{id:guid}")]
    public Task<IActionResult> Get(Guid org, Guid id, CancellationToken ct) =>
        Run(actor => service.GetAsync(org, actor, id, ct));

    [HttpPost]
    public Task<IActionResult> Create(Guid org, CancellationToken ct) =>
        Run(actor => service.CreateAsync(org, actor, ct));

    [HttpPut("{id:guid}")]
    public Task<IActionResult> Edit(Guid org, Guid id, EditApproval input, CancellationToken ct) =>
        Run(actor => service.EditAsync(org, actor, id, input, ct));

    [HttpPost("{id:guid}/submit")]
    public Task<IActionResult> Submit(Guid org, Guid id, VersionInput input, CancellationToken ct) =>
        Run(actor => service.SubmitAsync(org, actor, id, input, ct));

    private async Task<IActionResult> Run(Func<Guid, Task<ApprovalDetail>> action)
    {
        if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var actor))
            return Unauthorized();
        Response.Headers.CacheControl = "no-store";
        try
        {
            return Ok(await action(actor));
        }
        catch (WorkflowError error)
        {
            var problem = new ProblemDetails
            {
                Status = error.Status,
                Title = "申請の操作を完了できませんでした。",
            };
            problem.Extensions["code"] = error.Code;
            return new ObjectResult(problem) { StatusCode = error.Status };
        }
    }
}

想定した業務上の失敗はWorkflowErrorで表し、HTTPのステータスとcodeへ変換します。DB接続エラーなどまで一律に400へ変換せず、アプリ共通の例外処理で記録してください。

[Authorize]はログイン済みであることを確認します。組織や申請ごとの権限は、サービスと保存層で確認します。認証だけで、すべての申請にアクセスできるわけではありません。

今回は成功した操作がすべて最新の詳細を200 OKで返します。画面は、返ってきたVersionと操作可否を次の操作に使います。

Cookie認証のCSRF対策を接続する

ReactとAPIを同じOriginで公開し、既存のCookie認証を使う前提です。開発中に別ポートで動かす場合も、React側の/apiをバックエンドへプロキシします。

既存のProgram.csには、次のサービスを統合します。

Program.csに追加する登録
builder.Services.AddControllersWithViews();
builder.Services.AddAntiforgery(options =>
{
    options.HeaderName = "X-CSRF-TOKEN";
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
builder.Services.AddScoped<ApprovalService>();
// IApprovalStore の実装クラスも、ここで Scoped として登録する。

AutoValidateAntiforgeryTokenのフィルターを使うため、ここではAddControllersWithViewsを使用します。画面はReactで描画しますが、AddControllersだけではこのフィルターに必要なサービスが登録されません。

既存の認証・認可設定とUseAuthentication、UseAuthorization、MapControllersも必要です。Cookie認証のAPIへのリダイレクトは、未認証なら401、権限不足なら403になるように設定します。HTTPSで動作させてください。

CSRFトークン取得用のAPIは、次の形です。既存の取得APIがあれば、そちらに合わせても構いません。

CsrfController.cs
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Authorize]
public sealed class CsrfController(IAntiforgery antiforgery) : ControllerBase
{
    [HttpGet("api/csrf")]
    public IActionResult Get()
    {
        Response.Headers.CacheControl = "no-store";
        return Ok(new { token = antiforgery.GetAndStoreTokens(HttpContext).RequestToken });
    }
}

更新時はX-CSRF-TOKENヘッダーにトークンを載せます。詳しい仕組みはSPAでのCSRF対策も参照してください。

APIの確認順序

まずは、ログイン済みの利用者で次の順に呼び出します。更新リクエストには、先ほど取得したCSRFトークンを付けます。

  1. POST /api/organizations/{org}/approvalsで下書きを作る
  2. 返ってきたIDとVersionを使って下書きを保存する
  3. 保存結果のVersionを使って申請する
  4. 詳細を取得し、状態がSubmittedであることを確認する

保存の本文は次の形です。

下書きを保存する本文
{
  "title": "外付けモニターの購入",
  "description": "作業用の表示領域を増やすため、モニターの購入を申請します。",
  "expectedVersion": 1
}

保存に成功したらVersionは2になります。申請では、その番号を送ります。

申請する本文
{
  "expectedVersion": 2
}

画面は番号を自分で加算せず、APIが返した値を使います。次は承認・却下と権限の確認へ進みます。


関連記事