w4c-workflows-api/Services/Nodes/Executors/HttpRequestNodeExecutor.Response.cs
2026-09-13 19:28:47 +03:00

231 lines
7.6 KiB
C#

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
using w4c_workflows.Models.Credentials;
using w4c_workflows.Models.Nodes;
using w4c_workflows.Services.Credentials;
using w4c_workflows.Services.Nodes.Binary;
using w4c_workflows.Services.Security;
namespace w4c_workflows.Services.Nodes.Executors;
// Response half of the HTTP Request node: maps a page body to output items,
// including the file/binary case and the envelope-unwrapping JSON path.
public sealed partial class HttpRequestNodeExecutor
{
// ------------------------------------------------------------------ response
private sealed record BuildResult(List<FlowItem> Items, string? Error);
/// <summary>
/// Maps one page's body to items. <c>responseFormat: file</c> is the only
/// asynchronous case: it stores the payload and emits a binary-backed item.
/// </summary>
private async Task<BuildResult> BuildItemsAsync(
PageResult page,
string responseFormat,
string? putOutputInField,
string? outputPath,
CancellationToken ct)
{
if (responseFormat == "file")
{
BinaryAttachment attachment;
try
{
await using var content = new MemoryStream(page.Bytes, writable: false);
attachment = await _binary.SaveAsync(content, page.SuggestedFileName, page.ContentType, ct);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is InvalidOperationException or IOException)
{
return new BuildResult(new List<FlowItem>(), ex.Message);
}
return new BuildResult(new List<FlowItem> { FileItem(attachment, putOutputInField) }, null);
}
if (!TryBuildItems(
page.Body, page.Parsed, responseFormat, putOutputInField, outputPath,
out var items, out var error))
{
return new BuildResult(new List<FlowItem>(), error);
}
return new BuildResult(items, null);
}
/// <summary>Builds a one-item result whose payload lives in the binary store.</summary>
private static FlowItem FileItem(BinaryAttachment attachment, string? putOutputInField)
{
var meta = new JsonObject
{
["fileName"] = attachment.FileName,
["mimeType"] = attachment.MimeType,
["sizeBytes"] = attachment.SizeBytes,
};
var extension = Path.GetExtension(attachment.FileName);
if (!string.IsNullOrEmpty(extension))
meta["fileExtension"] = extension.TrimStart('.');
return new FlowItem
{
Json = string.IsNullOrWhiteSpace(putOutputInField)
? meta
: new JsonObject { [putOutputInField!] = meta },
Binary = new Dictionary<string, BinaryAttachment>(StringComparer.Ordinal)
{
[DefaultBinaryProperty] = attachment,
},
};
}
/// <summary>
/// File name for a downloaded response: the Content-Disposition name, else the
/// last URL path segment, else null. Directory separators are stripped so a
/// provider cannot smuggle a path into the stored reference.
/// </summary>
private static string? ResolveFileName(HttpResponseMessage response, Uri requestUri)
{
try
{
var disposition = response.Content.Headers.ContentDisposition;
var name = TrimName(disposition?.FileNameStar ?? disposition?.FileName);
if (name != null)
return name;
}
catch (FormatException)
{
// Malformed Content-Disposition: fall through to the URL segment.
}
if (requestUri.Segments.Length > 0)
{
var name = TrimName(Uri.UnescapeDataString(requestUri.Segments[^1]));
if (name != null)
return name;
}
return null;
}
private static string? TrimName(string? name)
{
if (string.IsNullOrWhiteSpace(name))
return null;
var trimmed = name.Trim().Trim('"');
var separator = trimmed.LastIndexOfAny(new[] { '/', '\\' });
if (separator >= 0)
trimmed = trimmed[(separator + 1)..];
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
}
private static bool TryBuildItems(
string body,
JsonNode? parsed,
string responseFormat,
string? putOutputInField,
string? outputPath,
out List<FlowItem> items,
out string? error)
{
items = new List<FlowItem>();
error = null;
switch (responseFormat)
{
case "text":
items.Add(TextItem(body, putOutputInField));
return true;
case "json":
if (parsed == null)
{
error = "the response is not valid JSON";
return false;
}
break;
case "file":
// File responses are handled by BuildItemsAsync before this point.
error = "responseFormat 'file' must be built through the binary store";
return false;
default: // autodetect
if (parsed == null)
{
items.Add(TextItem(body, putOutputInField));
return true;
}
break;
}
// Envelope unwrapping: read the page's items from a JSON path so the
// cursor/next fields that live beside them do not become items.
if (!string.IsNullOrWhiteSpace(outputPath) && parsed is JsonObject envelope)
{
parsed = NodePath.Read(envelope, outputPath, dotNotation: true);
if (parsed == null)
return true; // a page without the data field simply contributes nothing
}
items.AddRange(ToItems(parsed, putOutputInField));
return true;
}
private static List<FlowItem> ToItems(JsonNode parsed, string? putOutputInField)
{
var items = new List<FlowItem>();
if (!string.IsNullOrWhiteSpace(putOutputInField))
{
if (parsed is JsonArray wrapped)
{
foreach (var element in wrapped)
items.Add(Wrap(new JsonObject { [putOutputInField] = element?.DeepClone() }));
}
else
{
items.Add(Wrap(new JsonObject { [putOutputInField] = parsed.DeepClone() }));
}
return items;
}
switch (parsed)
{
case JsonArray array:
foreach (var element in array)
{
if (element is JsonObject obj)
items.Add(Wrap(obj));
else
items.Add(Wrap(new JsonObject { ["value"] = element?.DeepClone() }));
}
break;
case JsonObject obj:
items.Add(Wrap(obj));
break;
default:
items.Add(Wrap(new JsonObject { ["value"] = parsed.DeepClone() }));
break;
}
return items;
}
private static FlowItem TextItem(string body, string? putOutputInField)
=> string.IsNullOrWhiteSpace(putOutputInField)
? Wrap(new JsonObject { ["data"] = body })
: Wrap(new JsonObject { [putOutputInField] = body });
private static FlowItem Wrap(JsonNode json)
=> new() { Json = json is JsonObject obj ? (JsonObject)obj.DeepClone() : new JsonObject { ["value"] = json } };
}