feat(api): POST /wh/{workflow-id}/{*path} endpoint
This commit is contained in:
55
src/VolcanicCore.Api/Endpoints/WebhookEndpoint.cs
Normal file
55
src/VolcanicCore.Api/Endpoints/WebhookEndpoint.cs
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,6 +146,7 @@ app.MapNodeTypesEndpoints();
|
|||||||
app.MapConnectionProvidersEndpoints();
|
app.MapConnectionProvidersEndpoints();
|
||||||
app.MapConnectionsEndpoints();
|
app.MapConnectionsEndpoints();
|
||||||
app.MapWorkflowsEndpoints();
|
app.MapWorkflowsEndpoints();
|
||||||
|
app.MapWebhookEndpoint(TimeSpan.FromSeconds(settings.WebhookDefaultTimeoutSeconds));
|
||||||
|
|
||||||
app.MapGet("/api/health", () => Results.Ok(new { ok = true }));
|
app.MapGet("/api/health", () => Results.Ok(new { ok = true }));
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class WebFactory : WebApplicationFactory<Program>
|
|||||||
{
|
{
|
||||||
public string DataDir { get; } = Path.Combine(Path.GetTempPath(), $"vc-{Guid.NewGuid():N}");
|
public string DataDir { get; } = Path.Combine(Path.GetTempPath(), $"vc-{Guid.NewGuid():N}");
|
||||||
public string AdminPassword { get; } = "hunter2";
|
public string AdminPassword { get; } = "hunter2";
|
||||||
|
public string WebhookTimeoutSeconds { get; } = "2";
|
||||||
|
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
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.
|
// 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.DataDirectory), DataDir);
|
||||||
builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.AdminPassword), AdminPassword);
|
builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.AdminPassword), AdminPassword);
|
||||||
|
builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.WebhookDefaultTimeoutSeconds), WebhookTimeoutSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
|
|||||||
58
tests/VolcanicCore.Tests/WebhookEndpointTests.cs
Normal file
58
tests/VolcanicCore.Tests/WebhookEndpointTests.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user