using System.Collections.Immutable; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Scanner.ReachabilityDrift; using StellaOps.Scanner.ReachabilityDrift.Services; using StellaOps.Scanner.Storage.Repositories; using StellaOps.Scanner.WebService.Constants; using StellaOps.Scanner.WebService.Domain; using StellaOps.Scanner.WebService.Infrastructure; using StellaOps.Scanner.WebService.Security; using StellaOps.Scanner.WebService.Services; namespace StellaOps.Scanner.WebService.Endpoints; internal static class ReachabilityDriftEndpoints { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() } }; public static void MapReachabilityDriftScanEndpoints(this RouteGroupBuilder scansGroup) { ArgumentNullException.ThrowIfNull(scansGroup); // GET /scans/{scanId}/drift?baseScanId=...&language=dotnet&includeFullPath=false scansGroup.MapGet("/{scanId}/drift", HandleGetDriftAsync) .WithName("scanner.scans.reachability-drift") .WithTags("ReachabilityDrift") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); } public static void MapReachabilityDriftRootEndpoints(this RouteGroupBuilder apiGroup) { ArgumentNullException.ThrowIfNull(apiGroup); var driftGroup = apiGroup.MapGroup("/drift"); // GET /drift/{driftId}/sinks?direction=became_reachable&offset=0&limit=100 driftGroup.MapGet("/{driftId:guid}/sinks", HandleListSinksAsync) .WithName("scanner.drift.sinks") .WithTags("ReachabilityDrift") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); } private static async Task HandleGetDriftAsync( string scanId, string? baseScanId, string? language, bool? includeFullPath, IScanCoordinator coordinator, ICallGraphSnapshotRepository callGraphSnapshots, CodeChangeFactExtractor codeChangeFactExtractor, ICodeChangeRepository codeChangeRepository, ReachabilityDriftDetector driftDetector, IReachabilityDriftResultRepository driftRepository, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(callGraphSnapshots); ArgumentNullException.ThrowIfNull(codeChangeFactExtractor); ArgumentNullException.ThrowIfNull(codeChangeRepository); ArgumentNullException.ThrowIfNull(driftDetector); ArgumentNullException.ThrowIfNull(driftRepository); if (!ScanId.TryParse(scanId, out var headScan)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan identifier", StatusCodes.Status400BadRequest, detail: "Scan identifier is required."); } var resolvedLanguage = string.IsNullOrWhiteSpace(language) ? "dotnet" : language.Trim(); var headSnapshot = await coordinator.GetAsync(headScan, cancellationToken).ConfigureAwait(false); if (headSnapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Scan not found", StatusCodes.Status404NotFound, detail: "Requested scan could not be located."); } if (string.IsNullOrWhiteSpace(baseScanId)) { var existing = await driftRepository.TryGetLatestForHeadAsync(headScan.Value, resolvedLanguage, cancellationToken) .ConfigureAwait(false); if (existing is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Drift result not found", StatusCodes.Status404NotFound, detail: $"No reachability drift result recorded for scan {scanId} (language={resolvedLanguage})."); } return Json(existing, StatusCodes.Status200OK); } if (!ScanId.TryParse(baseScanId, out var baseScan)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid base scan identifier", StatusCodes.Status400BadRequest, detail: "Query parameter 'baseScanId' must be a valid scan id."); } var baselineSnapshot = await coordinator.GetAsync(baseScan, cancellationToken).ConfigureAwait(false); if (baselineSnapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Base scan not found", StatusCodes.Status404NotFound, detail: "Base scan could not be located."); } var baseGraph = await callGraphSnapshots.TryGetLatestAsync(baseScan.Value, resolvedLanguage, cancellationToken) .ConfigureAwait(false); if (baseGraph is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Base call graph not found", StatusCodes.Status404NotFound, detail: $"No call graph snapshot found for base scan {baseScan.Value} (language={resolvedLanguage})."); } var headGraph = await callGraphSnapshots.TryGetLatestAsync(headScan.Value, resolvedLanguage, cancellationToken) .ConfigureAwait(false); if (headGraph is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Head call graph not found", StatusCodes.Status404NotFound, detail: $"No call graph snapshot found for head scan {headScan.Value} (language={resolvedLanguage})."); } try { var codeChanges = codeChangeFactExtractor.Extract(baseGraph, headGraph); await codeChangeRepository.StoreAsync(codeChanges, cancellationToken).ConfigureAwait(false); var drift = driftDetector.Detect( baseGraph, headGraph, codeChanges, includeFullPath: includeFullPath == true); await driftRepository.StoreAsync(drift, cancellationToken).ConfigureAwait(false); return Json(drift, StatusCodes.Status200OK); } catch (ArgumentException ex) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid drift request", StatusCodes.Status400BadRequest, detail: ex.Message); } } private static async Task HandleListSinksAsync( Guid driftId, string? direction, int? offset, int? limit, IReachabilityDriftResultRepository driftRepository, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(driftRepository); if (driftId == Guid.Empty) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid drift identifier", StatusCodes.Status400BadRequest, detail: "driftId must be a non-empty GUID."); } if (!TryParseDirection(direction, out var parsedDirection)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid direction", StatusCodes.Status400BadRequest, detail: "direction must be 'became_reachable' or 'became_unreachable'."); } var resolvedOffset = offset ?? 0; if (resolvedOffset < 0) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid offset", StatusCodes.Status400BadRequest, detail: "offset must be >= 0."); } var resolvedLimit = limit ?? 100; if (resolvedLimit <= 0 || resolvedLimit > 500) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid limit", StatusCodes.Status400BadRequest, detail: "limit must be between 1 and 500."); } if (!await driftRepository.ExistsAsync(driftId, cancellationToken).ConfigureAwait(false)) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Drift result not found", StatusCodes.Status404NotFound, detail: "Requested drift result could not be located."); } var sinks = await driftRepository.ListSinksAsync( driftId, parsedDirection, resolvedOffset, resolvedLimit, cancellationToken).ConfigureAwait(false); var response = new DriftedSinksResponseDto( DriftId: driftId, Direction: parsedDirection, Offset: resolvedOffset, Limit: resolvedLimit, Count: sinks.Count, Sinks: sinks.ToImmutableArray()); return Json(response, StatusCodes.Status200OK); } private static bool TryParseDirection(string? direction, out DriftDirection parsed) { if (string.IsNullOrWhiteSpace(direction)) { parsed = DriftDirection.BecameReachable; return true; } var normalized = direction.Trim().ToLowerInvariant(); parsed = normalized switch { "became_reachable" or "newly_reachable" or "reachable" or "up" => DriftDirection.BecameReachable, "became_unreachable" or "newly_unreachable" or "unreachable" or "down" => DriftDirection.BecameUnreachable, _ => DriftDirection.BecameReachable }; return normalized is "became_reachable" or "newly_reachable" or "reachable" or "up" or "became_unreachable" or "newly_unreachable" or "unreachable" or "down"; } private static IResult Json(T value, int statusCode) { var payload = JsonSerializer.Serialize(value, SerializerOptions); return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode); } } internal sealed record DriftedSinksResponseDto( Guid DriftId, DriftDirection Direction, int Offset, int Limit, int Count, ImmutableArray Sinks);