feat(api): POST /wh/{workflow-id}/{*path} endpoint

This commit is contained in:
Claude
2026-08-28 15:00:24 +00:00
parent c88d27294b
commit ff54b1f22d
4 changed files with 116 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using VolcanicCore.Api.Engine;
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);
});
}
}

View File

@@ -146,6 +146,7 @@ app.MapNodeTypesEndpoints();
app.MapConnectionProvidersEndpoints();
app.MapConnectionsEndpoints();
app.MapWorkflowsEndpoints();
app.MapWebhookEndpoint(TimeSpan.FromSeconds(settings.WebhookDefaultTimeoutSeconds));
app.MapGet("/api/health", () => Results.Ok(new { ok = true }));
app.Run();

View File

@@ -13,6 +13,7 @@ public class WebFactory : WebApplicationFactory<Program>
{
public string DataDir { get; } = Path.Combine(Path.GetTempPath(), $"vc-{Guid.NewGuid():N}");
public string AdminPassword { get; } = "hunter2";
public string WebhookTimeoutSeconds { get; } = "2";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
@@ -21,6 +22,7 @@ public class WebFactory : WebApplicationFactory<Program>
// Program.cs's Configuration.Bind picks these up without env vars.
builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.DataDirectory), DataDir);
builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.AdminPassword), AdminPassword);
builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.WebhookDefaultTimeoutSeconds), WebhookTimeoutSeconds);
}
protected override void Dispose(bool disposing)

View File

@@ -0,0 +1,58 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using VolcanicCore.Api.Engine;
namespace VolcanicCore.Tests;
public class WebhookEndpointTests : IClassFixture<WebFactory>
{
private readonly WebFactory _factory;
public WebhookEndpointTests(WebFactory f) => _factory = f;
[Fact]
public async Task Unknown_workflow_returns_404()
{
var c = _factory.CreateClient();
var res = await c.PostAsJsonAsync("/wh/does-not-exist/anything", new { });
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
}
[Fact]
public async Task Registered_callback_that_never_resolves_yields_204_after_execution_finishes()
{
var reg = _factory.Services.GetRequiredService<WebhookRegistry>();
var got = new TaskCompletionSource<(JsonElement body, Guid ch)>();
using var _ = reg.Register("wf-1", "hi", (body, id, ct) =>
{
got.SetResult((body.Clone(), id));
return Task.CompletedTask; // no WebResponse → router timeout → 204
});
var c = _factory.CreateClient();
var res = await c.PostAsJsonAsync("/wh/wf-1/hi", new { greeting = "hello" });
Assert.Equal(HttpStatusCode.NoContent, res.StatusCode);
var (body, ch) = await got.Task;
Assert.Equal("hello", body.GetProperty("greeting").GetString());
Assert.NotEqual(Guid.Empty, ch);
}
[Fact]
public async Task Registered_callback_that_resolves_yields_the_supplied_status_body()
{
var reg = _factory.Services.GetRequiredService<WebhookRegistry>();
var router = _factory.Services.GetRequiredService<WebhookRouter>();
using var _ = reg.Register("wf-2", "reply", (body, id, ct) =>
{
router.Resolve(id, new WebResponsePayload(201, "created!", "text/plain"));
return Task.CompletedTask;
});
var c = _factory.CreateClient();
var res = await c.PostAsJsonAsync("/wh/wf-2/reply", new { });
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
Assert.Equal("created!", await res.Content.ReadAsStringAsync());
}
}