Previously a browser click on the webhook URL fell through the POST-only
handler to MapFallbackToFile('index.html'), landing the user on the
workflows page and looking like a bug. Now non-POST verbs on the same
route return 405 with Allow: POST — GETs render a small HTML page showing
the correct curl invocation, other methods get JSON. Two new xUnit tests
cover the HTML and JSON branches and assert we don't hand back the SPA.
102 lines
4.7 KiB
C#
102 lines
4.7 KiB
C#
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Http;
|
|
using VolcanicCore.Core;
|
|
|
|
namespace VolcanicCore.Api.Endpoints;
|
|
|
|
public static class WebhookEndpoint
|
|
{
|
|
public static void MapWebhookEndpoint(this IEndpointRouteBuilder app, TimeSpan defaultTimeout)
|
|
{
|
|
app.MapPost("/wh/{workflowId}/{**path}", async (
|
|
string workflowId, string? path,
|
|
HttpRequest req, HttpResponse res,
|
|
WebhookRegistry registry, WebhookRouter router,
|
|
CancellationToken ct) =>
|
|
{
|
|
path ??= "";
|
|
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())
|
|
{
|
|
await req.Body.CopyToAsync(ms, ct);
|
|
if (ms.Length == 0)
|
|
body = JsonDocument.Parse("{}").RootElement.Clone();
|
|
else
|
|
{
|
|
ms.Position = 0;
|
|
try
|
|
{
|
|
using var doc = await JsonDocument.ParseAsync(ms, cancellationToken: ct);
|
|
body = doc.RootElement.Clone();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
ms.Position = 0;
|
|
using var sr = new StreamReader(ms);
|
|
var raw = await sr.ReadToEndAsync(ct);
|
|
body = JsonSerializer.SerializeToElement(new { raw });
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
// 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.
|
|
app.MapMethods(
|
|
"/wh/{workflowId}/{**path}",
|
|
new[] { "GET", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS" },
|
|
(string workflowId, string? path, HttpRequest req, HttpResponse res) =>
|
|
{
|
|
res.Headers.Allow = "POST";
|
|
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.",
|
|
endpoint = url,
|
|
}, statusCode: 405);
|
|
});
|
|
}
|
|
}
|