w4c-workflows-api/Services/Nodes/Connectors/RestConnectorExecutor.cs
2026-09-12 01:02:46 +03:00

329 lines
12 KiB
C#

using System.Globalization;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.WebUtilities;
using w4c_workflows.Models.Nodes;
using w4c_workflows.Services.Nodes.Executors;
using w4c_workflows.Services.Security;
namespace w4c_workflows.Services.Nodes.Connectors;
/// <summary>
/// Runs a declarative REST connector blueprint. One instance is registered per
/// catalog entry that carries a <see cref="NodeConnector"/>, so the palette can
/// grow integrations as data (catalog JSON) without a new executor for each
/// service.
///
/// The connector URL is a template: <c>{name}</c> resolves from the node's
/// resolved parameters and <c>{credential.field}</c> from the credential the
/// blueprint references. Every request is vetted by <see cref="EgressGuard"/> and
/// consumes <see cref="NodeQuotaPolicy"/> exactly like the HTTP node, so a
/// connector cannot reach a blocked target or escape the request budget.
///
/// v1 scope: a single request per item with JSON/form bodies and dot-path
/// response extraction. Pagination, binary bodies and per-hop redirect vetting
/// stay with <c>core.httpRequest</c> for now; connectors that need them can call
/// it (or gain the behaviour here later).
/// </summary>
public sealed class RestConnectorExecutor : INodeExecutor
{
private const int DefaultTimeoutMs = 30_000;
private readonly NodeBlueprint _blueprint;
private readonly NodeConnector _connector;
private readonly IHttpClientFactory _http;
private readonly EgressGuard _egress;
private readonly NodeQuotaPolicy _quota;
public RestConnectorExecutor(
NodeBlueprint blueprint,
IHttpClientFactory http,
EgressGuard egress,
NodeQuotaPolicy quota)
{
_blueprint = blueprint;
_connector = blueprint.Connector
?? throw new InvalidOperationException($"blueprint '{blueprint.Type}' has no connector definition");
_http = http;
_egress = egress;
_quota = quota;
}
public string Type => _blueprint.Type;
public async Task<NodeExecutionOutcome> RunAsync(NodeExecutionContext context, CancellationToken ct)
{
if (!TryBuildUrl(context, out var url, out var urlError))
return NodeExecutionOutcome.Failed(urlError!, "invalid_connector_url");
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
return NodeExecutionOutcome.Failed($"'{url}' is not a valid absolute URL", "invalid_url");
var verdict = await _egress.AuthorizeAsync(uri, ct);
if (!verdict.Allowed)
return NodeExecutionOutcome.Failed(verdict.Reason ?? "blocked by the egress policy", "egress_blocked");
if (!_quota.TryReserveRequest(context.State, out var used))
{
return NodeExecutionOutcome.Failed(
$"outbound request quota exceeded ({_quota.MaxRequestsPerRun} per run)",
"quota_exceeded",
$"this run has already issued {used - 1} requests");
}
using var request = new HttpRequestMessage(new HttpMethod(_connector.Method.ToUpperInvariant()), uri);
var query = context.Parameters[_connector.QueryParameter ?? "query"] as JsonObject;
if (query is { Count: > 0 })
request.RequestUri = AppendQuery(request.RequestUri!, query);
var bodyError = ApplyBody(context, request);
if (bodyError != null)
return NodeExecutionOutcome.Failed(bodyError, "invalid_parameter");
var options = context.Parameters["options"] as JsonObject;
var neverError = ReadBool(options, "neverError");
var timeoutMs = ReadInt(options, "timeoutMs", DefaultTimeoutMs);
if (timeoutMs <= 0)
timeoutMs = DefaultTimeoutMs;
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(timeoutMs);
var client = _http.CreateClient(HttpRequestNodeExecutor.TypeName);
HttpResponseMessage response;
try
{
response = await client.SendAsync(request, timeout.Token);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
return NodeExecutionOutcome.Failed($"request timed out after {timeoutMs} ms", "timeout");
}
catch (HttpRequestException ex)
{
return NodeExecutionOutcome.Failed(ex.Message, "request_failed");
}
using (response)
{
var status = (int)response.StatusCode;
var text = await response.Content.ReadAsStringAsync(timeout.Token);
if (status >= 300 && !neverError)
{
return new NodeExecutionOutcome
{
Outputs = Array.Empty<IReadOnlyList<FlowItem>>(),
Failure = new NodeFailure($"HTTP {status}", "http_error", Truncate(text), status),
};
}
var parsed = TryParse(text);
if (parsed == null)
return NodeExecutionOutcome.Single(new List<FlowItem> { TextItem(text) });
if (!string.IsNullOrWhiteSpace(_connector.ResponsePath) && parsed is JsonObject envelope)
parsed = NodeJsonPath.Read(envelope, _connector.ResponsePath, dotNotation: true);
return NodeExecutionOutcome.Single(parsed == null ? new List<FlowItem>() : ToItems(parsed));
}
}
// ------------------------------------------------------------------ url
private bool TryBuildUrl(NodeExecutionContext context, out string url, out string? error)
{
url = string.Empty;
error = null;
var template = (_connector.BaseUrl ?? string.Empty) + (_connector.Path ?? string.Empty);
var builder = new StringBuilder(template.Length + 32);
for (var index = 0; index < template.Length; index++)
{
if (template[index] != '{')
{
builder.Append(template[index]);
continue;
}
var end = template.IndexOf('}', index + 1);
if (end < 0)
{
error = $"unbalanced placeholder in the connector URL '{template}'";
return false;
}
var token = template[(index + 1)..end];
if (!TryResolvePlaceholder(context, token, out var value))
{
error = $"connector URL placeholder '{{{token}}}' could not be resolved";
return false;
}
builder.Append(value);
index = end;
}
url = builder.ToString();
if (string.IsNullOrWhiteSpace(url))
{
error = "the connector URL is empty";
return false;
}
return true;
}
/// <summary>
/// Resolves one <c>{…}</c> token: <c>credential.field</c> from the resolved
/// credential, anything else from a parameter of that name.
/// </summary>
private bool TryResolvePlaceholder(NodeExecutionContext context, string token, out string value)
{
value = string.Empty;
if (token.StartsWith("credential.", StringComparison.Ordinal))
{
var field = token["credential.".Length..];
if (_connector.CredentialAlias == null
|| !context.Credentials.TryGetValue(_connector.CredentialAlias, out var credential))
{
return false;
}
if (credential.Data[field] is not JsonValue credentialValue
|| !credentialValue.TryGetValue<string>(out var secret))
{
return false;
}
value = secret;
return true;
}
if (context.Parameters[token] is not JsonValue parameterValue)
return false;
if (parameterValue.TryGetValue<string>(out var parameterText))
{
value = parameterText;
return true;
}
value = parameterValue.ToJsonString();
return true;
}
private static Uri AppendQuery(Uri uri, JsonObject query)
{
var result = uri.ToString();
foreach (var (name, node) in query)
result = QueryHelpers.AddQueryString(result, name, ScalarText(node));
return new Uri(result);
}
// ------------------------------------------------------------------ body
private string? ApplyBody(NodeExecutionContext context, HttpRequestMessage request)
{
if (string.Equals(_connector.ContentType, "none", StringComparison.OrdinalIgnoreCase))
return null;
var body = context.Parameters[_connector.BodyParameter ?? "body"];
if (body == null)
return null;
switch ((_connector.ContentType ?? "json").ToLowerInvariant())
{
case "json":
request.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
return null;
case "form":
if (body is not JsonObject form)
return "a form connector body must be a JSON object";
request.Content = new FormUrlEncodedContent(
form.Select(pair => new KeyValuePair<string, string>(pair.Key, ScalarText(pair.Value))));
return null;
default:
return $"unknown connector content type '{_connector.ContentType}'";
}
}
// ------------------------------------------------------------------ response
private static List<FlowItem> ToItems(JsonNode parsed)
{
var items = new List<FlowItem>();
if (parsed is JsonArray array)
{
foreach (var element in array)
items.Add(Wrap(element));
return items;
}
items.Add(Wrap(parsed));
return items;
}
private static FlowItem Wrap(JsonNode? node) => node switch
{
JsonObject obj => FlowItem.FromJson((JsonObject)obj.DeepClone()),
JsonArray array => FlowItem.FromJson(new JsonObject { ["value"] = array.DeepClone() }),
null => FlowItem.FromJson(new JsonObject()),
_ => FlowItem.FromJson(new JsonObject { ["value"] = node.DeepClone() }),
};
private static FlowItem TextItem(string text)
=> FlowItem.FromJson(new JsonObject { ["data"] = text });
private static JsonNode? TryParse(string body)
{
if (string.IsNullOrWhiteSpace(body))
return null;
try
{
return JsonNode.Parse(body);
}
catch (Exception)
{
return null;
}
}
private static string Truncate(string text, int max = 512)
=> text.Length <= max ? text : text[..max] + "…";
private static string ScalarText(JsonNode? node)
=> node switch
{
null => string.Empty,
JsonValue value when value.TryGetValue<string>(out var text) => text,
_ => node.ToJsonString(),
};
private static bool ReadBool(JsonObject? obj, string name)
=> obj?[name] is JsonValue value && value.TryGetValue<bool>(out var flag) && flag;
private static int ReadInt(JsonObject? obj, string name, int defaultValue)
{
if (obj?[name] is not JsonValue value)
return defaultValue;
if (value.TryGetValue<int>(out var intValue))
return intValue;
if (value.TryGetValue<long>(out var longValue))
return (int)longValue;
if (value.TryGetValue<string>(out var text)
&& int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
return parsed;
return defaultValue;
}
}