244 lines
9.7 KiB
C#
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; }
|
|
}
|