Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReachabilityDriftEndpoints.cs
StellaOps Bot 28823a8960 save progress
2025-12-18 09:10:36 +02:00

308 lines
11 KiB
C#

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<ReachabilityDriftResult>(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<DriftedSinksResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> 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<IResult> 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>(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<DriftedSink> Sinks);