feat(nodes): WebResponseNode — resolves webhook TCS with configured payload; move WebhookRouter to Core

This commit is contained in:
Claude
2026-08-28 15:20:10 +00:00
parent 3d7910c5e7
commit ebd06a6abc
6 changed files with 112 additions and 4 deletions

View File

@@ -1,6 +1,5 @@
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using VolcanicCore.Api.Engine;
using VolcanicCore.Core;
namespace VolcanicCore.Api.Endpoints;

View File

@@ -1,6 +1,6 @@
using System.Collections.Concurrent;
namespace VolcanicCore.Api.Engine;
namespace VolcanicCore.Core;
public sealed record WebResponsePayload(int StatusCode, string Body, string ContentType);

View File

@@ -0,0 +1,54 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using VolcanicCore.Core;
namespace VolcanicCore.Nodes;
public sealed class WebResponseNode : IActionNode
{
private readonly WebhookRouter _router;
public WebResponseNode(WebhookRouter router) => _router = router;
public string Id => "http.response";
public string DisplayName => "Web Response";
public NodeCategory Category => NodeCategory.Terminal;
public int InputCount => 1;
public int OutputCount => 0;
public string? ConnectionProviderId => null;
public JsonSchema ConfigSchema { get; } = new(JsonDocument.Parse("""
{
"type": "object",
"properties": {
"status": { "type": "integer", "title": "HTTP status", "default": 200 },
"body": { "type": "string", "title": "Body" },
"contentType": { "type": "string", "title": "Content-Type", "default": "application/json" }
}
}
""").RootElement);
public Task<IReadOnlyList<IReadOnlyList<Item>>> ExecuteAsync(
INodeContext ctx, IReadOnlyList<IReadOnlyList<Item>> inputsByPort, CancellationToken ct)
{
if (ctx.AnswerChannelId is not Guid channel)
{
ctx.Logger.LogWarning("WebResponse node {NodeId} has no answerChannelId in scope — no-op.", ctx.NodeId);
return Task.FromResult<IReadOnlyList<IReadOnlyList<Item>>>(Array.Empty<IReadOnlyList<Item>>());
}
var status = ctx.Config.TryGetProperty("status", out var s) && s.ValueKind == JsonValueKind.Number
? s.GetInt32() : 200;
var contentType = ctx.Config.TryGetProperty("contentType", out var ctype) && ctype.ValueKind == JsonValueKind.String
? ctype.GetString()! : "application/json";
string body;
if (ctx.Config.TryGetProperty("body", out var b) && b.ValueKind == JsonValueKind.String
&& !string.IsNullOrEmpty(b.GetString()))
body = b.GetString()!;
else if (inputsByPort[0].Count > 0)
body = inputsByPort[0][0].Json.GetRawText();
else
body = "";
_router.Resolve(channel, new WebResponsePayload(status, body, contentType));
return Task.FromResult<IReadOnlyList<IReadOnlyList<Item>>>(Array.Empty<IReadOnlyList<Item>>());
}
}

View File

@@ -0,0 +1,56 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using VolcanicCore.Api.Engine;
using VolcanicCore.Core;
using VolcanicCore.Nodes;
// ImplicitUsings pulls System.Threading → collision with our ExecutionContext.
using ExecutionContext = VolcanicCore.Api.Engine.ExecutionContext;
namespace VolcanicCore.Tests;
public class WebResponseNodeTests
{
private sealed class NullReg : IConnectionInstanceRegistry
{
public Task<object> GetAsync(string id, CancellationToken ct) => throw new InvalidOperationException();
}
[Fact]
public async Task Resolves_router_with_configured_status_and_body_and_returns_no_outputs()
{
var router = new WebhookRouter();
var channel = Guid.NewGuid();
var wait = router.Register(channel, TimeSpan.FromSeconds(5));
var node = new WebResponseNode(router);
var ctx = new ExecutionContext("e", "wf", "n",
JsonDocument.Parse("""{"status":201,"body":"created","contentType":"text/plain"}""").RootElement,
channel, NullLogger.Instance, null, new NullReg());
var outputs = await node.ExecuteAsync(ctx,
new IReadOnlyList<Item>[] { new [] { new Item(JsonDocument.Parse("{}").RootElement) } },
CancellationToken.None);
Assert.Empty(outputs);
var payload = await wait;
Assert.NotNull(payload);
Assert.Equal(201, payload!.StatusCode);
Assert.Equal("created", payload.Body);
Assert.Equal("text/plain", payload.ContentType);
}
[Fact]
public async Task Missing_answer_channel_is_a_noop_but_does_not_throw()
{
var router = new WebhookRouter();
var node = new WebResponseNode(router);
var ctx = new ExecutionContext("e", "wf", "n",
JsonDocument.Parse("""{"status":200,"body":""}""").RootElement,
answerChannelId: null, NullLogger.Instance, null, new NullReg());
var outputs = await node.ExecuteAsync(ctx,
new IReadOnlyList<Item>[] { Array.Empty<Item>() }, CancellationToken.None);
Assert.Empty(outputs);
}
}

View File

@@ -2,7 +2,6 @@ using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using VolcanicCore.Api.Engine;
using VolcanicCore.Core;
namespace VolcanicCore.Tests;

View File

@@ -1,4 +1,4 @@
using VolcanicCore.Api.Engine;
using VolcanicCore.Core;
namespace VolcanicCore.Tests;