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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user