ASP.NET Core 申請・承認機能を作る② 下書き保存と申請API
ASP.NET Coreで、下書きの作成・保存と、申請するAPIを作ります。
下書きは途中までしか書いていなくても保存できるようにし、申請時には必要な項目がそろっていることを確認します。保存と申請で同じ検証を使うと、書きかけの内容を保存できなくなるためです。
前回の記事で作ったApprovalRequestとApprovalPolicyを使用します。
保存処理をトランザクション単位で扱う
サービスからDBを操作するためのインターフェースを用意します。
ApprovalStore.cspublic 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.cspublic 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では、件名と内容の前後の空白を除き、長すぎる値を拒否します。空文字は許可します。
同時に、次の条件を確認します。
- 申請を閲覧できる
- 画面が持つVersionと現在のVersionが一致する
- 本人の下書きである
Versionが古ければ409、現在の状態や権限で編集できなければ403を返します。
利用者が閲覧できない申請は、存在しない申請と同じ404にします。IDを変えてAPIを呼ぶだけで、他人の下書きの存在や内容を調べられないようにするためです。
申請は、DBに保存済みの内容を対象にする
SubmitAsyncは、リクエストから件名や内容を受け取りません。DBに保存されている本文を確認し、その内容を申請します。
これにより、「保存したもの」と「申請したもの」の境界が分かりやすくなります。画面で未保存の変更がある場合は、先に保存してもらいます。
申請では、件名と内容の両方を必須にしています。保存時の長さ制限に加えて、空白だけの入力も拒否します。
SaveAsyncは、内容の更新、履歴の追加、コミットを順に行う共通処理です。履歴追加に失敗したら内容だけが確定することのないよう、保存層は同じトランザクションを使います。
HTTPの入口を作る
コントローラーでは、認証済みの利用者IDを取り出してサービスへ渡します。この例は、ClaimTypes.NameIdentifierにGUID形式の会員IDが入っている前提です。
ApprovalsController.csusing 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.csusing 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トークンを付けます。
POST /api/organizations/{org}/approvalsで下書きを作る- 返ってきたIDとVersionを使って下書きを保存する
- 保存結果のVersionを使って申請する
- 詳細を取得し、状態が
Submittedであることを確認する
保存の本文は次の形です。
下書きを保存する本文{
"title": "外付けモニターの購入",
"description": "作業用の表示領域を増やすため、モニターの購入を申請します。",
"expectedVersion": 1
}保存に成功したらVersionは2になります。申請では、その番号を送ります。
申請する本文{
"expectedVersion": 2
}画面は番号を自分で加算せず、APIが返した値を使います。次は承認・却下と権限の確認へ進みます。