feat(nodes): WebResponseNode — resolves webhook TCS with configured payload; move WebhookRouter to Core
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using VolcanicCore.Api.Engine;
|
||||
using VolcanicCore.Core;
|
||||
|
||||
namespace VolcanicCore.Api.Endpoints;
|
||||
|
||||
@@ -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);
|
||||
|
||||
54
src/VolcanicCore.Nodes/WebResponseNode.cs
Normal file
54
src/VolcanicCore.Nodes/WebResponseNode.cs
Normal 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>>());
|
||||
}
|
||||
}
|
||||
56
tests/VolcanicCore.Tests/WebResponseNodeTests.cs
Normal file
56
tests/VolcanicCore.Tests/WebResponseNodeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using VolcanicCore.Api.Engine;
|
||||
using VolcanicCore.Core;
|
||||
|
||||
namespace VolcanicCore.Tests;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user