using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Text; using StellaOps.Determinism; using StellaOps.Scheduler.Models; using StellaOps.Scheduler.Persistence.Postgres.Repositories; namespace StellaOps.Scheduler.WebService; internal static class SchedulerEndpointHelpers { private const string ActorHeader = "X-Actor-Id"; private const string ActorNameHeader = "X-Actor-Name"; private const string ActorKindHeader = "X-Actor-Kind"; private const string TenantHeader = "X-Tenant-Id"; public static string GenerateIdentifier(string prefix, IGuidProvider? guidProvider = null) { if (string.IsNullOrWhiteSpace(prefix)) { throw new ArgumentException("Prefix must be provided.", nameof(prefix)); } var guid = (guidProvider ?? SystemGuidProvider.Instance).NewGuid(); return $"{prefix.Trim()}_{guid:N}"; } public static string ResolveActorId(HttpContext context) { ArgumentNullException.ThrowIfNull(context); if (context.Request.Headers.TryGetValue(ActorHeader, out var values)) { var actor = values.ToString().Trim(); if (!string.IsNullOrEmpty(actor)) { return actor; } } if (context.Request.Headers.TryGetValue(TenantHeader, out var tenant)) { var tenantId = tenant.ToString().Trim(); if (!string.IsNullOrEmpty(tenantId)) { return tenantId; } } return "system"; } public static AuditActor ResolveAuditActor(HttpContext context) { ArgumentNullException.ThrowIfNull(context); var actorId = context.Request.Headers.TryGetValue(ActorHeader, out var idHeader) ? idHeader.ToString().Trim() : null; var displayName = context.Request.Headers.TryGetValue(ActorNameHeader, out var nameHeader) ? nameHeader.ToString().Trim() : null; var kind = context.Request.Headers.TryGetValue(ActorKindHeader, out var kindHeader) ? kindHeader.ToString().Trim() : null; if (string.IsNullOrWhiteSpace(actorId)) { actorId = context.Request.Headers.TryGetValue(TenantHeader, out var tenantHeader) ? tenantHeader.ToString().Trim() : "system"; } displayName = string.IsNullOrWhiteSpace(displayName) ? actorId : displayName; kind = string.IsNullOrWhiteSpace(kind) ? "user" : kind; return new AuditActor(actorId!, displayName!, kind!); } public static bool TryParseBoolean(string? value) => !string.IsNullOrWhiteSpace(value) && (string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)); public static int? TryParsePositiveInt(string? value) { if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed > 0) { return parsed; } return null; } public static DateTimeOffset? TryParseDateTimeOffset(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) { return parsed.ToUniversalTime(); } throw new ValidationException($"Value '{value}' is not a valid ISO-8601 timestamp."); } public static Selector NormalizeSelector(Selector selection, string tenantId) { ArgumentNullException.ThrowIfNull(selection); if (string.IsNullOrWhiteSpace(tenantId)) { throw new ArgumentException("Tenant identifier must be provided.", nameof(tenantId)); } return new Selector( selection.Scope, tenantId, selection.Namespaces, selection.Repositories, selection.Digests, selection.IncludeTags, selection.Labels, selection.ResolvesTags); } public static string CreateRunCursor(Run run) { ArgumentNullException.ThrowIfNull(run); var payload = $"{run.CreatedAt.ToUniversalTime():O}|{run.Id}"; return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)); } public static RunListCursor? TryParseRunCursor(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } var trimmed = value.Trim(); if (trimmed.Length == 0) { return null; } try { var bytes = Convert.FromBase64String(trimmed); var decoded = Encoding.UTF8.GetString(bytes); var parts = decoded.Split('|', 2, StringSplitOptions.TrimEntries); if (parts.Length != 2) { throw new ValidationException($"Cursor '{value}' is not valid."); } if (!DateTimeOffset.TryParse(parts[0], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp)) { throw new ValidationException($"Cursor '{value}' is not valid."); } if (string.IsNullOrWhiteSpace(parts[1])) { throw new ValidationException($"Cursor '{value}' is not valid."); } return new RunListCursor(timestamp.ToUniversalTime(), parts[1]); } catch (FormatException ex) { throw new ValidationException($"Cursor '{value}' is not valid.", ex); } } }