diff --git a/src/VolcanicCore.Api/Endpoints/WebhookEndpoint.cs b/src/VolcanicCore.Api/Endpoints/WebhookEndpoint.cs index a32e40d..954c83c 100644 --- a/src/VolcanicCore.Api/Endpoints/WebhookEndpoint.cs +++ b/src/VolcanicCore.Api/Endpoints/WebhookEndpoint.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; -using VolcanicCore.Api.Engine; using VolcanicCore.Core; namespace VolcanicCore.Api.Endpoints; diff --git a/src/VolcanicCore.Api/Engine/WebhookRouter.cs b/src/VolcanicCore.Core/WebhookRouter.cs similarity index 97% rename from src/VolcanicCore.Api/Engine/WebhookRouter.cs rename to src/VolcanicCore.Core/WebhookRouter.cs index a25c7fb..0f7f5ac 100644 --- a/src/VolcanicCore.Api/Engine/WebhookRouter.cs +++ b/src/VolcanicCore.Core/WebhookRouter.cs @@ -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); diff --git a/src/VolcanicCore.Nodes/WebResponseNode.cs b/src/VolcanicCore.Nodes/WebResponseNode.cs new file mode 100644 index 0000000..f6df427 --- /dev/null +++ b/src/VolcanicCore.Nodes/WebResponseNode.cs @@ -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>> ExecuteAsync( + INodeContext ctx, IReadOnlyList> 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>>(Array.Empty>()); + } + + 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>>(Array.Empty>()); + } +} diff --git a/tests/VolcanicCore.Tests/WebResponseNodeTests.cs b/tests/VolcanicCore.Tests/WebResponseNodeTests.cs new file mode 100644 index 0000000..c19791e --- /dev/null +++ b/tests/VolcanicCore.Tests/WebResponseNodeTests.cs @@ -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 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[] { 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[] { Array.Empty() }, CancellationToken.None); + Assert.Empty(outputs); + } +} diff --git a/tests/VolcanicCore.Tests/WebhookEndpointTests.cs b/tests/VolcanicCore.Tests/WebhookEndpointTests.cs index f08d0e5..e5d460b 100644 --- a/tests/VolcanicCore.Tests/WebhookEndpointTests.cs +++ b/tests/VolcanicCore.Tests/WebhookEndpointTests.cs @@ -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; diff --git a/tests/VolcanicCore.Tests/WebhookRouterTests.cs b/tests/VolcanicCore.Tests/WebhookRouterTests.cs index 4aa3542..50716ad 100644 --- a/tests/VolcanicCore.Tests/WebhookRouterTests.cs +++ b/tests/VolcanicCore.Tests/WebhookRouterTests.cs @@ -1,4 +1,4 @@ -using VolcanicCore.Api.Engine; +using VolcanicCore.Core; namespace VolcanicCore.Tests;