329 lines
12 KiB
C#
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;
|
|
}
|
|
}
|