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:
@@ -105,9 +105,12 @@ function WebhookUrlHint({
|
||||
{copied ? <><Check size={12} /> Copied</> : <><Copy size={12} /> Copy</>}
|
||||
</button>
|
||||
</div>
|
||||
<code className="block break-all text-xs text-slate-200 font-mono">
|
||||
POST {url}
|
||||
</code>
|
||||
<code className="block break-all text-xs text-slate-200 font-mono">{url}</code>
|
||||
<p className="text-xs text-slate-400">
|
||||
Accepts <code className="text-slate-200">POST</code> (JSON body) or{" "}
|
||||
<code className="text-slate-200">GET</code> (query params →{" "}
|
||||
<code className="text-slate-200">{"{a:'1',…}"}</code>). Click the URL to trigger from the browser.
|
||||
</p>
|
||||
{!path && (
|
||||
<p className="text-xs text-amber-400">Set a URL path above to complete the endpoint.</p>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,19 +20,82 @@ public class WebhookEndpointTests : IClassFixture<WebFactory>
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_on_webhook_url_returns_405_html_helper_not_spa_fallback()
|
||||
public async Task Get_on_unknown_webhook_returns_404_like_post()
|
||||
{
|
||||
var c = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
var res = await c.GetAsync("/wh/some-workflow/hook");
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, res.StatusCode);
|
||||
Assert.Equal("POST", res.Content.Headers.Allow.ToString());
|
||||
Assert.Contains("text/html", res.Content.Headers.ContentType?.ToString() ?? "");
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
Assert.Contains("POST-only webhook endpoint", body);
|
||||
Assert.Contains("/wh/some-workflow/hook", body);
|
||||
// Sanity: it must NOT be the SPA index.html (that would say "VolcanicCore" in <title>
|
||||
// without any mention of "POST-only" or "webhook").
|
||||
Assert.DoesNotContain("id=\"root\"", body);
|
||||
var res = await c.GetAsync("/wh/does-not-exist/hook");
|
||||
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_on_webhook_triggers_workflow_with_query_as_json_body()
|
||||
{
|
||||
var reg = _factory.Services.GetRequiredService<WebhookRegistry>();
|
||||
var got = new TaskCompletionSource<JsonElement>();
|
||||
using var _ = reg.Register("wf-get-1", "hi", (body, _id, _ct) =>
|
||||
{
|
||||
got.SetResult(body.Clone());
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var c = _factory.CreateClient();
|
||||
var res = await c.GetAsync("/wh/wf-get-1/hi?a=1&name=alice");
|
||||
Assert.Equal(HttpStatusCode.NoContent, res.StatusCode);
|
||||
|
||||
var body = await got.Task;
|
||||
Assert.Equal("1", body.GetProperty("a").GetString());
|
||||
Assert.Equal("alice", body.GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_with_repeated_query_key_yields_string_array()
|
||||
{
|
||||
var reg = _factory.Services.GetRequiredService<WebhookRegistry>();
|
||||
var got = new TaskCompletionSource<JsonElement>();
|
||||
using var _ = reg.Register("wf-get-2", "tags", (body, _id, _ct) =>
|
||||
{
|
||||
got.SetResult(body.Clone());
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var c = _factory.CreateClient();
|
||||
var res = await c.GetAsync("/wh/wf-get-2/tags?tag=a&tag=b&only=x");
|
||||
Assert.Equal(HttpStatusCode.NoContent, res.StatusCode);
|
||||
|
||||
var body = await got.Task;
|
||||
var tags = body.GetProperty("tag").EnumerateArray().Select(e => e.GetString()).ToList();
|
||||
Assert.Equal(new[] { "a", "b" }, tags);
|
||||
Assert.Equal("x", body.GetProperty("only").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_with_no_query_string_triggers_with_empty_object()
|
||||
{
|
||||
var reg = _factory.Services.GetRequiredService<WebhookRegistry>();
|
||||
var got = new TaskCompletionSource<JsonElement>();
|
||||
using var _ = reg.Register("wf-get-3", "noargs", (body, _id, _ct) =>
|
||||
{
|
||||
got.SetResult(body.Clone());
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
var c = _factory.CreateClient();
|
||||
var res = await c.GetAsync("/wh/wf-get-3/noargs");
|
||||
Assert.Equal(HttpStatusCode.NoContent, res.StatusCode);
|
||||
|
||||
var body = await got.Task;
|
||||
Assert.Equal(JsonValueKind.Object, body.ValueKind);
|
||||
Assert.Empty(body.EnumerateObject());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Head_on_webhook_returns_200_empty_to_be_safe_for_link_previewers()
|
||||
{
|
||||
// Slack/Discord/etc issue HEAD on links to unfurl them. That MUST NOT
|
||||
// fire the workflow, but also shouldn't look like a broken URL.
|
||||
var c = _factory.CreateClient();
|
||||
var res = await c.SendAsync(new HttpRequestMessage(HttpMethod.Head, "/wh/wf-head/hi"));
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -42,7 +105,7 @@ public class WebhookEndpointTests : IClassFixture<WebFactory>
|
||||
var res = await c.PutAsync("/wh/some-workflow/hook",
|
||||
new StringContent("{}", System.Text.Encoding.UTF8, "application/json"));
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, res.StatusCode);
|
||||
Assert.Equal("POST", res.Content.Headers.Allow.ToString());
|
||||
Assert.Equal("POST, GET", res.Content.Headers.Allow.ToString());
|
||||
var body = await res.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("method_not_allowed", body.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user