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

244 lines
9.7 KiB
C#

using System.Globalization;
using System.Text.Json.Nodes;
namespace w4c_workflows.Services.Nodes.Executors;
/// <summary>
/// Declarative pagination for the HTTP Request node (and, later, the REST
/// connector executor). Instead of free-form expressions it reads the next
/// cursor / next URL from a JSON path into the previous response, which keeps
/// pagination fully data-driven and safe to validate up front.
///
/// Three styles are supported (our own naming, not a one-to-one copy of any
/// reference implementation):
/// - <c>nextUrl</c> — the response carries the absolute/relative URL of the
/// next page;
/// - <c>cursor</c> — the response carries an opaque cursor that is fed
/// back into a query/header/body parameter;
/// - <c>pageNumber</c> — a numeric query/header/body parameter is incremented
/// on every page.
///
/// Every style shares the same guards: a max page count, a max item count,
/// "stop on an empty page" and a set of status codes that terminate the loop.
/// </summary>
internal sealed class HttpPaginationPlan
{
public const string ModeOff = "off";
public const string ModePageNumber = "pageNumber";
public const string PlaceQuery = "query";
public const string PlaceHeader = "header";
public const string PlaceBody = "body";
private const int DefaultMaxPages = 50;
private const int DefaultMaxItems = 1000;
public required string Mode { get; init; }
/// <summary>Dot path into the response body that holds the next URL.</summary>
public string? NextUrlPath { get; init; }
/// <summary>Dot path into the response body that holds the next cursor.</summary>
public string? CursorPath { get; init; }
public string CursorPlace { get; init; } = PlaceQuery;
public string CursorName { get; init; } = string.Empty;
public string PagePlace { get; init; } = PlaceQuery;
public string PageName { get; init; } = string.Empty;
public int PageStart { get; init; } = 1;
public bool StopOnEmptyResponse { get; init; } = true;
public IReadOnlySet<int> StopStatusCodes { get; init; } = new HashSet<int>();
/// <summary>Hard cap on the number of requests; 0 disables the cap.</summary>
public int MaxPages { get; init; } = DefaultMaxPages;
/// <summary>Hard cap on the accumulated items; 0 disables the cap.</summary>
public int MaxItems { get; init; } = DefaultMaxItems;
/// <summary>
/// Parses the <c>options.pagination</c> object. Returns null when pagination
/// is absent or disabled, so the caller keeps the plain single-request path.
/// </summary>
public static HttpPaginationPlan? From(JsonObject options)
{
if (options["pagination"] is not JsonObject config)
return null;
var mode = NodeValueAccess.ReadString(config, "mode");
if (string.IsNullOrWhiteSpace(mode) ||
string.Equals(mode, ModeOff, StringComparison.OrdinalIgnoreCase))
{
return null;
}
var plan = new HttpPaginationPlan
{
Mode = mode,
NextUrlPath = Trimmed(NodeValueAccess.ReadString(config, "nextUrlPath")),
CursorPath = Trimmed(NodeValueAccess.ReadString(config, "cursorPath")),
CursorPlace = Place(config, "cursorPlace"),
CursorName = NodeValueAccess.ReadString(config, "cursorName") ?? string.Empty,
PagePlace = Place(config, "pagePlace"),
PageName = NodeValueAccess.ReadString(config, "pageName") ?? string.Empty,
PageStart = NodeValueAccess.ReadInt(config, "pageStart", 1),
StopOnEmptyResponse = NodeValueAccess.ReadBool(config, "stopOnEmptyResponse", true),
StopStatusCodes = ParseStatusCodes(config["stopStatusCodes"]),
MaxPages = Math.Max(0, NodeValueAccess.ReadInt(config, "maxPages", DefaultMaxPages)),
MaxItems = Math.Max(0, NodeValueAccess.ReadInt(config, "maxItems", DefaultMaxItems)),
};
return plan.Validate();
}
private HttpPaginationPlan Validate()
{
switch (Mode.ToLowerInvariant())
{
case "nexturl" when string.IsNullOrEmpty(NextUrlPath):
throw new InvalidOperationException("pagination 'nextUrl' requires nextUrlPath");
case "cursor" when string.IsNullOrEmpty(CursorPath):
throw new InvalidOperationException("pagination 'cursor' requires cursorPath");
case "cursor" when string.IsNullOrEmpty(CursorName):
throw new InvalidOperationException("pagination 'cursor' requires cursorName");
case "pagenumber" when string.IsNullOrEmpty(PageName):
throw new InvalidOperationException("pagination 'pageNumber' requires pageName");
case "nexturl" or "cursor" or "pagenumber":
return this;
default:
throw new InvalidOperationException($"unknown pagination mode '{Mode}'");
}
}
/// <summary>True when the page's status code terminates pagination.</summary>
public bool IsStopStatus(int status) => StopStatusCodes.Contains(status);
/// <summary>True when the page's item count terminates pagination.</summary>
public bool IsStopPage(int pageItemCount) => StopOnEmptyResponse && pageItemCount == 0;
/// <summary>True when the request cap has been reached.</summary>
public bool IsPageLimitReached(int page) => MaxPages > 0 && page >= MaxPages;
/// <summary>
/// Computes the state for the next request. Returns null — "stop" — when the
/// response carries no next URL/cursor, so a provider that signals the end by
/// omitting the token needs no separate termination setting.
/// </summary>
public HttpPaginationStep? TryNext(JsonNode? response, ref int pageNumber)
{
switch (Mode.ToLowerInvariant())
{
case "nexturl":
{
if (response is not JsonObject body)
return null;
var value = NodeJsonPath.Read(body, NextUrlPath, dotNotation: true);
return TryScalar(value, out var nextUrl)
? new HttpPaginationStep { NextUrl = nextUrl }
: null;
}
case "cursor":
{
if (response is not JsonObject body)
return null;
var value = NodeJsonPath.Read(body, CursorPath, dotNotation: true);
return TryScalar(value, out var cursor)
? new HttpPaginationStep { Cursor = cursor }
: null;
}
case "pagenumber":
pageNumber++;
return new HttpPaginationStep { PageNumber = pageNumber };
default:
return null;
}
}
/// <summary>Applies the page-start value before the first request.</summary>
public HttpPaginationStep? InitialStep()
=> string.Equals(Mode, ModePageNumber, StringComparison.OrdinalIgnoreCase)
? new HttpPaginationStep { PageNumber = PageStart }
: null;
// ------------------------------------------------------------------ parsing
private static string? Trimmed(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string Place(JsonObject config, string name)
{
var place = NodeValueAccess.ReadString(config, name);
return place?.ToLowerInvariant() switch
{
PlaceHeader => PlaceHeader,
PlaceBody => PlaceBody,
_ => PlaceQuery,
};
}
private static IReadOnlySet<int> ParseStatusCodes(JsonNode? node)
{
var codes = new HashSet<int>();
switch (node)
{
case JsonArray array:
foreach (var entry in array)
{
if (NodeValueAccess.TryReadNumber(entry, out var number))
codes.Add((int)number);
}
break;
case JsonValue value when value.TryGetValue<string>(out var text):
foreach (var part in text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var code))
codes.Add(code);
}
break;
}
return codes;
}
/// <summary>Renders a JSON scalar to text; null/empty/objects/arrays are "stop".</summary>
private static bool TryScalar(JsonNode? node, out string value)
{
value = string.Empty;
switch (node)
{
case null:
return false;
case JsonValue text when text.TryGetValue<string>(out var s):
value = s;
return !string.IsNullOrEmpty(value);
case JsonValue number when NodeValueAccess.TryReadNumber(number, out var n):
value = n.ToString(CultureInfo.InvariantCulture);
return true;
case JsonValue boolean when boolean.TryGetValue<bool>(out var b):
value = b ? "true" : "false";
return true;
default:
return false;
}
}
}
/// <summary>How the next request differs from the current one.</summary>
internal sealed record HttpPaginationStep
{
/// <summary>Absolute or relative URL of the next page (<c>nextUrl</c> mode).</summary>
public string? NextUrl { get; init; }
/// <summary>Opaque cursor fed back into a parameter (<c>cursor</c> mode).</summary>
public string? Cursor { get; init; }
/// <summary>Numeric page value fed back into a parameter (<c>pageNumber</c> mode).</summary>
public int? PageNumber { get; init; }
}