refactor(audit): consolidate query-param parsers; widen CLI export to multi-value

This commit is contained in:
Joseph Doherty
2026-05-21 05:37:06 -04:00
parent 2a76be1f94
commit f64a7aed02
9 changed files with 350 additions and 177 deletions

View File

@@ -128,10 +128,36 @@ public static class AuditCommands
var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true };
formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
var channelOption = new Option<string?>("--channel") { Description = "Filter by channel" };
var kindOption = new Option<string?>("--kind") { Description = "Filter by event kind" };
var statusOption = new Option<string?>("--status") { Description = "Filter by status" };
var siteOption = new Option<string?>("--site") { Description = "Filter by source site ID" };
// --channel/--kind/--status/--site are multi-valued — same shape as the
// `query` subcommand: repeated tokens (--channel A --channel B) and, with
// AllowMultipleArgumentsPerToken, a single token carrying several values
// (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
var channelOption = new Option<string[]>("--channel")
{
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
AllowMultipleArgumentsPerToken = true,
};
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
var kindOption = new Option<string[]>("--kind")
{
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable",
AllowMultipleArgumentsPerToken = true,
};
kindOption.AcceptOnlyFromAmong(
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
var statusOption = new Option<string[]>("--status")
{
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable",
AllowMultipleArgumentsPerToken = true,
};
statusOption.AcceptOnlyFromAmong(
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
var siteOption = new Option<string[]>("--site")
{
Description = "Filter by source site ID; repeatable",
AllowMultipleArgumentsPerToken = true,
};
var targetOption = new Option<string?>("--target") { Description = "Filter by target" };
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
@@ -162,10 +188,10 @@ public static class AuditCommands
Until = result.GetValue(untilOption)!,
Format = result.GetValue(formatExportOption)!,
Output = result.GetValue(outputOption)!,
Channel = result.GetValue(channelOption),
Kind = result.GetValue(kindOption),
Status = result.GetValue(statusOption),
Site = result.GetValue(siteOption),
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
Target = result.GetValue(targetOption),
Actor = result.GetValue(actorOption),
};

View File

@@ -6,6 +6,10 @@ namespace ScadaLink.CLI.Commands;
/// <summary>
/// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the
/// Bundle B <c>GET /api/audit/export</c> parameters.
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
/// are multi-valued — each supplied value becomes a repeated query-string param so
/// the server's multi-value <c>IN (…)</c> filter sees the full set, exactly like
/// the <c>audit query</c> subcommand.
/// </summary>
public sealed class AuditExportArgs
{
@@ -13,10 +17,10 @@ public sealed class AuditExportArgs
public string Until { get; set; } = string.Empty;
public string Format { get; set; } = string.Empty;
public string Output { get; set; } = string.Empty;
public string? Channel { get; set; }
public string? Kind { get; set; }
public string? Status { get; set; }
public string? Site { get; set; }
public string[] Channel { get; set; } = Array.Empty<string>();
public string[] Kind { get; set; } = Array.Empty<string>();
public string[] Status { get; set; } = Array.Empty<string>();
public string[] Site { get; set; } = Array.Empty<string>();
public string? Target { get; set; }
public string? Actor { get; set; }
}
@@ -31,7 +35,11 @@ public static class AuditExportHelpers
/// <summary>
/// Builds the <c>?...</c> query string for <c>GET /api/audit/export</c>: the required
/// time window + format, plus optional filters. Time-specs are resolved via
/// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>.
/// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>. The multi-valued
/// <c>--channel</c>/<c>--kind</c>/<c>--status</c>/<c>--site</c> filters each emit ONE
/// repeated query-string key per value (e.g. <c>channel=A&amp;channel=B</c>) so the
/// server's multi-value <c>IN (…)</c> filter receives the full set — mirroring
/// <see cref="AuditQueryHelpers.BuildQueryString"/>.
/// </summary>
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
{
@@ -43,13 +51,21 @@ public static class AuditExportHelpers
parts.Add($"{key}={Uri.EscapeDataString(value)}");
}
void AddEach(string key, IReadOnlyList<string> values)
{
foreach (var value in values)
{
Add(key, value);
}
}
Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture));
Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture));
Add("format", args.Format);
Add("channel", args.Channel);
Add("kind", args.Kind);
Add("status", args.Status);
Add("sourceSiteId", args.Site);
AddEach("channel", args.Channel);
AddEach("kind", args.Kind);
AddEach("status", args.Status);
AddEach("sourceSiteId", args.Site);
Add("target", args.Target);
Add("actor", args.Actor);