外部 Webhook 受信の設計
GitHub や決済サービスなどの Webhook を受けてプリザンターにレコードを作りたい、という要件に対して、現行のプリザンターには受信専用の口がありません。このページは、Webhook を受けてサーバースクリプトで処理する機能を追加する本体改修の設計メモです。
現状: 受信用のエンドポイントは無い
1.5.8.1 の API コントローラ(Controllers/Api/)は Items・Depts・Users・Groups・Extensions・Binaries・Sessions・BackgroundTasks・Tenants・OutgoingMails・Utility・Extended・Demo で、外部サービス固有のペイロードをそのまま受ける口はありません。
外部サービスの Webhook は送り手ごとに決まった形式の JSON を送るため、プリザンターの API(ApiKey と決まった形式の本文が必要)に直接は向けられません。今は、間に iPaaS や自前の中継サービスを置いて、プリザンターの API の形式に変換してから呼ぶ必要があります。
図を読み込み中…
| 観点 | 現行の API | Webhook 受信 |
|---|---|---|
| 認証 | API キー | URL の秘密値、HMAC 署名、トークン |
| 本文 | プリザンターの形式 | 送り手の形式 |
| 変換 | 呼び出し側で行う | 受信側(サーバースクリプト)で行う |
| 起点 | 呼び出し側が能動的に呼ぶ | 外部サービスからのプッシュ |
要件
| 要件 | 内容 |
|---|---|
| テナント単位で複数 | 1 テナントに複数の受信口を作れる |
| 推測できない URL | フォーム機能と同じく 32 桁の 16 進数を URL に含める |
| レート制限 | 一定時間あたりのリクエスト数を制限する |
| 後続処理 | 受けたリクエストをサーバースクリプトで参照・処理する |
| 実行ユーザー | スクリプトを実行するユーザーを設定ごとに指定する |
前提にした現行実装
| 部品 | 1.5.8.1 の実装 | 流用のしかた |
|---|---|---|
| 推測できない URL | フォーム機能の Sites.Form(32 桁)と /forms/{guid}/new。ルートの guid は [A-Fa-f0-9]{32} で制約(Startup.cs)。仕組みは iCal フィードの設計 を参照 | 同じ形式のトークンを URL に使う |
| テナント単位の設定 | TenantSettings は Tenants.TenantSettings 列の JSON で、BackgroundServerScripts を持つ(TenantSettings.cs) | 受信設定の一覧を同じ場所に持たせる |
| 実行ユーザー | BackgroundServerScript は UserId を持ち(BackgroundServerScript.cs)、ジョブがそのユーザーの Context を作る(BackgroundServerScriptJob.cs) | 同じ手順で Context を作る |
| 既存の回数制限 | サイトごとの API の日次上限(ContractSettings.ApiLimit。未設定ならパラメータ Api.LimitPerSite。ContractSettings.cs)。回数は Sites の ApiCount / ApiCountDate で数える | 日次の上限なので、秒・分単位の制限には別の仕組みが要る |
| フィルタ | フォーム用の FormsAttributes(Filters/) | 受信用フィルタの参考にする |
設計
全体の流れ
図を読み込み中…
URL
POST /webhooks/{token}| 項目 | 値 |
|---|---|
| トークン | 32 桁の 16 進数(Guid.NewGuid().ToString("N") など、サーバー側で作る) |
| 保存先 | Tenants.TenantSettings の WebhookSettings |
| コントローラ | WebhooksController(新設) |
| ルート制約 | [A-Fa-f0-9]{32} |
endpoints.MapControllerRoute(
name: "Webhooks",
pattern: "webhooks/{guid}",
defaults: new { Controller = "Webhooks", Action = "Receive" },
constraints: new { Guid = "[A-Fa-f0-9]{32}" });Context はルートの guid を大文字にして持つので(Context.cs)、トークンは大文字で保存し、照合時に正規化します。
設定モデル
public class WebhookSetting : ISettingListItem
{
public int Id { get; set; }
public string Token; // 32 桁の 16 進数
public string Name; // 管理用の名前
public int UserId; // スクリプトの実行ユーザー
public string Script; // 実行するサーバースクリプト
public int RateLimitPerMinute; // 1 分あたりの上限(0 は無制限)
public bool Disabled;
}図を読み込み中…
管理画面は、バックグラウンドサーバースクリプトと同じくテナントの管理画面にタブを追加します。
| 項目 | 説明 |
|---|---|
| 名前 | 管理用 |
| URL | 自動生成(読み取り専用・コピーボタン)。再発行ボタンも用意する |
| 実行ユーザー | テナントのユーザーから選ぶ |
| スクリプト | 実行するサーバースクリプト |
| 1 分あたりの上限 | 0 は無制限 |
| 無効 | ON で受け付けない |
レート制限
既存の日次カウントでは足りないので、トークンごとにメモリ上のスライディングウィンドウで数えます。
public static class WebhookRateLimiter
{
private static readonly ConcurrentDictionary<string, Queue<DateTime>> Counters = new();
public static bool IsAllowed(string token, int limitPerMinute)
{
var now = DateTime.UtcNow;
var queue = Counters.GetOrAdd(token, _ => new Queue<DateTime>());
lock (queue)
{
while (queue.Count > 0 && now - queue.Peek() > TimeSpan.FromMinutes(1))
{
queue.Dequeue();
}
if (queue.Count >= limitPerMinute) return false;
queue.Enqueue(now);
return true;
}
}
}複数台構成ではメモリのカウンタはサーバー間で共有されないため、厳密に制限するなら Redis や DB のカウンタにします。
受信内容をスクリプトに渡す
サーバースクリプトの実行条件に WebhookReceived を追加し、受信内容を読むホストオブジェクト webhookRequest を登録します。
public class ServerScriptModelWebhookRequest
{
public string Body { get; } // 本文(生の文字列)
public string Method { get; } // POST など
public string ContentType { get; }
public Dictionary<string, string> Headers { get; } // 署名の検証などに使う
public string Json(string path) { /* 本文を JSON として読み、path の値を返す */ }
}// 受信用のサーバースクリプト(例)
var eventType = webhookRequest.Json('event');
if (eventType === 'order.created') {
var orderId = webhookRequest.Json('data.id');
items.Create(12345, JSON.stringify({
Title: '受注 #' + orderId
}));
}items.Create の第 2 引数は JSON 文字列か items.New() のモデルで渡します。
コントローラとフィルタ
public class WebhookAttributes : ActionFilterAttribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext filterContext)
{
var token = filterContext.RouteData.Values["guid"]?.ToString()?.ToUpper();
var setting = WebhookSettingsCache.GetByToken(token);
if (setting == null || setting.Disabled)
{
filterContext.Result = new NotFoundResult();
return;
}
if (setting.RateLimitPerMinute > 0
&& !WebhookRateLimiter.IsAllowed(token, setting.RateLimitPerMinute))
{
filterContext.Result = new StatusCodeResult(429);
return;
}
filterContext.HttpContext.Items["WebhookSetting"] = setting;
}
}
[AllowAnonymous]
[WebhookAttributes]
public class WebhooksController : Controller
{
[HttpPost]
public IActionResult Receive(string guid)
{
var setting = (WebhookSetting)HttpContext.Items["WebhookSetting"];
var context = CreateContext(tenantId: setting.TenantId, userId: setting.UserId);
var ss = SiteSettingsUtilities.TenantsSiteSettings(context: context);
// setting.Script を WebhookReceived 条件で実行し、webhookRequest を渡す
return Ok();
}
}WebhookSettingsCache はトークンから設定とテナント ID を引く新設のキャッシュです。
変更箇所
図を読み込み中…
注意点
| 項目 | 内容 |
|---|---|
| CSRF | 外部からの POST なので AntiForgery トークンは検証しない([AllowAnonymous]) |
| 本文の読み取り | 署名検証とスクリプトの両方で読むなら Request.EnableBuffering() で読み直せるようにする |
| 署名の検証 | 送り手が HMAC 署名を付けるなら、Headers と Body からスクリプトまたはフィルタで検証する |
| 長い処理 | 外部サービスは短いタイムアウトで再送することが多い。重い処理はキューに入れて先に 200 を返す |
| ログ | 受信日時・トークンの先頭数桁・結果を SysLogs に残す。トークン全体は残さない |
| 機能の ON/OFF | パラメータ(例: Webhook.json の Enabled)と、契約設定の拡張機能(フォームの Form と同じ形)で制御する |