diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs
index f4fa401b..6461b673 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs
@@ -726,17 +726,6 @@ public class DeviceJourneyAndFtrTests
Assert.Equal(3, totals.ExcludedSessions);
}
- [Theory]
- [InlineData(null, 30)] // default window
- [InlineData("garbage", 30)]
- [InlineData("7", 7)]
- [InlineData("0", 1)] // clamped low
- [InlineData("999", 180)] // clamped to aggregate retention
- public void ClampDays_DefaultsAndClamps(string? raw, int expected)
- {
- Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw));
- }
-
[Fact]
public void InclusiveWindowStart_YieldsExactlyNCalendarDays()
{
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamGuardTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamGuardTests.cs
new file mode 100644
index 00000000..94d2c6cb
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamGuardTests.cs
@@ -0,0 +1,88 @@
+using System.Text.RegularExpressions;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Ratchet: HTTP query parameters are parsed through Helpers/QueryParams.cs only. A
+/// int/long/DateTime/DateTimeOffset.TryParse under Functions/ or Pagination/
+/// is a new private parser copy — the state the 2026-09-04 API audit found six times over for
+/// days alone, each with its own default, cap and out-of-range rule. The baseline lists
+/// the sites that parse something other than a query parameter (a header, a stored cell, a
+/// version string); it only shrinks.
+///
+public class QueryParamGuardTests
+{
+ private static readonly Regex TryParseCall = new(
+ @"\b(int|long|DateTime|DateTimeOffset)\.TryParse(Exact)?\(", RegexOptions.Compiled);
+
+ /// Non-query parses, reviewed: file → why it is allowed.
+ private static readonly IReadOnlyDictionary Baseline = new Dictionary(StringComparer.Ordinal)
+ {
+ ["Functions/Config/GetAgentConfigFunction.cs"] = "major of the X-Agent-Version header",
+ ["Functions/Ingest/IngestTelemetryFunction.cs"] = "X-Send-Time-Utc header (RoundtripKind, deliberately not a query instant)",
+ ["Functions/Raw/QueryRawEventsFunction.cs"] = "Severity / Sequence cells of raw table rows",
+ ["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "se= expiry inside the tenant's stored SAS URL",
+ // Content-Length header guard on body-taking routes (long.TryParse on the header value).
+ ["Functions/Admin/SubmitOffboardingFeedbackFunction.cs"] = "Content-Length header",
+ ["Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = "Content-Length header",
+ ["Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = "Content-Length header",
+ ["Functions/Config/UpdateAdminConfigurationFunction.cs"] = "Content-Length header",
+ ["Functions/Config/UpdateTenantConfigurationFunction.cs"] = "Content-Length header",
+ ["Functions/Feedback/FeedbackFunction.cs"] = "Content-Length header",
+ ["Functions/Infrastructure/SignalRAddToGroupFunction.cs"] = "Content-Length header",
+ ["Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs"] = "Content-Length header",
+ ["Functions/Ingest/ReportAgentErrorFunction.cs"] = "Content-Length header",
+ ["Functions/Ingest/ReportDistressFunction.cs"] = "Content-Length header",
+ ["Functions/Reports/SubmitDiagFilesReportFunction.cs"] = "Content-Length header",
+ ["Functions/Reports/SubmitSessionReportFunction.cs"] = "Content-Length header",
+ ["Functions/Rules/AnalyzeRulesFunction.cs"] = "Content-Length header",
+ ["Functions/Rules/DryRunAnalyzeRuleFunction.cs"] = "Content-Length header",
+ ["Functions/Rules/GatherRulesFunction.cs"] = "Content-Length header",
+ ["Functions/Rules/GlobalRulesFunction.cs"] = "Content-Length header",
+ ["Functions/Rules/ImeLogPatternsFunction.cs"] = "Content-Length header",
+ ["Functions/Rules/TestLogPatternFunction.cs"] = "Content-Length header",
+ ["Functions/Sessions/RegisterSessionFunction.cs"] = "Content-Length header",
+ };
+
+ private static string FunctionsRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
+ dir = dir.Parent;
+ Assert.NotNull(dir);
+ return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
+ }
+
+ private static IEnumerable ScannedFiles(string root) =>
+ new[] { "Functions", "Pagination" }
+ .SelectMany(sub => Directory.EnumerateFiles(Path.Combine(root, sub), "*.cs", SearchOption.AllDirectories))
+ .Select(p => Path.GetRelativePath(root, p).Replace('\\', '/'))
+ .OrderBy(p => p, StringComparer.Ordinal);
+
+ [Fact]
+ public void Query_parameters_are_parsed_through_QueryParams_only()
+ {
+ var root = FunctionsRoot();
+ var offenders = new List();
+ var seen = new HashSet(StringComparer.Ordinal);
+
+ foreach (var rel in ScannedFiles(root))
+ {
+ var source = File.ReadAllText(Path.Combine(root, rel));
+ if (!TryParseCall.IsMatch(source)) continue;
+ seen.Add(rel);
+ if (!Baseline.ContainsKey(rel)) offenders.Add(rel);
+ }
+
+ Assert.True(offenders.Count == 0,
+ "TryParse outside Helpers/QueryParams.cs — parse query parameters through QueryParams " +
+ "(Int / TryInt / TryPageSize / UtcInstant / TryUtcInstant), or add the file to the baseline " +
+ "with the non-query reason:\n " + string.Join("\n ", offenders));
+
+ var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
+ Assert.True(stale.Count == 0,
+ "Baseline entries without a TryParse any more — remove them (the ratchet only shrinks):\n " +
+ string.Join("\n ", stale));
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamsTests.cs
new file mode 100644
index 00000000..275931e1
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamsTests.cs
@@ -0,0 +1,192 @@
+using System.Globalization;
+using AutopilotMonitor.Functions.Helpers;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// The one query-parameter parser (). The lenient theories carry the
+/// cases the former per-function copies pinned (RuleHitSessions, DeviceJourney, VerdictCalibration,
+/// the Apps reject-to-default variant) so their behaviour is decided once, here: garbage and
+/// below-minimum mean default, above-maximum clamps. The strict theories pin the 400 messages
+/// the paginators and admin routes return.
+///
+public class QueryParamsTests
+{
+ private static readonly DateTime Occurred = new(2026, 8, 29, 20, 11, 0, DateTimeKind.Utc);
+
+ // ── Lenient ────────────────────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(null, 14)]
+ [InlineData("", 14)]
+ [InlineData(" ", 14)]
+ [InlineData("garbage", 14)]
+ [InlineData("14", 14)]
+ [InlineData("1", 1)]
+ [InlineData("0", 14)] // below min ⇒ default (the old ClampDays copies answered 1)
+ [InlineData("-5", 14)]
+ [InlineData("90", 90)]
+ [InlineData("365", 90)] // above max ⇒ clamped (the old Apps copies answered the default)
+ [InlineData("2147483648", 14)] // overflow ⇒ default
+ public void Int_defaults_below_min_and_clamps_above_max(string? raw, int expected)
+ {
+ Assert.Equal(expected, QueryParams.Int(raw, @default: 14, min: 1, max: 90));
+ }
+
+ [Fact]
+ public void Int_reads_the_named_parameter_from_a_query_collection()
+ {
+ var query = System.Web.HttpUtility.ParseQueryString("days=7&limit=abc");
+ Assert.Equal(7, QueryParams.Int(query, "days", 30, 1, 365));
+ Assert.Equal(100, QueryParams.Int(query, "limit", 100, 1, 2000));
+ Assert.Equal(30, QueryParams.Int((System.Collections.Specialized.NameValueCollection?)null, "days", 30, 1, 365));
+ }
+
+ [Fact]
+ public void Int_is_culture_invariant()
+ {
+ var previous = CultureInfo.CurrentCulture;
+ try
+ {
+ CultureInfo.CurrentCulture = new CultureInfo("de-DE");
+ Assert.Equal(1000, QueryParams.Int("1000", 1, 1, 5000));
+ Assert.Equal(1, QueryParams.Int("1.000", 1, 1, 5000)); // thousands separator is not a number
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = previous;
+ }
+ }
+
+ [Theory]
+ [InlineData(null, null)]
+ [InlineData("", null)]
+ [InlineData("x", null)]
+ [InlineData("-3", -3)]
+ [InlineData("12", 12)]
+ public void IntOrNull_has_no_range(string? raw, int? expected)
+ {
+ Assert.Equal(expected, QueryParams.IntOrNull(raw));
+ }
+
+ [Fact]
+ public void UtcInstant_is_null_for_absent_or_garbage_and_utc_otherwise()
+ {
+ Assert.Null(QueryParams.UtcInstant(null));
+ Assert.Null(QueryParams.UtcInstant(" "));
+ Assert.Null(QueryParams.UtcInstant("yesterday"));
+ var parsed = QueryParams.UtcInstant("2026-08-29T22:11:00+02:00");
+ Assert.Equal(Occurred, parsed);
+ Assert.Equal(DateTimeKind.Utc, parsed!.Value.Kind);
+ }
+
+ // ── Strict ─────────────────────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void TryInt_absent_is_null_and_ok(string? raw)
+ {
+ Assert.True(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error));
+ Assert.Null(value);
+ Assert.Null(error);
+ }
+
+ [Theory]
+ [InlineData("banana", "days must be an integer")]
+ [InlineData("1.5", "days must be an integer")]
+ [InlineData("0", "days must be between 1 and 365")]
+ [InlineData("-1", "days must be between 1 and 365")]
+ [InlineData("366", "days must be between 1 and 365")]
+ public void TryInt_rejects_garbage_and_out_of_range_with_a_named_message(string raw, string expectedError)
+ {
+ Assert.False(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error));
+ Assert.Null(value);
+ Assert.Equal(expectedError, error);
+ }
+
+ [Theory]
+ [InlineData("1", 1)]
+ [InlineData("365", 365)]
+ public void TryInt_accepts_the_range_ends(string raw, int expected)
+ {
+ Assert.True(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error));
+ Assert.Equal(expected, value);
+ Assert.Null(error);
+ }
+
+ [Fact]
+ public void TryInt_without_an_upper_bound_names_only_the_minimum()
+ {
+ Assert.False(QueryParams.TryInt("-1", "skip", 0, int.MaxValue, out _, out var error));
+ Assert.Equal("skip must be an integer of at least 0", error);
+ Assert.True(QueryParams.TryInt("2147483647", "skip", 0, int.MaxValue, out var max, out _));
+ Assert.Equal(int.MaxValue, max);
+ }
+
+ [Theory]
+ [InlineData(null, null, null)]
+ [InlineData("1", 1, null)]
+ [InlineData("1000", 1000, null)]
+ [InlineData("1001", null, "pageSize must be between 1 and 1000")]
+ [InlineData("0", null, "pageSize must be between 1 and 1000")]
+ [InlineData("ten", null, "pageSize must be an integer")]
+ public void TryPageSize_is_strict_within_one_to_MaxPageSize(string? raw, int? expected, string? expectedError)
+ {
+ var ok = QueryParams.TryPageSize(raw, out var pageSize, out var error);
+ Assert.Equal(expectedError == null, ok);
+ Assert.Equal(expected, pageSize);
+ Assert.Equal(expectedError, error);
+ }
+
+ // ── Instants ───────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void TryUtcInstant_reads_offset_bare_and_date_only_values_as_utc()
+ {
+ Assert.True(QueryParams.TryUtcInstant("2026-08-29T20:11:00Z", "d", out var z, out _));
+ Assert.True(QueryParams.TryUtcInstant("2026-08-29T22:11:00+02:00", "d", out var offset, out _));
+ Assert.True(QueryParams.TryUtcInstant("2026-08-29T20:11:00", "d", out var bare, out _));
+ Assert.True(QueryParams.TryUtcInstant("2026-08-29", "d", out var dateOnly, out _));
+
+ Assert.Equal(Occurred, z);
+ Assert.Equal(Occurred, offset);
+ Assert.Equal(Occurred, bare);
+ Assert.Equal(Occurred.Date, dateOnly);
+ Assert.Equal(DateTimeKind.Utc, z!.Value.Kind);
+ Assert.Equal(DateTimeKind.Utc, bare!.Value.Kind);
+ Assert.Equal(DateTimeKind.Utc, dateOnly!.Value.Kind);
+ }
+
+ [Fact]
+ public void TryUtcInstant_treats_absent_and_whitespace_as_no_filter_and_garbage_as_error()
+ {
+ Assert.True(QueryParams.TryUtcInstant(null, "dateFrom", out var none, out var noneError));
+ Assert.Null(none);
+ Assert.Null(noneError);
+ Assert.True(QueryParams.TryUtcInstant(" ", "dateFrom", out var blank, out _));
+ Assert.Null(blank);
+ Assert.False(QueryParams.TryUtcInstant("yesterday", "dateFrom", out var bad, out var error));
+ Assert.Null(bad);
+ Assert.Equal("dateFrom must be an ISO 8601 date or date-time", error);
+ }
+
+ [Fact]
+ public void TryUtcInstant_is_culture_invariant()
+ {
+ var previous = CultureInfo.CurrentCulture;
+ try
+ {
+ // de-DE would read "08/29/2026" day-first and fail; invariant parsing does not.
+ CultureInfo.CurrentCulture = new CultureInfo("de-DE");
+ Assert.True(QueryParams.TryUtcInstant("08/29/2026 20:11", "d", out var parsed, out _));
+ Assert.Equal(Occurred, parsed);
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = previous;
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RawEventTimeTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RawEventTimeTests.cs
index ea608ae3..8899b13f 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/RawEventTimeTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/RawEventTimeTests.cs
@@ -116,30 +116,4 @@ public class RawEventTimeTests
Assert.Equal(Occurred - expectedSlack, QueryRawEventsPagination.IndexWrittenAfterHint(Occurred));
Assert.Null(QueryRawEventsPagination.IndexWrittenAfterHint(null));
}
-
- // ===== TryParseUtc =====
-
- [Fact]
- public void TryParseUtc_reads_offset_and_bare_values_as_utc()
- {
- Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T20:11:00Z", out var z));
- Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T22:11:00+02:00", out var offset));
- Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T20:11:00", out var bare));
-
- Assert.Equal(Occurred, z);
- Assert.Equal(Occurred, offset);
- Assert.Equal(Occurred, bare);
- Assert.Equal(DateTimeKind.Utc, z!.Value.Kind);
- Assert.Equal(DateTimeKind.Utc, bare!.Value.Kind);
- }
-
- [Fact]
- public void TryParseUtc_treats_empty_as_no_filter_and_garbage_as_error()
- {
- Assert.True(QueryRawEventsPagination.TryParseUtc(null, out var none));
- Assert.Null(none);
- Assert.True(QueryRawEventsPagination.TryParseUtc(" ", out var blank));
- Assert.Null(blank);
- Assert.False(QueryRawEventsPagination.TryParseUtc("yesterday", out _));
- }
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RuleHitSessionsFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RuleHitSessionsFunctionTests.cs
index 64219ba2..4fcbaaef 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/RuleHitSessionsFunctionTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/RuleHitSessionsFunctionTests.cs
@@ -10,21 +10,6 @@ namespace AutopilotMonitor.Functions.Tests;
///
public class RuleHitSessionsFunctionTests
{
- [Theory]
- [InlineData(null, 14)] // not supplied → default window
- [InlineData("", 14)] // empty → default
- [InlineData("garbage", 14)] // non-numeric → default
- [InlineData("14", 14)]
- [InlineData("1", 1)]
- [InlineData("0", 1)] // below range → clamped up
- [InlineData("-5", 1)]
- [InlineData("90", 90)]
- [InlineData("365", 90)] // above range → clamped down
- public void ParseDays_Defaults_And_Clamps(string? raw, int expected)
- {
- Assert.Equal(expected, RuleHitSessionsFunction.ParseDays(raw));
- }
-
[Fact]
public void MaxSessionIds_Matches_Repository_Default_Cap()
{
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/VerdictCalibrationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/VerdictCalibrationTests.cs
index d1810856..2e372d8b 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/VerdictCalibrationTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/VerdictCalibrationTests.cs
@@ -289,13 +289,4 @@ public class VerdictCalibrationTests
Assert.Equal(0, json.GetProperty("totals").GetProperty("sessions").GetInt32());
Assert.Equal(JsonValueKind.Null, json.GetProperty("computedAt").ValueKind);
}
-
- [Theory]
- [InlineData(null, 30)]
- [InlineData("abc", 30)]
- [InlineData("0", 1)]
- [InlineData("500", 180)]
- [InlineData("14", 14)]
- public void ClampDays_bounds_the_window(string? raw, int expected)
- => Assert.Equal(expected, VerdictCalibrationResponseBuilder.ClampDays(raw));
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/BackfillOccurredUtcFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/BackfillOccurredUtcFunction.cs
index d31d9baf..65feddc0 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/BackfillOccurredUtcFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/BackfillOccurredUtcFunction.cs
@@ -57,15 +57,11 @@ namespace AutopilotMonitor.Functions.Functions.Admin
var dryRun = !string.Equals(req.Query["dryRun"], "false", StringComparison.OrdinalIgnoreCase);
var continuation = req.Query["continuation"];
- var maxRows = DefaultMaxRows;
- if (!string.IsNullOrEmpty(req.Query["maxRows"]))
+ if (!QueryParams.TryInt(req.Query["maxRows"], "maxRows", 1, MaxMaxRows, out var maxRowsOrNull, out var maxRowsError))
{
- if (!int.TryParse(req.Query["maxRows"], out maxRows) || maxRows < 1)
- {
- return await req.BadRequestAsync($"maxRows must be a positive integer (max {MaxMaxRows})");
- }
- maxRows = Math.Min(maxRows, MaxMaxRows);
+ return await req.BadRequestAsync(maxRowsError!);
}
+ var maxRows = maxRowsOrNull ?? DefaultMaxRows;
_logger.LogInformation(
"OccurredUtc backfill requested by {User}: table={Table} dryRun={DryRun} maxRows={MaxRows} hasContinuation={HasContinuation}",
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetActiveUsersFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetActiveUsersFunction.cs
index dbd6247f..7fe7cfa9 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetActiveUsersFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetActiveUsersFunction.cs
@@ -36,9 +36,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
try
{
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
- var windowMinutes = DefaultWindowMinutes;
- if (int.TryParse(query["windowMinutes"], out var parsed))
- windowMinutes = Math.Clamp(parsed, MinWindowMinutes, MaxWindowMinutes);
+ var windowMinutes = QueryParams.Int(query["windowMinutes"], DefaultWindowMinutes, MinWindowMinutes, MaxWindowMinutes);
var now = DateTime.UtcNow;
var active = await _metricsRepo.GetActivePresenceAsync(TimeSpan.FromMinutes(windowMinutes));
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReclassifyLegacySessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReclassifyLegacySessionsFunction.cs
index 11f032c6..6c376949 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReclassifyLegacySessionsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReclassifyLegacySessionsFunction.cs
@@ -59,15 +59,11 @@ namespace AutopilotMonitor.Functions.Functions.Admin
return await req.BadRequestAsync("tenantId must be a GUID");
}
- var maxSessions = DefaultMaxSessions;
- if (!string.IsNullOrEmpty(req.Query["maxSessions"]))
+ if (!QueryParams.TryInt(req.Query["maxSessions"], "maxSessions", 1, MaxMaxSessions, out var maxSessionsOrNull, out var maxSessionsError))
{
- if (!int.TryParse(req.Query["maxSessions"], out maxSessions) || maxSessions < 1)
- {
- return await req.BadRequestAsync($"maxSessions must be a positive integer (max {MaxMaxSessions})");
- }
- maxSessions = Math.Min(maxSessions, MaxMaxSessions);
+ return await req.BadRequestAsync(maxSessionsError!);
}
+ var maxSessions = maxSessionsOrNull ?? DefaultMaxSessions;
_logger.LogInformation(
"Legacy reclassification requested by {User}: mode={Mode} dryRun={DryRun} tenantScope={TenantScope} maxSessions={MaxSessions}",
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs
index 8b8c8fc7..f9003e9a 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs
@@ -51,18 +51,14 @@ namespace AutopilotMonitor.Functions.Functions.Admin
var dateParam = req.Query["date"];
var aggregateOnlyParam = req.Query["aggregateOnly"];
- DateTime? targetDate = null;
- if (!string.IsNullOrEmpty(dateParam))
+ if (!QueryParams.TryUtcInstant(dateParam, "date", out var dateInstant, out _))
{
- if (DateTime.TryParse(dateParam, out var parsedDate))
- {
- targetDate = parsedDate.Date;
- _logger.LogInformation($"Manual maintenance for date: {targetDate:yyyy-MM-dd}");
- }
- else
- {
- return await req.BadRequestAsync("Invalid date format. Use yyyy-MM-dd");
- }
+ return await req.BadRequestAsync("Invalid date format. Use yyyy-MM-dd");
+ }
+ DateTime? targetDate = dateInstant?.Date;
+ if (targetDate.HasValue)
+ {
+ _logger.LogInformation($"Manual maintenance for date: {targetDate:yyyy-MM-dd}");
}
bool aggregateOnly = aggregateOnlyParam?.ToLower() == "true";
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs
index b38c3b82..fe09c5d5 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs
@@ -51,19 +51,15 @@ namespace AutopilotMonitor.Functions.Functions.Apps
///
public static AppsPaging ParseAppsPaging(NameValueCollection query)
{
- var pageSizeRaw = query["pageSize"];
- if (string.IsNullOrEmpty(pageSizeRaw))
+ if (!QueryParams.TryInt(query["pageSize"], "pageSize", 1, MaxAppsPageSize, out var pageSize, out var error))
+ return new AppsPaging { Error = error };
+ if (pageSize == null)
return new AppsPaging { PageSize = null, Skip = 0 };
- if (!int.TryParse(pageSizeRaw, out var pageSize) || pageSize < 1 || pageSize > MaxAppsPageSize)
- return new AppsPaging { Error = $"pageSize must be between 1 and {MaxAppsPageSize}" };
+ if (!QueryParams.TryInt(query["skip"], "skip", 0, int.MaxValue, out var skip, out error))
+ return new AppsPaging { Error = error };
- var skip = 0;
- var skipRaw = query["skip"];
- if (!string.IsNullOrEmpty(skipRaw) && (!int.TryParse(skipRaw, out skip) || skip < 0))
- return new AppsPaging { Error = "skip must be a non-negative integer" };
-
- return new AppsPaging { PageSize = pageSize, Skip = skip };
+ return new AppsPaging { PageSize = pageSize, Skip = skip ?? 0 };
}
// ── Data loaders ────────────────────────────────────────────────────
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs
index cef046fd..25d8061c 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs
@@ -47,9 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
}
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- int days = 30;
- if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
- days = parsedDays;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
// Active duration-regression episodes for this app (fail-soft: empty on error).
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs
index e6586fd2..cc9fb503 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs
@@ -46,21 +46,15 @@ namespace AutopilotMonitor.Functions.Functions.Apps
}
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- int days = 30;
- if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
- days = parsedDays;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant();
var modelFilter = query["model"];
var versionFilter = query["version"];
- int offset = 0;
- if (int.TryParse(query["offset"], out var parsedOffset) && parsedOffset >= 0)
- offset = parsedOffset;
+ var offset = QueryParams.Int(query["offset"], @default: 0, min: 0, max: int.MaxValue);
- int limit = DefaultLimit;
- if (int.TryParse(query["limit"], out var parsedLimit) && parsedLimit > 0)
- limit = Math.Min(parsedLimit, MaxLimit);
+ var limit = QueryParams.Int(query["limit"], DefaultLimit, 1, MaxLimit);
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync(
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs
index 3a72c35d..f22b5bde 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs
@@ -34,9 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var tenantId = TenantHelper.GetTenantId(req);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- int days = 30;
- if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
- days = parsedDays;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var paging = AppsAnalyticsHelper.ParseAppsPaging(query);
if (paging.Error != null)
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs
index 92656909..9fd89df5 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs
@@ -52,9 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
{
return await req.BadRequestAsync("tenantId must be a valid GUID");
}
- int days = 30;
- if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
- days = parsedDays;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
_logger.LogInformation(
"Global apps/{App}/analytics requested (user: {User}, tenantId: {TenantId}, days: {Days})",
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs
index d328de23..ac4ca2dc 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs
@@ -51,21 +51,15 @@ namespace AutopilotMonitor.Functions.Functions.Apps
{
return await req.BadRequestAsync("tenantId must be a valid GUID");
}
- int days = 30;
- if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
- days = parsedDays;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant();
var modelFilter = query["model"];
var versionFilter = query["version"];
- int offset = 0;
- if (int.TryParse(query["offset"], out var parsedOffset) && parsedOffset >= 0)
- offset = parsedOffset;
+ var offset = QueryParams.Int(query["offset"], @default: 0, min: 0, max: int.MaxValue);
- int limit = DefaultLimit;
- if (int.TryParse(query["limit"], out var parsedLimit) && parsedLimit > 0)
- limit = Math.Min(parsedLimit, MaxLimit);
+ var limit = QueryParams.Int(query["limit"], DefaultLimit, 1, MaxLimit);
_logger.LogInformation(
"Global apps/{App}/sessions requested (user: {User}, tenantId: {TenantId})",
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs
index d1f405cc..54a655ed 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs
@@ -39,9 +39,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
{
return await req.BadRequestAsync("tenantId must be a valid GUID");
}
- int days = 30;
- if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
- days = parsedDays;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
_logger.LogInformation(
"Global apps/list requested (user: {User}, tenantId: {TenantId}, days: {Days})",
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/ListTenantConfigBackupsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/ListTenantConfigBackupsFunction.cs
index cbe4f342..26329c37 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/ListTenantConfigBackupsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/ListTenantConfigBackupsFunction.cs
@@ -44,9 +44,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var requestCtx = req.GetRequestContext();
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var max = int.TryParse(query["max"], out var parsed)
- ? Math.Clamp(parsed, 1, MaxListSize)
- : MaxListSize;
+ var max = QueryParams.Int(query["max"], MaxListSize, 1, MaxListSize);
var backups = await _backupRepo.ListByPartitionAsync(requestCtx.TargetTenantId, max);
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs
index a00114b6..d66a511e 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs
@@ -31,12 +31,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Optional time filter: ?days=7 (default: 30, clamped to [1, 365] for consistency
// with metrics/summary, metrics/usage, and metrics/platform).
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var daysParam = query["days"];
- int days = 30;
- if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
- days = parsedDays;
- if (days < 1) days = 1;
- if (days > 365) days = 365;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var cutoff = DateTime.UtcNow.AddDays(-days);
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs
index 8f6f45c6..88419938 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs
@@ -124,7 +124,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]);
+ var days = QueryParams.Int(query["days"], DeviceJourneyMetricsResponseBuilder.DefaultWindowDays, 1, DeviceJourneyMetricsResponseBuilder.MaxWindowDays);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponseBuilder.BuildAsync(
@@ -169,7 +169,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var tenantIdFilter = query["tenantId"];
- var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]);
+ var days = QueryParams.Int(query["days"], DeviceJourneyMetricsResponseBuilder.DefaultWindowDays, 1, DeviceJourneyMetricsResponseBuilder.MaxWindowDays);
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -196,12 +196,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
internal const int MaxWindowDays = 180; // aggregate retention — older rows no longer exist
internal const int MaxRepeatDevices = 10;
- internal static int ClampDays(string? raw)
- {
- if (!int.TryParse(raw, out var days)) return DefaultWindowDays;
- return Math.Clamp(days, 1, MaxWindowDays);
- }
-
///
/// "Last N days" = exactly N calendar day keys including today — both range ends are
/// inclusive, so subtracting the full N would return N+1 days (Codex review: days=1
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetFleetHealthMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetFleetHealthMetricsFunction.cs
index 5e940b59..7d74456c 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetFleetHealthMetricsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetFleetHealthMetricsFunction.cs
@@ -35,12 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var tenantId = TenantHelper.GetTenantId(req);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var daysParam = query["days"];
- int days = 30;
- if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
- days = parsedDays;
- if (days < 1) days = 1;
- if (days > 365) days = 365;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
_logger.LogInformation("Fetching fleet health for tenant {TenantId} (days={Days})", tenantId, days);
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs
index 8216029f..0f99a8e4 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs
@@ -46,12 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.BadRequestAsync("locationKey or country parameter is required");
}
- var daysParam = query["days"];
- int days = 30;
- if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
- days = parsedDays;
- if (days < 1) days = 1;
- if (days > 365) days = 365;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var groupBy = query["groupBy"] ?? "city";
// Default response shape is lean (~15 fields/row) so MCP-driven callers fit
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs
index 8299db81..d620fedc 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs
@@ -36,12 +36,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
_logger.LogInformation("Fetching geographic metrics for tenant {TenantId}", tenantId);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var daysParam = query["days"];
- int days = 30;
- if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
- days = parsedDays;
- if (days < 1) days = 1;
- if (days > 365) days = 365;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var groupBy = query["groupBy"] ?? "city";
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAgentEfficiencyFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAgentEfficiencyFunction.cs
index 6266fe1b..44f838c3 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAgentEfficiencyFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAgentEfficiencyFunction.cs
@@ -35,8 +35,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var days = ParseIntParam(query["days"], defaultValue: 30, min: 1, max: 365);
- var limit = ParseIntParam(query["limit"], defaultValue: 500, min: 1, max: 2000);
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
+ var limit = QueryParams.Int(query["limit"], @default: 500, min: 1, max: 2000);
var tenantIdRaw = query["tenantId"];
string? tenantId = null;
@@ -62,15 +62,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.InternalServerErrorAsync(_logger, ex, "GetGlobalAgentEfficiency");
}
}
-
- private static int ParseIntParam(string? raw, int defaultValue, int min, int max)
- {
- var value = defaultValue;
- if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
- value = parsed;
- if (value < min) value = min;
- if (value > max) value = max;
- return value;
- }
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs
index 90ae5dd5..a91d12e4 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs
@@ -32,14 +32,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
_logger.LogInformation($"Fetching global app metrics (User: {userEmail})");
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var daysParam = query["days"];
- int days = 30;
- if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
- days = parsedDays;
// Clamped to [1, 365] like the tenant variant and the other metrics endpoints — an
// unbounded days= here was the one way to request an unlimited cross-tenant scan.
- if (days < 1) days = 1;
- if (days > 365) days = 365;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var tenantIdFilter = query["tenantId"];
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalFleetHealthMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalFleetHealthMetricsFunction.cs
index 34a82400..04900381 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalFleetHealthMetricsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalFleetHealthMetricsFunction.cs
@@ -35,12 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var userEmail = TenantHelper.GetUserIdentifier(req);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var daysParam = query["days"];
- int days = 30;
- if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
- days = parsedDays;
- if (days < 1) days = 1;
- if (days > 365) days = 365;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var tenantIdFilter = query["tenantId"];
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs
index e49c97f0..d0049008 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs
@@ -45,10 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.BadRequestAsync("locationKey or country parameter is required");
}
- var daysParam = query["days"];
- int days = 30;
- if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
- days = parsedDays;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var groupBy = query["groupBy"] ?? "city";
var full = string.Equals(query["full"], "1", StringComparison.Ordinal);
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs
index 04a3da01..2ff8ffa9 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs
@@ -36,10 +36,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
_logger.LogInformation("Fetching global geographic metrics (User: {UserEmail})", userEmail);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var daysParam = query["days"];
- int days = 30;
- if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
- days = parsedDays;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var groupBy = query["groupBy"] ?? "city";
var tenantIdFilter = query["tenantId"];
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalPlatformMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalPlatformMetricsFunction.cs
index 2363ac7a..5151e00d 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalPlatformMetricsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalPlatformMetricsFunction.cs
@@ -34,8 +34,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
- var days = ParseDays(req);
- var limit = ParseLimit(req);
+ var days = QueryParams.Int(req.Query["days"], @default: 90, min: 1, max: 365);
+ // limit drives the per-session work the service performs (each session
+ // triggers its own GetSessionEventsAsync call). The UI dropdown sets it
+ // explicitly so the user controls the analysis depth (e.g. 90 days x 1000
+ // sessions); the service bounds its concurrency internally, and the hard
+ // ceiling of 2000 protects against runaway query strings.
+ var limit = QueryParams.Int(req.Query["limit"], @default: 100, min: 1, max: 2000);
var metrics = await _platformMetricsService.ComputePlatformMetricsAsync(days, limit);
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -48,35 +53,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.InternalServerErrorAsync(_logger, ex, "GetGlobalPlatformMetrics");
}
}
-
- private static int ParseDays(HttpRequestData req)
- {
- var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var raw = query["days"];
- var days = 90;
- if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
- days = parsed;
- if (days < 1) days = 1;
- if (days > 365) days = 365;
- return days;
- }
-
- // Drives the per-session work the service performs (each session
- // triggers its own GetSessionEventsAsync call). The UI dropdown sets
- // this explicitly so the user controls the analysis depth (e.g.
- // 90 days × 1000 sessions). The service uses bounded concurrency
- // internally so even large limits don't blow the Function timeout.
- // Hard ceiling 2000 protects against runaway query strings.
- private static int ParseLimit(HttpRequestData req)
- {
- var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var raw = query["limit"];
- var limit = 100;
- if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
- limit = parsed;
- if (limit < 1) limit = 1;
- if (limit > 2000) limit = 2000;
- return limit;
- }
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalSlaMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalSlaMetricsFunction.cs
index a882029e..e714e4b7 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalSlaMetricsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalSlaMetricsFunction.cs
@@ -50,8 +50,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.BadRequestAsync("tenantId query parameter is required");
}
- if (int.TryParse(qs.Get("months"), out var parsedMonths))
- months = parsedMonths;
+ months = QueryParams.Int(qs.Get("months"), months, 1, SlaMetricsService.MaxMonths);
fresh = qs.Get("fresh") == "1";
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs
index 933b111d..ff27311d 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var tenantIdFilter = query["tenantId"];
- var days = VerdictCalibrationResponseBuilder.ClampDays(query["days"]);
+ var days = QueryParams.Int(query["days"], VerdictCalibrationResponseBuilder.DefaultWindowDays, 1, VerdictCalibrationResponseBuilder.MaxWindowDays);
var partition = "global";
if (!string.IsNullOrWhiteSpace(tenantIdFilter))
{
@@ -85,12 +85,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
internal const int TrendBaselineDays = 28;
internal const int TrendHorizonDays = TrendWindowDays + TrendBaselineDays;
- internal static int ClampDays(string? raw)
- {
- if (!int.TryParse(raw, out var days)) return DefaultWindowDays;
- return Math.Clamp(days, 1, MaxWindowDays);
- }
-
/// "Last N days" = exactly N calendar day keys including today (both range ends inclusive).
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs
index 8cc6c709..09466cc0 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs
@@ -26,7 +26,7 @@ public class MetricsSummaryFunction
try
{
var tenantId = TenantHelper.GetTenantId(req);
- var days = ParseDays(req);
+ var days = QueryParams.Int(req.Query["days"], @default: 30, min: 1, max: 365);
var summary = await _metricsRepo.GetMetricsSummaryAsync(tenantId, days);
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -45,7 +45,7 @@ public class MetricsSummaryFunction
{
try
{
- var days = ParseDays(req);
+ var days = QueryParams.Int(req.Query["days"], @default: 30, min: 1, max: 365);
// Optional tenantId filter — when set, GA scopes the cross-tenant summary to one
// tenant. When absent, returns the full cross-tenant view (null → all tenants).
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
@@ -73,16 +73,4 @@ public class MetricsSummaryFunction
return await req.InternalServerErrorAsync(_logger, ex, "Get global metrics summary");
}
}
-
- private static int ParseDays(HttpRequestData req)
- {
- var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var raw = query["days"];
- var days = 30;
- if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
- days = parsed;
- if (days < 1) days = 1;
- if (days > 365) days = 365;
- return days;
- }
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/PlatformUsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/PlatformUsageMetricsFunction.cs
index 771b451e..e18fd957 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/PlatformUsageMetricsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/PlatformUsageMetricsFunction.cs
@@ -41,7 +41,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Optional tenantId query parameter: when provided, return tenant-specific metrics
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var tenantId = query["tenantId"];
- var days = ParseDays(query);
+ var days = QueryParams.Int(query["days"], @default: 90, min: 1, max: 365);
var metrics = !string.IsNullOrEmpty(tenantId)
? await _usageMetricsService.ComputeTenantUsageMetricsAsync(tenantId, days)
@@ -57,16 +57,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.InternalServerErrorAsync(_logger, ex, "PlatformUsageMetrics");
}
}
-
- private static int ParseDays(System.Collections.Specialized.NameValueCollection query)
- {
- var raw = query["days"];
- var days = 90;
- if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
- days = parsed;
- if (days < 1) days = 1;
- if (days > 365) days = 365;
- return days;
- }
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleHitSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleHitSessionsFunction.cs
index a1110b82..790d1cdf 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleHitSessionsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleHitSessionsFunction.cs
@@ -48,7 +48,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.BadRequestAsync("ruleId is required");
}
- var days = ParseDays(query["days"]);
+ // Window in days: default 14, capped at 90 (RuleResults follow the session
+ // retention cascade, so anything past 90 days cannot widen the result).
+ var days = QueryParams.Int(query["days"], @default: 14, min: 1, max: 90);
var sinceUtc = DateTime.UtcNow.AddDays(-days);
var sessionIds = await _ruleRepo.GetRuleHitSessionIdsAsync(
requestCtx.TargetTenantId, ruleId, sinceUtc, MaxSessionIds);
@@ -68,16 +70,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.InternalServerErrorAsync(_logger, ex, "RuleHitSessions");
}
}
-
- ///
- /// Window in days: default 14, clamped to 1..90 (RuleResults follow the session
- /// retention cascade, so anything past 90 days cannot widen the result).
- ///
- internal static int ParseDays(string? raw)
- {
- if (string.IsNullOrEmpty(raw) || !int.TryParse(raw, out var parsed))
- return 14;
- return Math.Clamp(parsed, 1, 90);
- }
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/SlaMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/SlaMetricsFunction.cs
index 59c3ff44..f5af21a4 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/SlaMetricsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/SlaMetricsFunction.cs
@@ -43,8 +43,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
{
var qs = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? "");
- if (int.TryParse(qs.Get("months"), out var parsedMonths))
- months = parsedMonths;
+ months = QueryParams.Int(qs.Get("months"), months, 1, SlaMetricsService.MaxMonths);
fresh = qs.Get("fresh") == "1";
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/UsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/UsageMetricsFunction.cs
index ab756677..84905d13 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/UsageMetricsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/UsageMetricsFunction.cs
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
string tenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
- var days = ParseDays(req);
+ var days = QueryParams.Int(req.Query["days"], @default: 90, min: 1, max: 365);
_logger.LogInformation("Fetching usage metrics for tenant {TenantId} by user {User} (days={Days})", tenantId, userIdentifier, days);
var metrics = await _usageMetricsService.ComputeTenantUsageMetricsAsync(tenantId, days);
@@ -55,17 +55,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.InternalServerErrorAsync(_logger, ex, "UsageMetrics");
}
}
-
- private static int ParseDays(HttpRequestData req)
- {
- var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var raw = query["days"];
- var days = 90;
- if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
- days = parsed;
- if (days < 1) days = 1;
- if (days > 365) days = 365;
- return days;
- }
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs
index ab596e2f..d34a9ec2 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs
@@ -87,10 +87,13 @@ namespace AutopilotMonitor.Functions.Functions.Raw
// Date window: an unparsable value is an error, not a silently dropped filter —
// a caller that believes it narrowed the window must never get the whole table.
- if (!QueryRawEventsPagination.TryParseUtc(startedAfter, out var afterUtc)
- || !QueryRawEventsPagination.TryParseUtc(startedBefore, out var beforeUtc))
+ if (!QueryParams.TryUtcInstant(startedAfter, "startedAfter", out var afterUtc, out var dateError))
{
- return await req.BadRequestAsync("startedAfter/startedBefore must be ISO 8601 datetimes");
+ return await req.BadRequestAsync(dateError!);
+ }
+ if (!QueryParams.TryUtcInstant(startedBefore, "startedBefore", out var beforeUtc, out dateError))
+ {
+ return await req.BadRequestAsync(dateError!);
}
var callerTenantId = TenantHelper.GetTenantId(req);
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs
index d2858484..3ec9f7d9 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs
@@ -115,10 +115,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw
OsBuild = string.IsNullOrEmpty(osBuild) ? null : osBuild,
GeoCountry = string.IsNullOrEmpty(geoCountry) ? null : geoCountry,
};
- if (!string.IsNullOrEmpty(startedAfter) && DateTime.TryParse(startedAfter, out var after))
- filter.StartedAfter = after;
- if (!string.IsNullOrEmpty(startedBefore) && DateTime.TryParse(startedBefore, out var before))
- filter.StartedBefore = before;
+ filter.StartedAfter = QueryParams.UtcInstant(startedAfter);
+ filter.StartedBefore = QueryParams.UtcInstant(startedBefore);
if (!string.IsNullOrEmpty(isPreProvisioned) && bool.TryParse(isPreProvisioned, out var pp))
filter.IsPreProvisioned = pp;
if (!string.IsNullOrEmpty(isHybridJoin) && bool.TryParse(isHybridJoin, out var hj))
@@ -127,8 +125,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw
filter.IsSelfDeployingProfile = sdp;
if (!string.IsNullOrEmpty(isCloudPc) && bool.TryParse(isCloudPc, out var icp))
filter.IsCloudPc = icp;
- if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin;
- if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax;
+ filter.RebootCountMin = QueryParams.IntOrNull(query["rebootCountMin"]);
+ filter.RebootCountMax = QueryParams.IntOrNull(query["rebootCountMax"]);
var connectionType = query["connectionType"];
if (!string.IsNullOrEmpty(connectionType)) filter.ConnectionType = connectionType;
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDeletionsListFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDeletionsListFunction.cs
index edbb7858..2427423a 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDeletionsListFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDeletionsListFunction.cs
@@ -61,19 +61,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
return await req.BadRequestAsync("Query parameter 'state' must be one of: Preparing, Queued, Running, Poisoned.");
}
- int? strandedSinceMinutes = null;
- var strandedRaw = query["strandedSinceMinutes"];
- if (!string.IsNullOrEmpty(strandedRaw))
+ // 10080 minutes = 7 days.
+ if (!QueryParams.TryInt(query["strandedSinceMinutes"], "strandedSinceMinutes", 1, 10080, out var strandedSinceMinutes, out var strandedError))
{
- if (state != SessionDeletionState.Queued)
- {
- return await req.BadRequestAsync("strandedSinceMinutes is only valid with state=Queued.");
- }
- if (!int.TryParse(strandedRaw, out var minutes) || minutes <= 0 || minutes > 10080)
- {
- return await req.BadRequestAsync("strandedSinceMinutes must be a positive integer up to 10080 (7 days).");
- }
- strandedSinceMinutes = minutes;
+ return await req.BadRequestAsync(strandedError!);
+ }
+ if (strandedSinceMinutes.HasValue && state != SessionDeletionState.Queued)
+ {
+ return await req.BadRequestAsync("strandedSinceMinutes is only valid with state=Queued.");
}
var cutoffUtc = strandedSinceMinutes.HasValue
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionStatsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionStatsFunction.cs
index 0e969cc6..fe68251d 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionStatsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionStatsFunction.cs
@@ -65,19 +65,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
internal static bool TryParseDays(string? raw, out int days, out string? error)
{
- days = DefaultDays;
- error = null;
- if (string.IsNullOrEmpty(raw)) return true;
-
- if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
- || parsed < 1 || parsed > MaxDays)
- {
- error = $"days must be a positive integer between 1 and {MaxDays}";
- return false;
- }
-
- days = parsed;
- return true;
+ var ok = QueryParams.TryInt(raw, "days", 1, MaxDays, out var parsed, out error);
+ days = parsed ?? DefaultDays;
+ return ok;
}
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs
index 882ee70a..086f4e94 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs
@@ -158,10 +158,10 @@ public class SearchSessionsFunction
if (bool.TryParse(query["isHybridJoin"], out var ihj)) filter.IsHybridJoin = ihj;
if (bool.TryParse(query["isSelfDeployingProfile"], out var isdp)) filter.IsSelfDeployingProfile = isdp;
if (bool.TryParse(query["isCloudPc"], out var icp)) filter.IsCloudPc = icp;
- if (DateTime.TryParse(query["startedAfter"], out var sa)) filter.StartedAfter = sa;
- if (DateTime.TryParse(query["startedBefore"], out var sb)) filter.StartedBefore = sb;
- if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin;
- if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax;
+ filter.StartedAfter = QueryParams.UtcInstant(query["startedAfter"]);
+ filter.StartedBefore = QueryParams.UtcInstant(query["startedBefore"]);
+ filter.RebootCountMin = QueryParams.IntOrNull(query["rebootCountMin"]);
+ filter.RebootCountMax = QueryParams.IntOrNull(query["rebootCountMax"]);
// Dynamic device property filters: any query param starting with "prop."
var deviceProperties = new Dictionary();
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetUnmatchedSoftwareFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetUnmatchedSoftwareFunction.cs
index 08d7a23a..ac193e28 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetUnmatchedSoftwareFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetUnmatchedSoftwareFunction.cs
@@ -51,12 +51,8 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var query = HttpUtility.ParseQueryString(req.Url.Query);
- var skip = 0;
- var take = DefaultTake;
- if (int.TryParse(query["skip"], out var parsedSkip) && parsedSkip >= 0)
- skip = parsedSkip;
- if (int.TryParse(query["take"], out var parsedTake) && parsedTake >= 1)
- take = Math.Min(parsedTake, MaxTake);
+ var skip = QueryParams.Int(query["skip"], @default: 0, min: 0, max: int.MaxValue);
+ var take = QueryParams.Int(query["take"], DefaultTake, 1, MaxTake);
// Query all SoftwareInventory entries across tenants and filter for unmatched (no CpeUri).
// Server-side filtering on CpeUri is NOT possible: rows re-created by the cascade-delete
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryFunction.cs
index f2dd7447..ba94dd51 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryFunction.cs
@@ -66,8 +66,8 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
try
{
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
- var days = int.TryParse(query["days"], out var d) ? Math.Clamp(d, 1, 365) : 30;
- var topN = int.TryParse(query["topN"], out var t) ? Math.Clamp(t, 1, 100) : 20;
+ var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
+ var topN = QueryParams.Int(query["topN"], @default: 20, min: 1, max: 100);
_logger.LogInformation(
"Vulnerability summary requested (scope={Scope}, days={Days}, topN={TopN})",
diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/QueryParams.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/QueryParams.cs
new file mode 100644
index 00000000..f8fbb9af
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Helpers/QueryParams.cs
@@ -0,0 +1,140 @@
+using System.Collections.Specialized;
+using System.Globalization;
+
+namespace AutopilotMonitor.Functions.Helpers;
+
+///
+/// The one parser for HTTP query parameters. Every function reads its numbers and instants
+/// through here (QueryParamGuardTests fails on a stray int.TryParse /
+/// DateTime.TryParse under Functions/ or Pagination/); the default and
+/// the range stay arguments at the call site because they are per-endpoint wire behaviour
+/// (a 7-day stats window and a 90-day usage window are both right).
+///
+/// Two contracts, deliberately not one:
+///
+/// - Lenient (, ,
+/// ): a missing, unparseable or below-minimum value means "use the
+/// default", a value above the maximum is clamped. For windows and top-N knobs where a
+/// dashboard link with a stale or odd value should still render.
+/// - Strict (, ,
+/// ): a missing value is null (the caller applies its
+/// default), anything else that does not parse or falls outside the range is an error
+/// message for a 400. For pagination and admin batch sizes, where a silently altered
+/// value would misreport what was done.
+///
+/// Numbers are parsed invariant (); instants are parsed
+/// invariant with AssumeUniversal | AdjustToUniversal, so 2026-04-20,
+/// 2026-04-20T10:00:00 and 2026-04-20T12:00:00+02:00 all yield the same UTC
+/// value with . Whitespace-only counts as absent.
+///
+///
+internal static class QueryParams
+{
+ /// Upper bound of every pageSize parameter (continuation-token pagination).
+ public const int MaxPageSize = 1000;
+
+ private const DateTimeStyles UtcStyles = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal;
+
+ // ── Lenient ─────────────────────────────────────────────────────────────────────────
+
+ /// Lenient integer: absent / unparseable / below ⇒ ; above ⇒ .
+ public static int Int(string? raw, int @default, int min, int max)
+ {
+ if (!TryParseInvariant(raw, out var value) || value < min) return @default;
+ return value > max ? max : value;
+ }
+
+ ///
+ public static int Int(NameValueCollection? query, string name, int @default, int min, int max)
+ => Int(query?[name], @default, min, max);
+
+ /// Lenient optional integer without a range: absent or unparseable ⇒ null.
+ public static int? IntOrNull(string? raw)
+ => TryParseInvariant(raw, out var value) ? value : (int?)null;
+
+ /// Lenient optional instant: absent or unparseable ⇒ null, otherwise UTC.
+ public static DateTime? UtcInstant(string? raw)
+ => TryParseUtc(raw, out var value) ? value : (DateTime?)null;
+
+ // ── Strict ──────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Strict integer: absent ⇒ null + true; unparseable or outside
+ /// [, ] ⇒ false with an error message that names
+ /// the parameter. Pass int.MaxValue as for "at least min".
+ ///
+ public static bool TryInt(string? raw, string name, int min, int max, out int? value, out string? error)
+ {
+ value = null;
+ error = null;
+ if (string.IsNullOrWhiteSpace(raw)) return true;
+
+ if (!TryParseInvariant(raw, out var parsed))
+ {
+ error = $"{name} must be an integer";
+ return false;
+ }
+
+ if (parsed < min || parsed > max)
+ {
+ error = max == int.MaxValue
+ ? $"{name} must be an integer of at least {min}"
+ : $"{name} must be between {min} and {max}";
+ return false;
+ }
+
+ value = parsed;
+ return true;
+ }
+
+ /// Strict pageSize: absent ⇒ null (the paginator applies its default), otherwise 1...
+ public static bool TryPageSize(string? raw, out int? pageSize, out string? error)
+ => TryInt(raw, "pageSize", 1, MaxPageSize, out pageSize, out error);
+
+ /// Strict instant: absent ⇒ null + true; unparseable ⇒ false with an error message; otherwise UTC.
+ public static bool TryUtcInstant(string? raw, string name, out DateTime? value, out string? error)
+ {
+ value = null;
+ error = null;
+ if (string.IsNullOrWhiteSpace(raw)) return true;
+
+ if (!TryParseUtc(raw, out var parsed))
+ {
+ error = $"{name} must be an ISO 8601 date or date-time";
+ return false;
+ }
+
+ value = parsed;
+ return true;
+ }
+
+ // ── Core ────────────────────────────────────────────────────────────────────────────
+
+ private static bool TryParseInvariant(string? raw, out int value)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ value = 0;
+ return false;
+ }
+ return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
+ }
+
+ private static bool TryParseUtc(string? raw, out DateTime value)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ value = default;
+ return false;
+ }
+ if (!DateTime.TryParse(raw, CultureInfo.InvariantCulture, UtcStyles, out var parsed))
+ {
+ value = default;
+ return false;
+ }
+ // AdjustToUniversal yields Kind=Utc; SpecifyKind is belt and braces for the one
+ // shape (a bare date) where some runtimes leave it Unspecified.
+ value = parsed.Kind == DateTimeKind.Utc ? parsed : DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
+ return true;
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/DateWindowPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/DateWindowPagination.cs
index 21654ca9..11d1a252 100644
--- a/src/Backend/AutopilotMonitor.Functions/Pagination/DateWindowPagination.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Pagination/DateWindowPagination.cs
@@ -5,6 +5,7 @@ using System.Globalization;
using System.Linq;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
+using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -35,7 +36,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class DateWindowPagination
{
public const int DefaultPageSize = 200;
- public const int MaxPageSize = 1000;
public static readonly TimeSpan DefaultWindow = TimeSpan.FromDays(30);
///
@@ -67,19 +67,10 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
- DateTime? dateFrom = null, dateTo = null;
- if (!string.IsNullOrEmpty(dateFromRaw))
- {
- if (!TryParseUtc(dateFromRaw!, out var parsed))
- return new Parsed { Error = "dateFrom must be ISO 8601" };
- dateFrom = parsed;
- }
- if (!string.IsNullOrEmpty(dateToRaw))
- {
- if (!TryParseUtc(dateToRaw!, out var parsed))
- return new Parsed { Error = "dateTo must be ISO 8601" };
- dateTo = parsed;
- }
+ if (!QueryParams.TryUtcInstant(dateFromRaw, "dateFrom", out var dateFrom, out var dateError))
+ return new Parsed { Error = dateError };
+ if (!QueryParams.TryUtcInstant(dateToRaw, "dateTo", out var dateTo, out dateError))
+ return new Parsed { Error = dateError };
if (dateFrom.HasValue && dateTo.HasValue && dateFrom > dateTo)
{
return new Parsed { Error = "dateFrom must be <= dateTo" };
@@ -97,15 +88,8 @@ namespace AutopilotMonitor.Functions.Pagination
dateTo = anchor;
}
- int? pageSize = null;
- if (!string.IsNullOrEmpty(pageSizeRaw))
- {
- if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
- return new Parsed { Error = "pageSize must be an integer" };
- if (n < 1 || n > MaxPageSize)
- return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
- pageSize = n;
- }
+ if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSize, out var pageSizeError))
+ return new Parsed { Error = pageSizeError };
// continuation is meaningless without pageSize — silently drop.
var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw)
@@ -196,23 +180,5 @@ namespace AutopilotMonitor.Functions.Pagination
}
return sb.ToString();
}
-
- private static bool TryParseUtc(string s, out DateTime result)
- {
- // Accept ISO 8601 in any of the standard forms; force UTC.
- // AdjustToUniversal + AssumeUniversal handles strings without a TZ
- // offset (treat as UTC) and converts strings with an offset to UTC;
- // RoundtripKind is mutually exclusive with these and not needed.
- if (DateTime.TryParse(
- s, CultureInfo.InvariantCulture,
- DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
- out var parsed))
- {
- result = parsed.Kind == DateTimeKind.Utc ? parsed : DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
- return true;
- }
- result = default;
- return false;
- }
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/QueryRawEventsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/QueryRawEventsPagination.cs
index d16ff864..0a39a815 100644
--- a/src/Backend/AutopilotMonitor.Functions/Pagination/QueryRawEventsPagination.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Pagination/QueryRawEventsPagination.cs
@@ -4,6 +4,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
+using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -32,7 +33,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class QueryRawEventsPagination
{
public const int DefaultPageSize = 200;
- public const int MaxPageSize = 1000;
///
/// Slack between an event's sanitized time and the write time of its EventTypeIndex
@@ -48,22 +48,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static DateTime? IndexWrittenAfterHint(DateTime? startedAfterUtc)
=> startedAfterUtc.HasValue ? startedAfterUtc.Value - IndexWriteTimeSlack : null;
- ///
- /// Parses an ISO-8601 query value as a UTC instant. Returns false only for a non-empty
- /// value that does not parse — an absent/empty value yields null and true.
- /// A bare (offset-less) value is taken as UTC, never as server-local time.
- ///
- public static bool TryParseUtc(string? raw, out DateTime? utc)
- {
- utc = null;
- if (string.IsNullOrWhiteSpace(raw)) return true;
- if (!DateTime.TryParse(raw, CultureInfo.InvariantCulture,
- DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
- return false;
- utc = parsed;
- return true;
- }
-
public static string Fingerprint(
string scope,
string callerTenantId,
@@ -104,15 +88,9 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
- int pageSize = DefaultPageSize;
- if (!string.IsNullOrEmpty(pageSizeRaw))
- {
- if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
- return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
- if (n < 1 || n > MaxPageSize)
- return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
- pageSize = n;
- }
+ if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
+ return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
+ var pageSize = pageSizeOrNull ?? DefaultPageSize;
return new Parsed
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/RawTablePagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/RawTablePagination.cs
index 6442749b..52807ff4 100644
--- a/src/Backend/AutopilotMonitor.Functions/Pagination/RawTablePagination.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Pagination/RawTablePagination.cs
@@ -4,6 +4,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
+using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class RawTablePagination
{
public const int DefaultPageSize = 200;
- public const int MaxPageSize = 1000;
public static string Fingerprint(
string callerTenantId,
@@ -49,15 +49,9 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
- int pageSize = DefaultPageSize;
- if (!string.IsNullOrEmpty(pageSizeRaw))
- {
- if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
- return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
- if (n < 1 || n > MaxPageSize)
- return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
- pageSize = n;
- }
+ if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
+ return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
+ var pageSize = pageSizeOrNull ?? DefaultPageSize;
return new Parsed
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByCvePagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByCvePagination.cs
index 4091f2bb..515c6147 100644
--- a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByCvePagination.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByCvePagination.cs
@@ -4,6 +4,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
+using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SearchSessionsByCvePagination
{
public const int DefaultPageSize = 200;
- public const int MaxPageSize = 1000;
public static string Fingerprint(
string scope,
@@ -50,15 +50,9 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
- int pageSize = DefaultPageSize;
- if (!string.IsNullOrEmpty(pageSizeRaw))
- {
- if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
- return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
- if (n < 1 || n > MaxPageSize)
- return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
- pageSize = n;
- }
+ if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
+ return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
+ var pageSize = pageSizeOrNull ?? DefaultPageSize;
return new Parsed
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByEventPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByEventPagination.cs
index daf02b39..8f886183 100644
--- a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByEventPagination.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByEventPagination.cs
@@ -4,6 +4,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
+using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SearchSessionsByEventPagination
{
public const int DefaultPageSize = 50;
- public const int MaxPageSize = 1000;
public static string Fingerprint(
string scope,
@@ -46,15 +46,9 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
- int pageSize = DefaultPageSize;
- if (!string.IsNullOrEmpty(pageSizeRaw))
- {
- if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
- return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
- if (n < 1 || n > MaxPageSize)
- return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
- pageSize = n;
- }
+ if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
+ return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
+ var pageSize = pageSizeOrNull ?? DefaultPageSize;
return new Parsed
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs
index b2f56ad2..c7e9d909 100644
--- a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs
@@ -6,6 +6,7 @@ using System.Linq;
using System.Text;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Pagination;
+using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -20,7 +21,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SearchSessionsPagination
{
public const int DefaultPageSize = 50;
- public const int MaxPageSize = 1000;
public static string Fingerprint(string scope, string callerTenantId, string? filterTenantId, SessionSearchFilter filter)
{
@@ -79,15 +79,9 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
- int pageSize = DefaultPageSize;
- if (!string.IsNullOrEmpty(pageSizeRaw))
- {
- if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
- return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
- if (n < 1 || n > MaxPageSize)
- return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
- pageSize = n;
- }
+ if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
+ return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
+ var pageSize = pageSizeOrNull ?? DefaultPageSize;
return new Parsed
{
PageSize = pageSize,
diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs
index 1485f0a2..f03c966a 100644
--- a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs
@@ -6,6 +6,7 @@ using System.Linq;
using System.Text;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Pagination;
+using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -19,7 +20,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SessionAnnotationsPagination
{
public const int DefaultPageSize = 200;
- public const int MaxPageSize = 1000;
///
/// Cap on the free-text note search (?q=). Notes themselves are capped at 4096
/// characters; a search term longer than this is never a real query.
@@ -62,16 +62,10 @@ namespace AutopilotMonitor.Functions.Pagination
public static Parsed ParseQuery(NameValueCollection? query)
{
- var pageSize = DefaultPageSize;
var pageSizeRaw = query?["pageSize"];
- if (!string.IsNullOrEmpty(pageSizeRaw))
- {
- if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
- return new Parsed { Error = "pageSize must be an integer" };
- if (n < 1 || n > MaxPageSize)
- return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
- pageSize = n;
- }
+ if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
+ return new Parsed { Error = pageSizeError };
+ var pageSize = pageSizeOrNull ?? DefaultPageSize;
var lane = Normalize(query?["lane"]);
if (lane != null && !AnnotationLanes.All.Contains(lane))
@@ -81,10 +75,10 @@ namespace AutopilotMonitor.Functions.Pagination
if (verdict != null && !AnnotationVerdicts.All.Contains(verdict))
return new Parsed { Error = $"verdict must be one of: {string.Join(", ", AnnotationVerdicts.All)}" };
- if (!TryParseDate(query?["dateFrom"], out var dateFrom))
- return new Parsed { Error = "dateFrom must be an ISO 8601 date" };
- if (!TryParseDate(query?["dateTo"], out var dateTo))
- return new Parsed { Error = "dateTo must be an ISO 8601 date" };
+ if (!QueryParams.TryUtcInstant(query?["dateFrom"], "dateFrom", out var dateFrom, out var dateError))
+ return new Parsed { Error = dateError };
+ if (!QueryParams.TryUtcInstant(query?["dateTo"], "dateTo", out var dateTo, out dateError))
+ return new Parsed { Error = dateError };
var search = NullIfEmpty(query?["q"]?.Trim());
if (search != null && search.Length > MaxQueryLength)
@@ -149,18 +143,6 @@ namespace AutopilotMonitor.Functions.Pagination
sb.Append('&').Append(name).Append('=').Append(Uri.EscapeDataString(value!));
}
- private static bool TryParseDate(string? raw, out DateTime? value)
- {
- value = null;
- if (string.IsNullOrEmpty(raw)) return true;
- if (!DateTime.TryParse(
- raw, CultureInfo.InvariantCulture,
- DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
- return false;
- value = parsed;
- return true;
- }
-
private static string? Normalize(string? raw) =>
string.IsNullOrEmpty(raw) ? null : raw.ToLowerInvariant();
diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs
index 92a8a3d6..30c530e2 100644
--- a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
+using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -14,7 +15,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SessionEventsPagination
{
public const int DefaultPageSize = 200;
- public const int MaxPageSize = 1000;
/// Fingerprint binding tokens to (tenantId, sessionId).
public static string Fingerprint(string tenantId, string sessionId) =>
@@ -51,19 +51,8 @@ namespace AutopilotMonitor.Functions.Pagination
var rawPageSize = query["pageSize"];
var rawContinuation = query["continuation"];
- int? pageSize = null;
- if (!string.IsNullOrEmpty(rawPageSize))
- {
- if (!int.TryParse(rawPageSize, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
- {
- return new Parsed { Error = "pageSize must be an integer" };
- }
- if (n < 1 || n > MaxPageSize)
- {
- return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
- }
- pageSize = n;
- }
+ if (!QueryParams.TryPageSize(rawPageSize, out var pageSize, out var pageSizeError))
+ return new Parsed { Error = pageSizeError };
// continuation without pageSize is meaningless — silently drop it
// rather than 400, so a caller that toggles pageSize off does not get
diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionListPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionListPagination.cs
index 2d4613bb..c89e99c8 100644
--- a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionListPagination.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionListPagination.cs
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
+using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -16,7 +17,8 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SessionListPagination
{
public const int DefaultPageSize = 100;
- public const int MaxPageSize = 1000;
+ /// Scan-cost cap on days; a wider window is a 400, never a silent narrowing.
+ public const int MaxDays = 365;
///
/// Fingerprint binding the token to (scope, callerTenantId, days, filterTenantId).
@@ -47,23 +49,12 @@ namespace AutopilotMonitor.Functions.Pagination
var daysRaw = query?["days"];
var filterTenantIdRaw = acceptFilterTenantId ? query?["tenantId"] : null;
- int pageSize = DefaultPageSize;
- if (!string.IsNullOrEmpty(pageSizeRaw))
- {
- if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
- return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
- if (n < 1 || n > MaxPageSize)
- return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
- pageSize = n;
- }
+ if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
+ return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
+ var pageSize = pageSizeOrNull ?? DefaultPageSize;
- int? days = null;
- if (!string.IsNullOrEmpty(daysRaw))
- {
- if (!int.TryParse(daysRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var d) || d < 1)
- return new Parsed { PageSize = pageSize, Error = "days must be a positive integer" };
- days = d;
- }
+ if (!QueryParams.TryInt(daysRaw, "days", 1, MaxDays, out var days, out var daysError))
+ return new Parsed { PageSize = pageSize, Error = daysError };
return new Parsed
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionReportsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionReportsPagination.cs
index fcfb1889..837c8b4c 100644
--- a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionReportsPagination.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionReportsPagination.cs
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
+using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -18,7 +19,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SessionReportsPagination
{
public const int DefaultPageSize = 200;
- public const int MaxPageSize = 1000;
public static string Fingerprint(string callerTenantId, string? filterTenantId) =>
ContinuationToken.ComputeFingerprint(new[]
@@ -42,15 +42,8 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
- int? pageSize = null;
- if (!string.IsNullOrEmpty(pageSizeRaw))
- {
- if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
- return new Parsed { Error = "pageSize must be an integer" };
- if (n < 1 || n > MaxPageSize)
- return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
- pageSize = n;
- }
+ if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSize, out var pageSizeError))
+ return new Parsed { Error = pageSizeError };
// continuation is meaningless without pageSize — silently drop.
var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw)
diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/TenantConfigPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/TenantConfigPagination.cs
index 3c3951f1..53fcd729 100644
--- a/src/Backend/AutopilotMonitor.Functions/Pagination/TenantConfigPagination.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Pagination/TenantConfigPagination.cs
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
+using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class TenantConfigPagination
{
public const int DefaultPageSize = 200;
- public const int MaxPageSize = 1000;
public static string Fingerprint(string callerTenantId) =>
ContinuationToken.ComputeFingerprint(new[]
@@ -46,15 +46,8 @@ namespace AutopilotMonitor.Functions.Pagination
var continuationRaw = query?["continuation"];
var fieldsRaw = query?["fields"];
- int? pageSize = null;
- if (!string.IsNullOrEmpty(pageSizeRaw))
- {
- if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
- return new Parsed { Error = "pageSize must be an integer" };
- if (n < 1 || n > MaxPageSize)
- return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
- pageSize = n;
- }
+ if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSize, out var pageSizeError))
+ return new Parsed { Error = pageSizeError };
// continuation is meaningless without pageSize — silently drop.
var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw)
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs
index 729432b5..61d3f815 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs
@@ -55,9 +55,12 @@ namespace AutopilotMonitor.Functions.Services
_cache.TryRemove(key, out _);
}
+ /// Widest window the SLA aggregates serve; the HTTP layer caps months at this too.
+ public const int MaxMonths = 6;
+
public async Task ComputeSlaMetricsAsync(string tenantId, int months = 3, bool fresh = false)
{
- months = Math.Clamp(months, 1, 6);
+ months = Math.Clamp(months, 1, MaxMonths);
var cacheKey = $"{tenantId}:{months}";