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 Items, string? Error); /// /// Maps one page's body to items. responseFormat: file is the only /// asynchronous case: it stores the payload and emits a binary-backed item. /// private async Task 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(), ex.Message); } return new BuildResult(new List { FileItem(attachment, putOutputInField) }, null); } if (!TryBuildItems( page.Body, page.Parsed, responseFormat, putOutputInField, outputPath, out var items, out var error)) { return new BuildResult(new List(), error); } return new BuildResult(items, null); } /// Builds a one-item result whose payload lives in the binary store. 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(StringComparer.Ordinal) { [DefaultBinaryProperty] = attachment, }, }; } /// /// 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. /// 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 items, out string? error) { items = new List(); 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 ToItems(JsonNode parsed, string? putOutputInField) { var items = new List(); 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 } }; }