Skip to content

外部 Webhook 受信の設計 ​

第1版作成 最終更新 (日本時間)
確認バージョン1.5.8.1

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 の形式に変換してから呼ぶ必要があります。

図を読み込み中…

観点現行の APIWebhook 受信
認証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 ​

text
POST /webhooks/{token}
項目値
トークン32 桁の 16 進数(Guid.NewGuid().ToString("N") など、サーバー側で作る)
保存先Tenants.TenantSettings の WebhookSettings
コントローラWebhooksController(新設)
ルート制約[A-Fa-f0-9]{32}
csharp
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)、トークンは大文字で保存し、照合時に正規化します。

設定モデル ​

csharp
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 で受け付けない

レート制限 ​

既存の日次カウントでは足りないので、トークンごとにメモリ上のスライディングウィンドウで数えます。

csharp
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 を登録します。

csharp
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 の値を返す */ }
}
javascript
// 受信用のサーバースクリプト(例)
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() のモデルで渡します。

コントローラとフィルタ ​

csharp
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 と同じ形)で制御する

関連ページ ​

変更履歴

第1版外部連携の改修・設計メモ(iCal・RSS/Atom・Webhook 送受信・iPaaS・短縮 URL・POP 受信・マスターデータ同期)を追加