using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Scanner.WebService.Constants; using StellaOps.Scanner.WebService.Contracts; 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 CallGraphEndpoints { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() } }; private static readonly HashSet SupportedLanguages = new(StringComparer.OrdinalIgnoreCase) { "dotnet", "java", "node", "python", "go", "rust", "binary", "ruby", "php" }; public static void MapCallGraphEndpoints(this RouteGroupBuilder scansGroup) { ArgumentNullException.ThrowIfNull(scansGroup); // POST /scans/{scanId}/callgraphs scansGroup.MapPost("/{scanId}/callgraphs", HandleSubmitCallGraphAsync) .WithName("scanner.scans.callgraphs.submit") .WithTags("CallGraphs") .Produces(StatusCodes.Status202Accepted) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status413PayloadTooLarge) .RequireAuthorization(ScannerPolicies.CallGraphIngest); } private static async Task HandleSubmitCallGraphAsync( string scanId, CallGraphV1Dto request, IScanCoordinator coordinator, ICallGraphIngestionService ingestionService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(ingestionService); ArgumentNullException.ThrowIfNull(request); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan identifier", StatusCodes.Status400BadRequest, detail: "Scan identifier is required."); } // Validate Content-Digest header for idempotency var contentDigest = context.Request.Headers["Content-Digest"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(contentDigest)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Missing Content-Digest header", StatusCodes.Status400BadRequest, detail: "Content-Digest header is required for idempotent call graph submission."); } // Verify scan exists var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Scan not found", StatusCodes.Status404NotFound, detail: "Requested scan could not be located."); } // Validate call graph schema var validationResult = ValidateCallGraph(request); if (!validationResult.IsValid) { var extensions = new Dictionary { ["errors"] = validationResult.Errors }; return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid call graph", StatusCodes.Status400BadRequest, detail: "Call graph validation failed.", extensions: extensions); } // Check for duplicate submission (idempotency) var existing = await ingestionService.FindByDigestAsync(parsed, contentDigest, cancellationToken) .ConfigureAwait(false); if (existing is not null) { var conflictExtensions = new Dictionary { ["callgraphId"] = existing.Id, ["digest"] = existing.Digest }; return ProblemResultFactory.Create( context, ProblemTypes.Conflict, "Duplicate call graph", StatusCodes.Status409Conflict, detail: "Call graph with this Content-Digest already submitted.", extensions: conflictExtensions); } // Ingest the call graph var result = await ingestionService.IngestAsync(parsed, request, contentDigest, cancellationToken) .ConfigureAwait(false); var response = new CallGraphAcceptedResponseDto( CallgraphId: result.CallgraphId, NodeCount: result.NodeCount, EdgeCount: result.EdgeCount, Digest: result.Digest); context.Response.Headers.Location = $"/api/scans/{scanId}/callgraphs/{result.CallgraphId}"; return Json(response, StatusCodes.Status202Accepted); } private static CallGraphValidationResult ValidateCallGraph(CallGraphV1Dto callGraph) { var errors = new List(); // Validate schema version if (string.IsNullOrWhiteSpace(callGraph.Schema)) { errors.Add("Schema version is required."); } else if (!string.Equals(callGraph.Schema, "stella.callgraph.v1", StringComparison.Ordinal)) { errors.Add($"Unsupported schema '{callGraph.Schema}'. Expected 'stella.callgraph.v1'."); } // Validate scan key if (string.IsNullOrWhiteSpace(callGraph.ScanKey)) { errors.Add("ScanKey is required."); } // Validate language if (string.IsNullOrWhiteSpace(callGraph.Language)) { errors.Add("Language is required."); } else if (!SupportedLanguages.Contains(callGraph.Language)) { errors.Add($"Unsupported language '{callGraph.Language}'. Supported: {string.Join(", ", SupportedLanguages)}."); } // Validate nodes if (callGraph.Nodes is null || callGraph.Nodes.Count == 0) { errors.Add("At least one node is required."); } else { var nodeIds = new HashSet(StringComparer.Ordinal); for (var i = 0; i < callGraph.Nodes.Count; i++) { var node = callGraph.Nodes[i]; if (string.IsNullOrWhiteSpace(node.NodeId)) { errors.Add($"nodes[{i}].nodeId is required."); } else if (!nodeIds.Add(node.NodeId)) { errors.Add($"Duplicate nodeId '{node.NodeId}'."); } if (string.IsNullOrWhiteSpace(node.SymbolKey)) { errors.Add($"nodes[{i}].symbolKey is required."); } } } // Validate edges if (callGraph.Edges is null || callGraph.Edges.Count == 0) { errors.Add("At least one edge is required."); } else { var nodeIds = callGraph.Nodes? .Where(n => !string.IsNullOrWhiteSpace(n.NodeId)) .Select(n => n.NodeId) .ToHashSet(StringComparer.Ordinal) ?? new HashSet(); for (var i = 0; i < callGraph.Edges.Count; i++) { var edge = callGraph.Edges[i]; if (string.IsNullOrWhiteSpace(edge.From)) { errors.Add($"edges[{i}].from is required."); } else if (nodeIds.Count > 0 && !nodeIds.Contains(edge.From)) { errors.Add($"edges[{i}].from references unknown node '{edge.From}'."); } if (string.IsNullOrWhiteSpace(edge.To)) { errors.Add($"edges[{i}].to is required."); } else if (nodeIds.Count > 0 && !nodeIds.Contains(edge.To)) { errors.Add($"edges[{i}].to references unknown node '{edge.To}'."); } } } return errors.Count > 0 ? CallGraphValidationResult.Failure(errors.ToArray()) : CallGraphValidationResult.Success(); } 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); } }