feat(webhook): GET support — query string → JSON body, HEAD safe no-op

POST behaviour unchanged (JSON body). GET on the same URL now triggers the
workflow with the query string collapsed into a JSON object: /wh/wf/hi?a=1&name=x
becomes {"a":"1","name":"x"}, and repeated keys collapse into a string
array (?tag=a&tag=b → {"tag":["a","b"]}). No query string yields {} —
matches the empty-POST-body semantics.

HEAD returns 200 empty so Slack/Discord link previews don't fire the workflow
AND don't flag the URL as broken. Other verbs (PUT/PATCH/DELETE/OPTIONS) still
return 405 JSON with Allow: POST, GET.

Frontend WebhookUrlHint now advertises both verbs and drops the POST prefix
from the displayed URL so the raw URL is copy-and-clickable.

6 new xUnit tests cover: GET-unknown-404, GET-with-query, GET-repeated-keys,
GET-empty-query, HEAD-200, PUT-still-405 (updated Allow header).
This commit is contained in:
Claude
2026-08-29 07:51:27 +00:00
parent a06e9e0e84
commit bc682d8c9f
3 changed files with 136 additions and 58 deletions

View File

@@ -8,9 +8,11 @@ public static class WebhookEndpoint
{
public static void MapWebhookEndpoint(this IEndpointRouteBuilder app, TimeSpan defaultTimeout)
{
// POST — the primary shape. Body is parsed as JSON; on empty/invalid
// JSON the trigger receives {} or {"raw": "..."} respectively.
app.MapPost("/wh/{workflowId}/{**path}", async (
string workflowId, string? path,
HttpRequest req, HttpResponse res,
HttpRequest req,
WebhookRegistry registry, WebhookRouter router,
CancellationToken ct) =>
{
@@ -18,7 +20,6 @@ public static class WebhookEndpoint
var invoke = registry.Lookup(workflowId, path);
if (invoke is null) return Results.NotFound();
// Read + parse body as JSON. If empty/non-JSON, hand the trigger an empty object.
JsonElement body;
using (var ms = new MemoryStream())
{
@@ -42,60 +43,71 @@ public static class WebhookEndpoint
}
}
}
var answerChannelId = Guid.NewGuid();
var wait = router.Register(answerChannelId, defaultTimeout);
await invoke(body, answerChannelId, ct);
var payload = await wait;
if (payload is null) return Results.NoContent();
return Results.Text(payload.Body, contentType: payload.ContentType, statusCode: payload.StatusCode);
return await FireAndWait(invoke, body, router, defaultTimeout, ct);
});
// Non-POST requests to the same path — without this, browsers clicking
// the webhook URL (GET) fall through to MapFallbackToFile("index.html")
// and land on the SPA instead of getting a useful hint.
// GET — browser-triggerable. Query string collapses into a JSON object
// where repeated keys become string arrays: /wh/wf/hi?a=1&b=x&b=y →
// {"a": "1", "b": ["x", "y"]}. No query string → {}.
app.MapGet("/wh/{workflowId}/{**path}", async (
string workflowId, string? path,
HttpRequest req,
WebhookRegistry registry, WebhookRouter router,
CancellationToken ct) =>
{
path ??= "";
var invoke = registry.Lookup(workflowId, path);
if (invoke is null) return Results.NotFound();
var body = QueryToJson(req.Query);
return await FireAndWait(invoke, body, router, defaultTimeout, ct);
});
// Non-POST/GET verbs — HEAD is a safe 200 empty so link previewers
// don't fire the workflow AND don't report a broken URL. Everything
// else gets a 405 JSON with Allow: POST, GET.
app.MapMethods(
"/wh/{workflowId}/{**path}",
new[] { "GET", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS" },
new[] { "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS" },
(string workflowId, string? path, HttpRequest req, HttpResponse res) =>
{
res.Headers.Allow = "POST";
res.Headers.Allow = "POST, GET";
if (HttpMethods.IsHead(req.Method)) return Results.Ok();
var origin = $"{req.Scheme}://{req.Host}";
var url = $"{origin}/wh/{workflowId}/{path ?? ""}";
if (HttpMethods.IsHead(req.Method))
return Results.StatusCode(405);
if (HttpMethods.IsGet(req.Method))
{
// $$""" so single {/} are literal (CSS + curl JSON) and {{url}} interpolates.
var html = $$"""
<!doctype html>
<html><head><meta charset="utf-8"><title>VolcanicCore webhook</title>
<style>
body { font-family: system-ui, sans-serif; background: #020617; color: #e2e8f0;
padding: 2rem; max-width: 40rem; margin: 0 auto; }
code { background: #1e293b; padding: 0.1rem 0.4rem; border-radius: 0.25rem;
font-family: ui-monospace, monospace; font-size: 0.85rem; word-break: break-all; }
pre { background: #1e293b; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
h1 { font-size: 1.1rem; color: #fbbf24; }
</style></head><body>
<h1>This is a POST-only webhook endpoint.</h1>
<p>You reached it with a browser (GET). To trigger the workflow, send a POST with a JSON body:</p>
<pre>curl -X POST -H 'content-type: application/json' \
-d '{"hello":"world"}' \
{{url}}</pre>
<p>The workflow must be <strong>active</strong> for this endpoint to respond;
an inactive workflow returns 404 on POST.</p>
</body></html>
""";
return Results.Content(html, contentType: "text/html; charset=utf-8", statusCode: 405);
}
return Results.Json(new
{
error = "method_not_allowed",
message = "This endpoint accepts POST only.",
message = "This endpoint accepts POST or GET only.",
endpoint = url,
}, statusCode: 405);
});
}
private static JsonElement QueryToJson(IQueryCollection query)
{
// Multi-value queries (?tag=a&tag=b) become string arrays; single
// values stay as strings; empty → "".
var obj = new Dictionary<string, object?>();
foreach (var kv in query)
{
if (kv.Value.Count > 1)
obj[kv.Key] = kv.Value.ToArray();
else
obj[kv.Key] = kv.Value.ToString();
}
return JsonSerializer.SerializeToElement(obj);
}
private static async Task<IResult> FireAndWait(
WebhookInvocation invoke, JsonElement body,
WebhookRouter router, TimeSpan defaultTimeout, CancellationToken ct)
{
var answerChannelId = Guid.NewGuid();
var wait = router.Register(answerChannelId, defaultTimeout);
await invoke(body, answerChannelId, ct);
var payload = await wait;
if (payload is null) return Results.NoContent();
return Results.Text(payload.Body, contentType: payload.ContentType, statusCode: payload.StatusCode);
}
}