diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index e3313387..c1daa1a6 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -106,8 +106,16 @@ jobs: snapshot = json.load(handle) assert snapshot['framework'] == 'aspnetcore' assert snapshot['runtime']['name'] == 'dotnet' - assert snapshot['summary'] == {'documented': 1, 'runtime': 2, 'matched': 1, 'runtimeOnly': 1, 'documentedOnly': 0} - assert snapshot['runtimeOnly'] == [{'method': 'POST', 'path': '/internal'}] + assert snapshot['summary'] == {'documented': 1, 'runtime': 3, 'matched': 1, 'runtimeOnly': 2, 'documentedOnly': 0} + assert {(item['method'], item['path']) for item in snapshot['runtimeOnly']} == {('GET', '/internal/health'), ('POST', '/internal/reindex')} + validation = snapshot['validation'] + assert validation['status'] == 'fail' + assert validation['summary'] == {'total': 2, 'errors': 1, 'warnings': 0, 'info': 1} + assert validation['findings'][0]['severity'] == 'error' + assert validation['findings'][0]['location']['method'] == 'POST' + assert validation['findings'][0]['location']['path'] == '/internal/reindex' + assert validation['findings'][1]['disposition'] == 'acknowledged' + assert validation['findings'][1]['location']['path'] == '/internal/health' PY test "$(curl --silent --output /dev/null --write-out '%{http_code}' -X POST http://127.0.0.1:5087/docs/__flexdoc/runtime)" = "405" diff --git a/CHANGELOG.md b/CHANGELOG.md index 2231984d..5eea9c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Notable FlexDoc product releases are recorded here. Detailed release notes remai ## Unreleased +- Spring MVC and ASP.NET Core emit the same runtime-contract validation object as Node. A complete-discovery undocumented route fails `flexdoc validate` by method and path. Acknowledgement keeps that route registered as informational and does not remove it from the runtime record. Snapshot membership uses the same wire identity as validation, so differing path-parameter names are one operation. + ## 3.5.0 — Node disagreement loop `@prauga/flexdoc-client` and `@prauga/flexdoc-backend` **3.5.0**, with `@prauga/flexdoc-cli` **0.8.0**. Native adapter versions are unchanged. diff --git a/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetContractValidation.cs b/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetContractValidation.cs new file mode 100644 index 00000000..664ce441 --- /dev/null +++ b/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetContractValidation.cs @@ -0,0 +1,183 @@ +using System.Text.RegularExpressions; + +namespace Prauga.FlexDoc.AspNetCore; + +/// +/// Ports the Node runtime-contract validator. Path-parameter names are ignored for route identity. +/// Acknowledged undocumented routes stay registered and are reported as informational findings. +/// +internal static class AspNetContractValidation +{ + internal static Dictionary Validate( + IReadOnlyList documentedRoutes, + IReadOnlyList runtimeRoutes, + bool discoveryComplete, + IReadOnlyList acknowledgedUndocumented) + { + var documentedKeys = documentedRoutes.Select(ExactShapeKey).ToHashSet(StringComparer.Ordinal); + var runtimeKeys = runtimeRoutes.Select(ExactShapeKey).ToHashSet(StringComparer.Ordinal); + var acknowledgedKeys = acknowledgedUndocumented.Select(ExactShapeKey).ToHashSet(StringComparer.Ordinal); + var findings = new List>(); + var documentedMethods = GroupedMethods(documentedRoutes); + var runtimeMethods = GroupedMethods(runtimeRoutes); + var methodMismatchShapes = new HashSet(StringComparer.Ordinal); + + foreach (var (shape, expectedMethods) in documentedMethods) + { + if (!runtimeMethods.TryGetValue(shape, out var observedMethods)) continue; + if (expectedMethods.Any(observedMethods.Contains)) continue; + methodMismatchShapes.Add(shape); + var path = RepresentativePath(documentedRoutes, shape); + findings.Add(Finding( + "runtime.method-mismatch", + null, + path, + discoveryComplete ? "error" : "warning", + expectedMethods[0], + $"Runtime route {path} is registered for different HTTP methods than OpenAPI documents.", + expectedMethods, + observedMethods, + null)); + } + + foreach (var route in runtimeRoutes) + { + var shape = RouteShape(route.Path); + if (methodMismatchShapes.Contains(shape) || documentedKeys.Contains(ExactShapeKey(route))) continue; + var acknowledged = acknowledgedKeys.Contains(ExactShapeKey(route)); + findings.Add(Finding( + "runtime.operation-undocumented", + route.Method, + route.Path, + acknowledged ? "info" : discoveryComplete ? "error" : "warning", + route.Method, + $"Runtime implements {route.Method} {route.Path}, but OpenAPI does not document that operation.", + "Operation is represented in OpenAPI", + acknowledged + ? "Operation exists only in the running backend and is acknowledged" + : "Operation exists only in the running backend", + acknowledged ? "acknowledged" : null)); + } + + foreach (var route in documentedRoutes) + { + var shape = RouteShape(route.Path); + if (methodMismatchShapes.Contains(shape) || runtimeKeys.Contains(ExactShapeKey(route))) continue; + findings.Add(Finding( + "runtime.operation-unobserved", + route.Method, + route.Path, + discoveryComplete ? "error" : "info", + route.Method, + discoveryComplete + ? $"OpenAPI documents {route.Method} {route.Path}, but the running backend does not expose that operation." + : $"OpenAPI documents {route.Method} {route.Path}, but it was not observed during partial runtime discovery.", + "Operation is exposed by the running backend", + discoveryComplete ? "No matching runtime operation exists" : "No matching operation was observed during partial discovery", + null)); + } + + findings.Sort(static (left, right) => + { + var severity = SeverityRank(Text(left, "severity")).CompareTo(SeverityRank(Text(right, "severity"))); + if (severity != 0) return severity; + var path = string.Compare(Location(left, "path"), Location(right, "path"), StringComparison.Ordinal); + if (path != 0) return path; + var method = string.Compare(Location(left, "method"), Location(right, "method"), StringComparison.Ordinal); + if (method != 0) return method; + return string.Compare(Text(left, "code"), Text(right, "code"), StringComparison.Ordinal); + }); + + var errors = findings.Count(finding => Text(finding, "severity") == "error"); + var warnings = findings.Count(finding => Text(finding, "severity") == "warning"); + var info = findings.Count - errors - warnings; + var status = errors > 0 ? "fail" : warnings > 0 ? "warn" : !discoveryComplete ? "partial" : "pass"; + return new Dictionary + { + ["status"] = status, + ["complete"] = discoveryComplete, + ["findings"] = findings, + ["summary"] = new Dictionary + { + ["total"] = findings.Count, + ["errors"] = errors, + ["warnings"] = warnings, + ["info"] = info, + }, + }; + } + + private static Dictionary Finding( + string code, + string? idMethod, + string path, + string severity, + string? locationMethod, + string message, + object expected, + object observed, + string? disposition) + { + var location = new Dictionary { ["kind"] = "operation", ["path"] = path }; + if (locationMethod is not null) location["method"] = locationMethod; + var finding = new Dictionary + { + ["id"] = $"{code}:{(idMethod is null ? "" : idMethod + ":")}{RouteShape(path)}", + ["code"] = code, + ["severity"] = severity, + ["location"] = location, + ["message"] = message, + ["expected"] = expected, + ["observed"] = observed, + }; + if (disposition is not null) finding["disposition"] = disposition; + return finding; + } + + private static string Text(Dictionary finding, string key) => (string)finding[key]!; + + private static string Location(Dictionary finding, string key) + { + var location = (Dictionary)finding["location"]!; + return location.TryGetValue(key, out var value) && value is string text ? text : ""; + } + + private static int SeverityRank(string severity) => severity switch + { + "error" => 0, + "warning" => 1, + _ => 2, + }; + + private static Dictionary> GroupedMethods(IReadOnlyList routes) + { + var grouped = new Dictionary>(StringComparer.Ordinal); + foreach (var route in routes) + { + if (!grouped.TryGetValue(RouteShape(route.Path), out var methods)) + { + methods = new SortedSet(StringComparer.Ordinal); + grouped[RouteShape(route.Path)] = methods; + } + methods.Add(route.Method.ToUpperInvariant()); + } + return grouped.ToDictionary( + entry => entry.Key, + entry => entry.Value.ToList(), + StringComparer.Ordinal); + } + + private static string RepresentativePath(IReadOnlyList routes, string shape) + { + foreach (var route in routes) + { + if (RouteShape(route.Path) == shape) return route.Path; + } + return shape; + } + + private static string ExactShapeKey(AspNetRuntimeIntelligence.RuntimeRoute route) + => $"{route.Method.ToUpperInvariant()} {RouteShape(route.Path)}"; + + private static string RouteShape(string path) => Regex.Replace(path, @"\{[^/{}]+\}", "{}"); +} diff --git a/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetRuntimeIntelligence.cs b/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetRuntimeIntelligence.cs index 9f94db27..3047f21a 100644 --- a/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetRuntimeIntelligence.cs +++ b/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetRuntimeIntelligence.cs @@ -34,15 +34,16 @@ internal static JsonElement OpenApiDocumentElement(object value) JsonElement openApiDocument, HttpContext context, string docsPath, - string specUrl) + string specUrl, + IReadOnlyList? acknowledgedUndocumented = null) { var discovery = DiscoverRoutes(endpoints, docsPath, specUrl); var documented = DocumentedRoutes(openApiDocument); - var documentedKeys = documented.Select(RouteKey).ToHashSet(StringComparer.Ordinal); - var runtimeKeys = discovery.Routes.Select(RouteKey).ToHashSet(StringComparer.Ordinal); - var runtimeOnly = discovery.Routes.Where(route => !documentedKeys.Contains(RouteKey(route))).ToArray(); - var documentedOnly = documented.Where(route => !runtimeKeys.Contains(RouteKey(route))).ToArray(); - var matched = discovery.Routes.Count(route => documentedKeys.Contains(RouteKey(route))); + var documentedKeys = documented.Select(ShapeKey).ToHashSet(StringComparer.Ordinal); + var runtimeKeys = discovery.Routes.Select(ShapeKey).ToHashSet(StringComparer.Ordinal); + var runtimeOnly = discovery.Routes.Where(route => !documentedKeys.Contains(ShapeKey(route))).ToArray(); + var documentedOnly = documented.Where(route => !runtimeKeys.Contains(ShapeKey(route))).ToArray(); + var matched = discovery.Routes.Count(route => documentedKeys.Contains(ShapeKey(route))); var frameworkVersion = typeof(RouteEndpoint).Assembly.GetName().Version?.ToString(); var snapshot = new Dictionary @@ -76,9 +77,28 @@ internal static JsonElement OpenApiDocumentElement(object value) var environmentName = StandardEnvironmentName(); if (environmentName is not null) snapshot["environment"] = new Dictionary { ["name"] = environmentName }; + snapshot["validation"] = AspNetContractValidation.Validate( + documented, + discovery.Routes, + discovery.Complete, + acknowledgedUndocumented ?? Array.Empty()); return snapshot; } + internal static IReadOnlyList NormalizeAcknowledged(IEnumerable? configured) + { + if (configured is null) return Array.Empty(); + var routes = new List(); + foreach (var route in configured) + { + if (route is null || string.IsNullOrWhiteSpace(route.Method) || string.IsNullOrWhiteSpace(route.Path)) continue; + var method = route.Method.Trim().ToUpperInvariant(); + if (!HttpMethods.Contains(method)) continue; + routes.Add(new RuntimeRoute(method, NormalizePath(route.Path))); + } + return UniqueSorted(routes); + } + internal static (IReadOnlyList Routes, bool Complete) DiscoverRoutes( IEnumerable endpoints, string docsPath, @@ -192,6 +212,10 @@ private static IReadOnlyList WithoutImplicitHead(IReadOnlyList $"{route.Method} {route.Path}"; + /// Wire identity. Parameter names such as {id} and {petId} describe the same operation. + private static string ShapeKey(RuntimeRoute route) + => $"{route.Method} {System.Text.RegularExpressions.Regex.Replace(route.Path, @"\{[^/{}]+\}", "{}")}"; + private static Dictionary RouteObject(RuntimeRoute route) => new() { ["method"] = route.Method, diff --git a/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/FlexDocEndpointRouteBuilderExtensions.cs b/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/FlexDocEndpointRouteBuilderExtensions.cs index 90004f5a..2f0123c5 100644 --- a/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/FlexDocEndpointRouteBuilderExtensions.cs +++ b/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/FlexDocEndpointRouteBuilderExtensions.cs @@ -60,12 +60,16 @@ public static RouteGroupBuilder MapFlexDoc( // route; reverse proxies and application middleware may still enforce less. .WithMetadata(new DisableRequestSizeLimitAttribute()); if (runtimeDocument is JsonElement document) + { + var acknowledged = AspNetRuntimeIntelligence.NormalizeAcknowledged(options.AcknowledgedUndocumented); group.MapGet("/__flexdoc/runtime", context => WriteRuntime( context, endpoints, document, path, - options.SpecUrl)); + options.SpecUrl, + acknowledged)); + } return group; } @@ -93,7 +97,8 @@ private static async Task WriteRuntime( IEndpointRouteBuilder endpoints, JsonElement openApiDocument, string path, - string specUrl) + string specUrl, + IReadOnlyList acknowledgedUndocumented) { try { @@ -102,7 +107,8 @@ private static async Task WriteRuntime( openApiDocument, context, path, - specUrl); + specUrl, + acknowledgedUndocumented); var body = JsonSerializer.SerializeToUtf8Bytes(snapshot); context.Response.StatusCode = StatusCodes.Status200OK; context.Response.ContentType = "application/json; charset=utf-8"; diff --git a/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/FlexDocOptions.cs b/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/FlexDocOptions.cs index 1c1d2e68..8374b4a0 100644 --- a/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/FlexDocOptions.cs +++ b/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/FlexDocOptions.cs @@ -42,12 +42,25 @@ public FlexDocOptions() { } /// Native ASP.NET Core executor. A null executor keeps host execution unavailable and the execute route unregistered. public FlexDocHostExecution? HostExecution { get; set; } - /// Explicit opt-in for live ASP.NET Core route discovery and OpenAPI presence drift. + /// Explicit opt-in for live ASP.NET Core route discovery and the same contract-validation object Node emits. public bool RuntimeIntelligence { get; set; } + /// Runtime routes accepted as intentionally absent from OpenAPI. They stay registered. + public IReadOnlyList AcknowledgedUndocumented { get; set; } = Array.Empty(); + /// /// Server-only OpenAPI document used for Runtime Intelligence comparison. Required when RuntimeIntelligence is enabled. /// Accepts a serializable OpenAPI object, JsonElement/JsonDocument, or JSON string and is never sent to the renderer. /// public object? RuntimeOpenApiDocument { get; set; } } + +/// One runtime route the operator has accepted as undocumented. +public sealed class FlexDocAcknowledgedRoute +{ + /// HTTP method. + public string Method { get; set; } = ""; + + /// Route path. + public string Path { get; set; } = ""; +} diff --git a/adapters/dotnet/tests/Prauga.FlexDoc.AspNetCore.Tests/Program.cs b/adapters/dotnet/tests/Prauga.FlexDoc.AspNetCore.Tests/Program.cs index f2628629..18477f2c 100644 --- a/adapters/dotnet/tests/Prauga.FlexDoc.AspNetCore.Tests/Program.cs +++ b/adapters/dotnet/tests/Prauga.FlexDoc.AspNetCore.Tests/Program.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -125,12 +126,100 @@ static void Check(bool condition, string message) Check(discoveredRoutes.EnumerateArray().Any(route => route.GetProperty("path").GetString() == "/orders/{orderId}"), "ASP.NET route constraints must normalize to OpenAPI parameter syntax"); Check(!discoveredRoutes.EnumerateArray().Any(route => route.GetProperty("path").GetString() == "/openapi.json"), "OpenAPI infrastructure must be excluded"); Check(!discoveredRoutes.EnumerateArray().Any(route => route.GetProperty("path").GetString() == "/docs"), "FlexDoc infrastructure must be excluded"); + var validation = snapshotJson.GetProperty("validation"); + Check(validation.GetProperty("status").GetString() == "fail", "undocumented and unobserved routes must fail validation"); + Check(validation.GetProperty("summary").GetProperty("errors").GetInt32() == 2, "complete discovery must report both disagreements as errors"); } finally { Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalEnvironment); } +var acknowledged = new FlexDocAcknowledgedRoute { Method = "get", Path = "/internal/health" }; +var loopSpec = new { paths = new Dictionary { ["/pets"] = new { get = new { } } } }; +var loopApp = WebApplication.CreateBuilder().Build(); +loopApp.MapGet("/pets", () => Results.Ok()); +loopApp.MapGet("/internal/health", () => Results.Ok()); +loopApp.MapPost("/internal/reindex", () => Results.Ok()); +var loopContext = new DefaultHttpContext(); +loopContext.Request.Scheme = "https"; +loopContext.Request.Host = new HostString("staging.internal"); +var loopSnapshot = JsonSerializer.SerializeToElement(AspNetRuntimeIntelligence.BuildSnapshot( + ((IEndpointRouteBuilder)loopApp).DataSources.SelectMany(static source => source.Endpoints), + AspNetRuntimeIntelligence.OpenApiDocumentElement(loopSpec), + loopContext, + "/docs", + "/openapi.json", + AspNetRuntimeIntelligence.NormalizeAcknowledged(new[] { acknowledged }))); +var loopFindings = loopSnapshot.GetProperty("validation").GetProperty("findings"); +Check(loopSnapshot.GetProperty("summary").GetProperty("runtime").GetInt32() == 3, "acknowledged routes stay in the runtime count"); +Check(loopSnapshot.GetProperty("runtimeOnly").GetArrayLength() == 2, "acknowledged routes stay runtime-only"); +Check(loopSnapshot.GetProperty("validation").GetProperty("status").GetString() == "fail", "unacknowledged reindex must fail the loop"); +Check(loopFindings[0].GetProperty("severity").GetString() == "error" && loopFindings[0].GetProperty("location").GetProperty("path").GetString() == "/internal/reindex", "reindex must be the error"); +Check(!loopFindings[0].TryGetProperty("disposition", out _), "an unacknowledged finding has no disposition"); +Check(loopFindings[1].GetProperty("disposition").GetString() == "acknowledged" && loopFindings[1].GetProperty("location").GetProperty("path").GetString() == "/internal/health", "health must be acknowledged info"); + +var partialApp = WebApplication.CreateBuilder().Build(); +partialApp.MapPost("/internal/reindex", () => Results.Ok()); +var partialEndpoints = ((IEndpointRouteBuilder)partialApp).DataSources.SelectMany(static source => source.Endpoints) + .Append(new Endpoint(_ => Task.CompletedTask, EndpointMetadataCollection.Empty, "plain")); +var partialSnapshot = JsonSerializer.SerializeToElement(AspNetRuntimeIntelligence.BuildSnapshot( + partialEndpoints, + AspNetRuntimeIntelligence.OpenApiDocumentElement(new { paths = new Dictionary() }), + new DefaultHttpContext(), + "/docs", + "/openapi.json")); +var partialValidation = partialSnapshot.GetProperty("validation"); +Check(partialValidation.GetProperty("complete").GetBoolean() == false, "a non-route endpoint makes discovery partial"); +Check(partialValidation.GetProperty("status").GetString() == "warn", "partial undocumented discovery warns"); +Check(partialValidation.GetProperty("findings")[0].GetProperty("severity").GetString() == "warning", "the undocumented route is a warning when discovery is partial"); + +var shapeApp = WebApplication.CreateBuilder().Build(); +shapeApp.MapGet("/pets/{id}", () => Results.Ok()); +var shapeSnapshot = JsonSerializer.SerializeToElement(AspNetRuntimeIntelligence.BuildSnapshot( + ((IEndpointRouteBuilder)shapeApp).DataSources.SelectMany(static source => source.Endpoints), + AspNetRuntimeIntelligence.OpenApiDocumentElement(new { paths = new Dictionary { ["/pets/{petId}"] = new { get = new { } } } }), + new DefaultHttpContext(), + "/docs", + "/openapi.json")); +Check(shapeSnapshot.GetProperty("summary").GetProperty("matched").GetInt32() == 1, "parameter names must not split one wire operation"); +Check(shapeSnapshot.GetProperty("runtimeOnly").GetArrayLength() == 0, "equivalent runtime path is not runtime-only"); +Check(shapeSnapshot.GetProperty("documentedOnly").GetArrayLength() == 0, "equivalent documented path is not documented-only"); +Check(shapeSnapshot.GetProperty("validation").GetProperty("status").GetString() == "pass", "equivalent parameter names must pass validation"); + +var fixtureDir = new DirectoryInfo(AppContext.BaseDirectory); +string? fixturePath = null; +while (fixtureDir is not null) +{ + var candidate = Path.Combine(fixtureDir.FullName, "contracts", "contract-validation-fixtures.json"); + if (File.Exists(candidate)) + { + fixturePath = candidate; + break; + } + fixtureDir = fixtureDir.Parent; +} +if (fixturePath is null) throw new InvalidOperationException("contract validation fixtures were not found"); +foreach (var item in JsonNode.Parse(File.ReadAllText(fixturePath))!["cases"]!.AsArray()) +{ + var input = item!["input"]!; + var actual = JsonSerializer.SerializeToNode(AspNetContractValidation.Validate( + ConformanceRoutes(input["documentedRoutes"]), + ConformanceRoutes(input["runtimeRoutes"]), + input["discoveryComplete"]!.GetValue(), + ConformanceRoutes(input["acknowledgedUndocumented"]))); + Check(JsonNode.DeepEquals(actual, item["expected"]), $"ASP.NET validation diverged on {item["name"]}"); +} + +static List ConformanceRoutes(JsonNode? value) +{ + var routes = new List(); + if (value is not JsonArray array) return routes; + foreach (var route in array) + routes.Add(new AspNetRuntimeIntelligence.RuntimeRoute(route!["method"]!.GetValue(), route["path"]!.GetValue())); + return routes; +} + await HostExecutionConformance.RunAsync(); await HostExecutionHttpSecurityConformance.RunAsync(); await HostExecutionObservabilityConformance.RunAsync(); diff --git a/adapters/java-spring/src/main/java/com/prauga/flexdoc/spring/FlexDocProperties.java b/adapters/java-spring/src/main/java/com/prauga/flexdoc/spring/FlexDocProperties.java index 54bd7869..04cda930 100644 --- a/adapters/java-spring/src/main/java/com/prauga/flexdoc/spring/FlexDocProperties.java +++ b/adapters/java-spring/src/main/java/com/prauga/flexdoc/spring/FlexDocProperties.java @@ -41,8 +41,28 @@ public class FlexDocProperties { private int hostExecutionMaxInFlight = 16; /** Retry-After seconds returned when process-local host-execution admission is saturated. */ private int hostExecutionRetryAfterSeconds = 1; - /** Enables live Spring route discovery and OpenAPI presence drift reporting. */ + /** Enables live Spring route discovery and the same contract-validation object Node emits. */ private boolean runtimeIntelligence; + /** Runtime routes accepted as intentionally absent from OpenAPI. They stay registered. */ + private List acknowledgedUndocumented = List.of(); + + /** One runtime route the operator has accepted as undocumented. */ + public static class AcknowledgedRoute { + private String method = ""; + private String path = ""; + + /** @return the HTTP method */ + public String getMethod() { return method; } + + /** @param method the HTTP method */ + public void setMethod(String method) { this.method = method; } + + /** @return the route path */ + public String getPath() { return path; } + + /** @param path the route path */ + public void setPath(String path) { this.path = path; } + } /** @return whether FlexDoc auto-configuration is enabled */ public boolean isEnabled() { return enabled; } @@ -158,6 +178,14 @@ public void setHostExecutionRetryAfterSeconds(int hostExecutionRetryAfterSeconds /** @param runtimeIntelligence whether Runtime Intelligence is enabled */ public void setRuntimeIntelligence(boolean runtimeIntelligence) { this.runtimeIntelligence = runtimeIntelligence; } + /** @return runtime routes accepted as intentionally absent from OpenAPI */ + public List getAcknowledgedUndocumented() { return acknowledgedUndocumented; } + + /** @param acknowledgedUndocumented runtime routes accepted as intentionally absent from OpenAPI */ + public void setAcknowledgedUndocumented(List acknowledgedUndocumented) { + this.acknowledgedUndocumented = acknowledgedUndocumented == null ? List.of() : List.copyOf(acknowledgedUndocumented); + } + FlexDocConfig toConfig() { FlexDocConfig.Builder builder = FlexDocConfig.builder() .path(path) diff --git a/adapters/java-spring/src/main/java/com/prauga/flexdoc/spring/SpringContractValidation.java b/adapters/java-spring/src/main/java/com/prauga/flexdoc/spring/SpringContractValidation.java new file mode 100644 index 00000000..a293b41f --- /dev/null +++ b/adapters/java-spring/src/main/java/com/prauga/flexdoc/spring/SpringContractValidation.java @@ -0,0 +1,190 @@ +package com.prauga.flexdoc.spring; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Ports the Node runtime-contract validator. Path-parameter names are ignored for route identity. + * Acknowledged undocumented routes stay registered and are reported as informational findings. + */ +final class SpringContractValidation { + private SpringContractValidation() {} + + static Map validate( + List documentedRoutes, + List runtimeRoutes, + boolean discoveryComplete, + List acknowledgedUndocumented) { + Set documentedKeys = new LinkedHashSet<>(); + for (SpringRuntimeIntelligence.RuntimeRoute route : documentedRoutes) documentedKeys.add(exactShapeKey(route)); + Set runtimeKeys = new LinkedHashSet<>(); + for (SpringRuntimeIntelligence.RuntimeRoute route : runtimeRoutes) runtimeKeys.add(exactShapeKey(route)); + Set acknowledgedKeys = new LinkedHashSet<>(); + for (SpringRuntimeIntelligence.RuntimeRoute route : acknowledgedUndocumented) acknowledgedKeys.add(exactShapeKey(route)); + + List> findings = new ArrayList<>(); + Map> documentedMethods = groupedMethods(documentedRoutes); + Map> runtimeMethods = groupedMethods(runtimeRoutes); + Set methodMismatchShapes = new LinkedHashSet<>(); + + for (Map.Entry> entry : documentedMethods.entrySet()) { + List observedMethods = runtimeMethods.get(entry.getKey()); + if (observedMethods == null) continue; + boolean hasExact = false; + for (String method : entry.getValue()) { + if (observedMethods.contains(method)) { + hasExact = true; + break; + } + } + if (hasExact) continue; + methodMismatchShapes.add(entry.getKey()); + String path = representativePath(documentedRoutes, entry.getKey()); + findings.add(finding( + "runtime.method-mismatch", + null, + path, + discoveryComplete ? "error" : "warning", + entry.getValue().get(0), + "Runtime route " + path + " is registered for different HTTP methods than OpenAPI documents.", + entry.getValue(), + observedMethods, + null)); + } + + for (SpringRuntimeIntelligence.RuntimeRoute route : runtimeRoutes) { + String shape = routeShape(route.path()); + if (methodMismatchShapes.contains(shape) || documentedKeys.contains(exactShapeKey(route))) continue; + boolean acknowledged = acknowledgedKeys.contains(exactShapeKey(route)); + String severity = acknowledged ? "info" : (discoveryComplete ? "error" : "warning"); + findings.add(finding( + "runtime.operation-undocumented", + route.method(), + route.path(), + severity, + route.method(), + "Runtime implements " + route.method() + " " + route.path() + ", but OpenAPI does not document that operation.", + "Operation is represented in OpenAPI", + acknowledged + ? "Operation exists only in the running backend and is acknowledged" + : "Operation exists only in the running backend", + acknowledged ? "acknowledged" : null)); + } + + for (SpringRuntimeIntelligence.RuntimeRoute route : documentedRoutes) { + String shape = routeShape(route.path()); + if (methodMismatchShapes.contains(shape) || runtimeKeys.contains(exactShapeKey(route))) continue; + findings.add(finding( + "runtime.operation-unobserved", + route.method(), + route.path(), + discoveryComplete ? "error" : "info", + route.method(), + discoveryComplete + ? "OpenAPI documents " + route.method() + " " + route.path() + ", but the running backend does not expose that operation." + : "OpenAPI documents " + route.method() + " " + route.path() + ", but it was not observed during partial runtime discovery.", + "Operation is exposed by the running backend", + discoveryComplete ? "No matching runtime operation exists" : "No matching operation was observed during partial discovery", + null)); + } + + findings.sort(Comparator + .comparingInt((Map finding) -> severityRank((String) finding.get("severity"))) + .thenComparing(finding -> location(finding).get("path")) + .thenComparing(finding -> location(finding).getOrDefault("method", "")) + .thenComparing(finding -> (String) finding.get("code"))); + + int errors = 0; + int warnings = 0; + int info = 0; + for (Map finding : findings) { + switch ((String) finding.get("severity")) { + case "error" -> errors++; + case "warning" -> warnings++; + default -> info++; + } + } + String status = errors > 0 ? "fail" : warnings > 0 ? "warn" : !discoveryComplete ? "partial" : "pass"; + + Map validation = new LinkedHashMap<>(); + validation.put("status", status); + validation.put("complete", discoveryComplete); + validation.put("findings", findings); + validation.put("summary", Map.of("total", findings.size(), "errors", errors, "warnings", warnings, "info", info)); + return validation; + } + + private static Map finding( + String code, + String idMethod, + String path, + String severity, + String locationMethod, + String message, + Object expected, + Object observed, + String disposition) { + Map finding = new LinkedHashMap<>(); + finding.put("id", code + ":" + (idMethod == null ? "" : idMethod + ":") + routeShape(path)); + finding.put("code", code); + finding.put("severity", severity); + Map location = new LinkedHashMap<>(); + location.put("kind", "operation"); + location.put("path", path); + if (locationMethod != null) location.put("method", locationMethod); + finding.put("location", location); + finding.put("message", message); + finding.put("expected", expected); + finding.put("observed", observed); + if (disposition != null) finding.put("disposition", disposition); + return finding; + } + + @SuppressWarnings("unchecked") + private static Map location(Map finding) { + return (Map) finding.get("location"); + } + + private static int severityRank(String severity) { + return switch (severity) { + case "error" -> 0; + case "warning" -> 1; + default -> 2; + }; + } + + private static Map> groupedMethods(List routes) { + Map> grouped = new LinkedHashMap<>(); + for (SpringRuntimeIntelligence.RuntimeRoute route : routes) { + grouped.computeIfAbsent(routeShape(route.path()), key -> new LinkedHashSet<>()).add(route.method().toUpperCase(Locale.ROOT)); + } + Map> methods = new LinkedHashMap<>(); + for (Map.Entry> entry : grouped.entrySet()) { + List sorted = new ArrayList<>(entry.getValue()); + sorted.sort(Comparator.naturalOrder()); + methods.put(entry.getKey(), sorted); + } + return methods; + } + + private static String representativePath(List routes, String shape) { + for (SpringRuntimeIntelligence.RuntimeRoute route : routes) { + if (routeShape(route.path()).equals(shape)) return route.path(); + } + return shape; + } + + private static String exactShapeKey(SpringRuntimeIntelligence.RuntimeRoute route) { + return route.method().toUpperCase(Locale.ROOT) + " " + routeShape(route.path()); + } + + private static String routeShape(String path) { + return path.replaceAll("\\{[^/{}]+\\}", "{}"); + } +} diff --git a/adapters/java-spring/src/main/java/com/prauga/flexdoc/spring/SpringRuntimeIntelligence.java b/adapters/java-spring/src/main/java/com/prauga/flexdoc/spring/SpringRuntimeIntelligence.java index 701b71dc..665533d2 100644 --- a/adapters/java-spring/src/main/java/com/prauga/flexdoc/spring/SpringRuntimeIntelligence.java +++ b/adapters/java-spring/src/main/java/com/prauga/flexdoc/spring/SpringRuntimeIntelligence.java @@ -56,7 +56,8 @@ Map snapshot(HttpServletRequest request) throws Exception { request, properties.getPath(), properties.getSpecUrl(), - activeEnvironmentName(environment)); + activeEnvironmentName(environment), + acknowledgedRoutes(properties.getAcknowledgedUndocumented())); } static Map buildSnapshot( @@ -66,16 +67,27 @@ static Map buildSnapshot( String docsPath, String specUrl, String environmentName) { + return buildSnapshot(mappings, spec, request, docsPath, specUrl, environmentName, List.of()); + } + + static Map buildSnapshot( + Iterable mappings, + JsonNode spec, + HttpServletRequest request, + String docsPath, + String specUrl, + String environmentName, + List acknowledgedUndocumented) { Discovery discovery = discoverRoutes(mappings, docsPath, specUrl); List documented = documentedRoutes(spec); Set documentedKeys = new LinkedHashSet<>(); - for (RuntimeRoute route : documented) documentedKeys.add(routeKey(route)); + for (RuntimeRoute route : documented) documentedKeys.add(shapeKey(route)); Set runtimeKeys = new LinkedHashSet<>(); - for (RuntimeRoute route : discovery.routes()) runtimeKeys.add(routeKey(route)); + for (RuntimeRoute route : discovery.routes()) runtimeKeys.add(shapeKey(route)); - List runtimeOnly = discovery.routes().stream().filter(route -> !documentedKeys.contains(routeKey(route))).toList(); - List documentedOnly = documented.stream().filter(route -> !runtimeKeys.contains(routeKey(route))).toList(); - long matched = discovery.routes().stream().filter(route -> documentedKeys.contains(routeKey(route))).count(); + List runtimeOnly = discovery.routes().stream().filter(route -> !documentedKeys.contains(shapeKey(route))).toList(); + List documentedOnly = documented.stream().filter(route -> !runtimeKeys.contains(shapeKey(route))).toList(); + long matched = discovery.routes().stream().filter(route -> documentedKeys.contains(shapeKey(route))).count(); Map snapshot = new LinkedHashMap<>(); snapshot.put("framework", "spring"); @@ -101,9 +113,26 @@ static Map buildSnapshot( "matched", matched, "runtimeOnly", runtimeOnly.size(), "documentedOnly", documentedOnly.size())); + snapshot.put("validation", SpringContractValidation.validate( + documented, + discovery.routes(), + discovery.complete(), + acknowledgedUndocumented == null ? List.of() : acknowledgedUndocumented)); return snapshot; } + private static List acknowledgedRoutes(List configured) { + if (configured == null || configured.isEmpty()) return List.of(); + List routes = new ArrayList<>(); + for (FlexDocProperties.AcknowledgedRoute route : configured) { + if (route == null || route.getMethod() == null || route.getPath() == null) continue; + String method = route.getMethod().trim().toUpperCase(Locale.ROOT); + if (!HTTP_METHODS.contains(method) || route.getPath().isBlank()) continue; + routes.add(new RuntimeRoute(method, normalizePath(route.getPath()))); + } + return uniqueSorted(routes); + } + static Discovery discoverRoutes(Iterable mappings, String docsPath, String specUrl) { List routes = new ArrayList<>(); boolean complete = true; @@ -205,6 +234,11 @@ private static List> routeObjects(List routes) private static String routeKey(RuntimeRoute route) { return route.method() + " " + route.path(); } + /** Wire identity. Parameter names such as `{id}` and `{petId}` describe the same operation. */ + private static String shapeKey(RuntimeRoute route) { + return route.method() + " " + route.path().replaceAll("\\{[^/{}]+\\}", "{}"); + } + private static boolean isExcluded(String path, String docsPath, String specPath) { return path.equals(docsPath) || path.startsWith(docsPath + "/") || (specPath != null && path.equals(specPath)); } diff --git a/adapters/java-spring/src/test/java/com/prauga/flexdoc/spring/SpringContractValidationConformanceTest.java b/adapters/java-spring/src/test/java/com/prauga/flexdoc/spring/SpringContractValidationConformanceTest.java new file mode 100644 index 00000000..37b525bf --- /dev/null +++ b/adapters/java-spring/src/test/java/com/prauga/flexdoc/spring/SpringContractValidationConformanceTest.java @@ -0,0 +1,48 @@ +package com.prauga.flexdoc.spring; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class SpringContractValidationConformanceTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void matchesTheSharedFixture() throws Exception { + JsonNode document = objectMapper.readTree(fixturePath().toFile()); + for (JsonNode item : document.get("cases")) { + JsonNode input = item.get("input"); + var actual = SpringContractValidation.validate( + routes(input.get("documentedRoutes")), + routes(input.get("runtimeRoutes")), + input.get("discoveryComplete").asBoolean(), + routes(input.get("acknowledgedUndocumented"))); + JsonNode actualJson = objectMapper.valueToTree(actual); + assertThat(actualJson).as(item.get("name").asText()).isEqualTo(item.get("expected")); + } + } + + private static List routes(JsonNode value) { + List routes = new ArrayList<>(); + if (value == null || !value.isArray()) return routes; + for (JsonNode route : value) { + routes.add(new SpringRuntimeIntelligence.RuntimeRoute(route.get("method").asText(), route.get("path").asText())); + } + return routes; + } + + private static Path fixturePath() { + Path dir = Path.of("").toAbsolutePath(); + while (dir != null) { + Path candidate = dir.resolve("contracts").resolve("contract-validation-fixtures.json"); + if (candidate.toFile().isFile()) return candidate; + dir = dir.getParent(); + } + throw new IllegalStateException("contract validation fixtures were not found"); + } +} diff --git a/adapters/java-spring/src/test/java/com/prauga/flexdoc/spring/SpringRuntimeIntelligenceTest.java b/adapters/java-spring/src/test/java/com/prauga/flexdoc/spring/SpringRuntimeIntelligenceTest.java index 5e27d85c..80d540c1 100644 --- a/adapters/java-spring/src/test/java/com/prauga/flexdoc/spring/SpringRuntimeIntelligenceTest.java +++ b/adapters/java-spring/src/test/java/com/prauga/flexdoc/spring/SpringRuntimeIntelligenceTest.java @@ -60,6 +60,68 @@ void buildsPresenceDriftFromLiveSpringMappings() { assertThat(json.get("documentedOnly").get(0).get("path").asText()).isEqualTo("/missing"); assertThat(json.get("routes").toString()).contains("/orders/{orderId}"); assertThat(json.get("routes").toString()).doesNotContain("/v3/api-docs", "/docs"); + assertThat(json.get("validation").get("status").asText()).isEqualTo("fail"); + assertThat(json.get("validation").get("summary").get("errors").asInt()).isEqualTo(2); + } + + @Test + @SuppressWarnings("unchecked") + void disagreementLoopKeepsAcknowledgedRoutesAndFailsTheUnacknowledgedOne() throws Exception { + Map liveMappings = new LinkedHashMap<>(); + liveMappings.put(RequestMappingInfo.paths("/pets").methods(RequestMethod.GET).build(), null); + liveMappings.put(RequestMappingInfo.paths("/internal/health").methods(RequestMethod.GET).build(), null); + liveMappings.put(RequestMappingInfo.paths("/internal/reindex").methods(RequestMethod.POST).build(), null); + RequestMappingHandlerMapping handlerMapping = mock(RequestMappingHandlerMapping.class); + when(handlerMapping.getHandlerMethods()).thenReturn(liveMappings); + ObjectProvider mappingProvider = mock(ObjectProvider.class); + when(mappingProvider.getIfAvailable()).thenReturn(handlerMapping); + + FlexDocProperties.AcknowledgedRoute health = new FlexDocProperties.AcknowledgedRoute(); + health.setMethod("get"); + health.setPath("/internal/health"); + FlexDocProperties properties = new FlexDocProperties(); + properties.setAcknowledgedUndocumented(List.of(health)); + FlexDocSpecProvider specProvider = () -> Map.of("paths", Map.of("/pets", Map.of("get", Map.of()))); + SpringRuntimeIntelligence runtime = new SpringRuntimeIntelligence( + properties, specProvider, mappingProvider, objectMapper, new MockEnvironment()); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setScheme("https"); + request.addHeader("Host", "staging.internal"); + + JsonNode json = objectMapper.valueToTree(runtime.snapshot(request)); + JsonNode findings = json.get("validation").get("findings"); + + assertThat(json.get("serverOrigin").asText()).isEqualTo("https://staging.internal"); + assertThat(json.get("summary").get("runtime").asInt()).isEqualTo(3); + assertThat(json.get("runtimeOnly")).hasSize(2); + assertThat(json.get("validation").get("status").asText()).isEqualTo("fail"); + assertThat(json.get("validation").get("summary")).isEqualTo(objectMapper.valueToTree(Map.of( + "total", 2, "errors", 1, "warnings", 0, "info", 1))); + assertThat(findings.get(0).get("code").asText()).isEqualTo("runtime.operation-undocumented"); + assertThat(findings.get(0).get("severity").asText()).isEqualTo("error"); + assertThat(findings.get(0).get("location").get("method").asText()).isEqualTo("POST"); + assertThat(findings.get(0).get("location").get("path").asText()).isEqualTo("/internal/reindex"); + assertThat(findings.get(0).has("disposition")).isFalse(); + assertThat(findings.get(1).get("severity").asText()).isEqualTo("info"); + assertThat(findings.get(1).get("disposition").asText()).isEqualTo("acknowledged"); + assertThat(findings.get(1).get("location").get("path").asText()).isEqualTo("/internal/health"); + assertThat(json.get("runtimeOnly").toString()).contains("/internal/health"); + } + + @Test + void partialDiscoveryKeepsAnUndocumentedRouteAWarning() { + List mappings = List.of( + RequestMappingInfo.paths("/internal/reindex").methods(RequestMethod.POST).build(), + RequestMappingInfo.paths("/any-method").build()); + JsonNode spec = objectMapper.valueToTree(Map.of("paths", Map.of())); + + Map snapshot = SpringRuntimeIntelligence.buildSnapshot( + mappings, spec, null, "/docs", "/v3/api-docs", null); + JsonNode finding = objectMapper.valueToTree(snapshot).get("validation"); + + assertThat(finding.get("complete").asBoolean()).isFalse(); + assertThat(finding.get("status").asText()).isEqualTo("warn"); + assertThat(finding.get("findings").get(0).get("severity").asText()).isEqualTo("warning"); } @Test @@ -112,6 +174,22 @@ void marksMethodlessAndWildcardMappingsPartialInsteadOfGuessing() { assertThat(discovery.routes()).isEmpty(); } + @Test + void parameterNamesDoNotSplitOneWireOperation() { + List mappings = List.of( + RequestMappingInfo.paths("/pets/{id}").methods(RequestMethod.GET).build()); + JsonNode spec = objectMapper.valueToTree(Map.of("paths", Map.of("/pets/{petId}", Map.of("get", Map.of())))); + + JsonNode json = objectMapper.valueToTree(SpringRuntimeIntelligence.buildSnapshot( + mappings, spec, null, "/docs", "/v3/api-docs", null)); + + assertThat(json.get("summary").get("matched").asInt()).isEqualTo(1); + assertThat(json.get("runtimeOnly")).isEmpty(); + assertThat(json.get("documentedOnly")).isEmpty(); + assertThat(json.get("validation").get("status").asText()).isEqualTo("pass"); + assertThat(json.get("validation").get("findings")).isEmpty(); + } + @Test void normalizesSpringParameterSyntax() { assertThat(SpringRuntimeIntelligence.normalizeRuntimePath("/orders/{orderId:\\d+}/")) diff --git a/adapters/python/src/prauga_flexdoc/runtime_intelligence.py b/adapters/python/src/prauga_flexdoc/runtime_intelligence.py index 992474af..61ceac28 100644 --- a/adapters/python/src/prauga_flexdoc/runtime_intelligence.py +++ b/adapters/python/src/prauga_flexdoc/runtime_intelligence.py @@ -203,6 +203,14 @@ def server_port_from_asgi_scope(scope) -> int | None: return port if isinstance(port, int) and 0 < port <= 65535 else None +def _route_shape(path: str) -> str: + return re.sub(r"\{[^/{}]+\}", "{}", path) + + +def _exact_shape_key(route: dict[str, str]) -> str: + return f'{route["method"].upper()} {_route_shape(route["path"])}' + + def build_fastapi_runtime_snapshot(app, scope, exclude_prefix: str | None = None) -> dict: """Build a Runtime Intelligence route-presence snapshot for FastAPI. @@ -219,11 +227,11 @@ def build_fastapi_runtime_snapshot(app, scope, exclude_prefix: str | None = None discovery = discover_fastapi_routes(app, exclude_prefix) documented = documented_openapi_routes(spec) runtime_routes = discovery["routes"] - documented_keys = {_route_key(route) for route in documented} - runtime_keys = {_route_key(route) for route in runtime_routes} - runtime_only = [route for route in runtime_routes if _route_key(route) not in documented_keys] - documented_only = [route for route in documented if _route_key(route) not in runtime_keys] - matched = sum(1 for route in runtime_routes if _route_key(route) in documented_keys) + documented_keys = {_exact_shape_key(route) for route in documented} + runtime_keys = {_exact_shape_key(route) for route in runtime_routes} + runtime_only = [route for route in runtime_routes if _exact_shape_key(route) not in documented_keys] + documented_only = [route for route in documented if _exact_shape_key(route) not in runtime_keys] + matched = sum(1 for route in runtime_routes if _exact_shape_key(route) in documented_keys) origin = server_origin_from_asgi_scope(scope) local_port = server_port_from_asgi_scope(scope) diff --git a/adapters/python/tests/test_runtime_intelligence.py b/adapters/python/tests/test_runtime_intelligence.py index 080bb7c6..1958afac 100644 --- a/adapters/python/tests/test_runtime_intelligence.py +++ b/adapters/python/tests/test_runtime_intelligence.py @@ -80,6 +80,17 @@ def test_builds_fastapi_presence_drift_from_live_routes(self): }) self.assertNotIn({"method": "HEAD", "path": "/mounted/items/{item_id}"}, snapshot["routes"]) self.assertNotIn("environment", snapshot) + self.assertNotIn("validation", snapshot) + + def test_parameter_names_do_not_split_one_wire_operation(self): + app = FakeFastAPI([FakeRoute("/pets/{id}", {"GET"})]) + app.openapi = lambda: {"paths": {"/pets/{petId}": {"get": {}}}} + + snapshot = build_fastapi_runtime_snapshot(app, {"scheme": "http", "headers": [], "server": ("127.0.0.1", 8000)}) + + self.assertEqual(snapshot["summary"]["matched"], 1) + self.assertEqual(snapshot["runtimeOnly"], []) + self.assertEqual(snapshot["documentedOnly"], []) def test_marks_opaque_mounts_partial(self): app = FakeFastAPI([FakeMount("/opaque", [])]) diff --git a/contracts/contract-validation-fixtures.json b/contracts/contract-validation-fixtures.json new file mode 100644 index 00000000..e7167273 --- /dev/null +++ b/contracts/contract-validation-fixtures.json @@ -0,0 +1,342 @@ +{ + "cases": [ + { + "name": "exact-match", + "input": { + "documentedRoutes": [ + { + "method": "GET", + "path": "/pets" + } + ], + "runtimeRoutes": [ + { + "method": "GET", + "path": "/pets" + } + ], + "discoveryComplete": true + }, + "expected": { + "status": "pass", + "complete": true, + "findings": [], + "summary": { + "total": 0, + "errors": 0, + "warnings": 0, + "info": 0 + } + } + }, + { + "name": "parameter-name-equivalence", + "input": { + "documentedRoutes": [ + { + "method": "GET", + "path": "/pets/{petId}" + } + ], + "runtimeRoutes": [ + { + "method": "GET", + "path": "/pets/{id}" + } + ], + "discoveryComplete": true + }, + "expected": { + "status": "pass", + "complete": true, + "findings": [], + "summary": { + "total": 0, + "errors": 0, + "warnings": 0, + "info": 0 + } + } + }, + { + "name": "undocumented-complete", + "input": { + "documentedRoutes": [ + { + "method": "GET", + "path": "/pets" + } + ], + "runtimeRoutes": [ + { + "method": "GET", + "path": "/pets" + }, + { + "method": "POST", + "path": "/internal/reindex" + } + ], + "discoveryComplete": true + }, + "expected": { + "status": "fail", + "complete": true, + "findings": [ + { + "id": "runtime.operation-undocumented:POST:/internal/reindex", + "code": "runtime.operation-undocumented", + "severity": "error", + "location": { + "kind": "operation", + "method": "POST", + "path": "/internal/reindex" + }, + "message": "Runtime implements POST /internal/reindex, but OpenAPI does not document that operation.", + "expected": "Operation is represented in OpenAPI", + "observed": "Operation exists only in the running backend" + } + ], + "summary": { + "total": 1, + "errors": 1, + "warnings": 0, + "info": 0 + } + } + }, + { + "name": "undocumented-partial", + "input": { + "documentedRoutes": [ + { + "method": "GET", + "path": "/pets" + } + ], + "runtimeRoutes": [ + { + "method": "POST", + "path": "/internal/reindex" + } + ], + "discoveryComplete": false + }, + "expected": { + "status": "warn", + "complete": false, + "findings": [ + { + "id": "runtime.operation-undocumented:POST:/internal/reindex", + "code": "runtime.operation-undocumented", + "severity": "warning", + "location": { + "kind": "operation", + "method": "POST", + "path": "/internal/reindex" + }, + "message": "Runtime implements POST /internal/reindex, but OpenAPI does not document that operation.", + "expected": "Operation is represented in OpenAPI", + "observed": "Operation exists only in the running backend" + }, + { + "id": "runtime.operation-unobserved:GET:/pets", + "code": "runtime.operation-unobserved", + "severity": "info", + "location": { + "kind": "operation", + "method": "GET", + "path": "/pets" + }, + "message": "OpenAPI documents GET /pets, but it was not observed during partial runtime discovery.", + "expected": "Operation is exposed by the running backend", + "observed": "No matching operation was observed during partial discovery" + } + ], + "summary": { + "total": 2, + "errors": 0, + "warnings": 1, + "info": 1 + } + } + }, + { + "name": "acknowledged-undocumented", + "input": { + "documentedRoutes": [ + { + "method": "GET", + "path": "/pets" + } + ], + "runtimeRoutes": [ + { + "method": "GET", + "path": "/pets" + }, + { + "method": "GET", + "path": "/internal/health" + } + ], + "acknowledgedUndocumented": [ + { + "method": "GET", + "path": "/internal/health" + } + ], + "discoveryComplete": true + }, + "expected": { + "status": "pass", + "complete": true, + "findings": [ + { + "id": "runtime.operation-undocumented:GET:/internal/health", + "code": "runtime.operation-undocumented", + "severity": "info", + "disposition": "acknowledged", + "location": { + "kind": "operation", + "method": "GET", + "path": "/internal/health" + }, + "message": "Runtime implements GET /internal/health, but OpenAPI does not document that operation.", + "expected": "Operation is represented in OpenAPI", + "observed": "Operation exists only in the running backend and is acknowledged" + } + ], + "summary": { + "total": 1, + "errors": 0, + "warnings": 0, + "info": 1 + } + } + }, + { + "name": "documented-unobserved-complete", + "input": { + "documentedRoutes": [ + { + "method": "GET", + "path": "/pets/{petId}" + } + ], + "runtimeRoutes": [], + "discoveryComplete": true + }, + "expected": { + "status": "fail", + "complete": true, + "findings": [ + { + "id": "runtime.operation-unobserved:GET:/pets/{}", + "code": "runtime.operation-unobserved", + "severity": "error", + "location": { + "kind": "operation", + "method": "GET", + "path": "/pets/{petId}" + }, + "message": "OpenAPI documents GET /pets/{petId}, but the running backend does not expose that operation.", + "expected": "Operation is exposed by the running backend", + "observed": "No matching runtime operation exists" + } + ], + "summary": { + "total": 1, + "errors": 1, + "warnings": 0, + "info": 0 + } + } + }, + { + "name": "documented-unobserved-partial", + "input": { + "documentedRoutes": [ + { + "method": "GET", + "path": "/pets/{petId}" + } + ], + "runtimeRoutes": [], + "discoveryComplete": false + }, + "expected": { + "status": "partial", + "complete": false, + "findings": [ + { + "id": "runtime.operation-unobserved:GET:/pets/{}", + "code": "runtime.operation-unobserved", + "severity": "info", + "location": { + "kind": "operation", + "method": "GET", + "path": "/pets/{petId}" + }, + "message": "OpenAPI documents GET /pets/{petId}, but it was not observed during partial runtime discovery.", + "expected": "Operation is exposed by the running backend", + "observed": "No matching operation was observed during partial discovery" + } + ], + "summary": { + "total": 1, + "errors": 0, + "warnings": 0, + "info": 1 + } + } + }, + { + "name": "method-mismatch", + "input": { + "documentedRoutes": [ + { + "method": "POST", + "path": "/pets/{petId}" + } + ], + "runtimeRoutes": [ + { + "method": "GET", + "path": "/pets/{id}" + } + ], + "discoveryComplete": true + }, + "expected": { + "status": "fail", + "complete": true, + "findings": [ + { + "id": "runtime.method-mismatch:/pets/{}", + "code": "runtime.method-mismatch", + "severity": "error", + "location": { + "kind": "operation", + "method": "POST", + "path": "/pets/{petId}" + }, + "message": "Runtime route /pets/{petId} is registered for different HTTP methods than OpenAPI documents.", + "expected": [ + "POST" + ], + "observed": [ + "GET" + ] + } + ], + "summary": { + "total": 1, + "errors": 1, + "warnings": 0, + "info": 0 + } + } + } + ] +} diff --git a/docs/runtime-intelligence.md b/docs/runtime-intelligence.md index 5d7a250c..b998ed1e 100644 --- a/docs/runtime-intelligence.md +++ b/docs/runtime-intelligence.md @@ -81,7 +81,7 @@ flexdoc: Spring Runtime Intelligence requires a `FlexDocSpecProvider`. Existing `spec-location` configuration creates one automatically; code-first applications can provide the exact generated OpenAPI model as a bean. FlexDoc deliberately does not fetch `spec-url` server-side and does not couple this feature to springdoc internals. -FastAPI, ASP.NET Core, and Spring MVC continue to emit the compatible route-level Runtime Intelligence snapshot in this 3.1 cut, but they do **not** yet emit the structured `validation` object. The renderer keeps showing route presence/drift for those snapshots; the CLI intentionally refuses to invent a second validator when `validation` is absent. +Spring MVC and ASP.NET Core emit the same structured `validation` object as the Node integrations. Spring acknowledgement is `flexdoc.acknowledged-undocumented`. ASP.NET Core acknowledgement is `FlexDocOptions.AcknowledgedUndocumented`. FastAPI, Flask, and Django still emit the route snapshot without that object. The CLI refuses to invent a validator when `validation` is absent. Snapshot membership (`matched`, `runtimeOnly`, `documentedOnly`) uses the same wire identity as validation, so `{id}` and `{petId}` are one operation. When enabled, FlexDoc registers `GET /__flexdoc/runtime` under the documentation route. The renderer requests that endpoint and shows: @@ -140,7 +140,7 @@ A snapshot has one framework-neutral shape. The runtime `name` identifies the ho } ``` -`serverOrigin`, `server`, and `environment` are optional. Missing values are omitted rather than guessed. `validation` is also optional at the renderer-contract level so 3.0/native route snapshots remain compatible; in this cut it is produced by the Node Express/Fastify/Hono/Nest integrations. +`serverOrigin`, `server`, and `environment` are optional. Missing values are omitted rather than guessed. `validation` is also optional at the renderer-contract level so route snapshots without it remain compatible. Node Express/Fastify/Hono/Nest, Spring MVC, and ASP.NET Core produce it. ## 3.1 Contract Validation diff --git a/examples/dotnet-aspnetcore/Program.cs b/examples/dotnet-aspnetcore/Program.cs index 8a5c68af..11e01138 100644 --- a/examples/dotnet-aspnetcore/Program.cs +++ b/examples/dotnet-aspnetcore/Program.cs @@ -25,7 +25,8 @@ app.MapGet("/openapi.json", () => Results.Json(openApiDocument)); app.MapGet("/health", () => Results.Ok(new { status = "ok" })); -app.MapPost("/internal", () => Results.Ok(new { status = "internal" })); +app.MapGet("/internal/health", () => Results.Ok(new { status = "internal-ok" })); +app.MapPost("/internal/reindex", () => Results.Ok(new { accepted = true })); app.MapFlexDoc(options => { options.Path = "/docs"; @@ -33,6 +34,10 @@ options.Title = "FlexDoc ASP.NET Core Example"; options.RuntimeIntelligence = true; options.RuntimeOpenApiDocument = openApiDocument; + options.AcknowledgedUndocumented = new[] + { + new FlexDocAcknowledgedRoute { Method = "GET", Path = "/internal/health" }, + }; }); app.Run(); diff --git a/examples/dotnet-aspnetcore/README.md b/examples/dotnet-aspnetcore/README.md index 84519bc7..fac52f1d 100644 --- a/examples/dotnet-aspnetcore/README.md +++ b/examples/dotnet-aspnetcore/README.md @@ -1,13 +1,25 @@ -# FlexDoc ASP.NET Core 3.3 example +# ASP.NET Core disagreement loop -Minimal ASP.NET Core application using `Prauga.FlexDoc.AspNetCore` `0.7.0` and the FlexDoc Runtime Intelligence surface. +This example compares the live `EndpointDataSource` with the same OpenAPI document the application serves at `/openapi.json`. + +| Route | What FlexDoc records | Validation | +| --- | --- | --- | +| `GET /health` | Documented and registered | Matched. | +| `GET /internal/health` | Runtime-only, listed in `AcknowledgedUndocumented` | Passes. The finding stays informational, and the route stays in the runtime record. | +| `POST /internal/reindex` | Runtime-only, not acknowledged | Fails. The finding names the method and path. | + +Open **Runtime**, then `POST /internal/reindex`. The page shows that OpenAPI does not declare the operation beside the route ASP.NET Core registered. **Open in API Client** sends that method and path. ```bash dotnet run --project examples/dotnet-aspnetcore/Prauga.FlexDoc.AspNetCore.Example.csproj ``` -Open `/docs`. The application exposes its exact OpenAPI document at `/openapi.json` and passes that same server-side document to `RuntimeOpenApiDocument`, allowing FlexDoc to compare the live `EndpointDataSource` with the documented operations without depending on Swashbuckle or NSwag. +Open `/docs`. + +```bash +npx @prauga/flexdoc-cli validate http://localhost:5000/docs/__flexdoc/runtime +``` -`GET /health` is documented, while `POST /internal` is deliberately runtime-only. Open the **Runtime** panel to see the drift detected from the running ASP.NET Core router. The browser-facing renderer is the same canonical surface used by every adapter, including Try It/API Client handoff, persisted preferences and response inspection. +The command prints the acknowledged health route and exits 1 on `POST /internal/reindex`. The listen URL is the one `dotnet run` prints. -During repository CI the example references the adapter project directly and the adapter embeds the canonical renderer built from `packages/client`. The standalone NuGet release for FlexDoc 3.3 is `Prauga.FlexDoc.AspNetCore` `0.7.0`. +The example project references the adapter in this repository. The published NuGet package remains `Prauga.FlexDoc.AspNetCore` `0.7.0`, and that package reports the route snapshot without the validation object. diff --git a/examples/java-spring/README.md b/examples/java-spring/README.md index 0b6e5335..62f2e209 100644 --- a/examples/java-spring/README.md +++ b/examples/java-spring/README.md @@ -1,10 +1,13 @@ -# Spring Boot + FlexDoc 3.3 Runtime Intelligence +# Spring MVC disagreement loop -This Spring MVC example keeps the standard `springdoc-openapi` code-first annotations, but the FlexDoc Runtime Intelligence comparison deliberately uses an exact checked-in OpenAPI document through `flexdoc.spec-location=classpath:/openapi.json`. +This example compares Spring's live `RequestMappingHandlerMapping` with a checked-in OpenAPI document (`flexdoc.spec-location=classpath:/openapi.json`). The two sources can disagree. FlexDoc does not fetch `/v3/api-docs` and does not read springdoc internals. -That separation is intentional: FlexDoc can inspect the live `RequestMappingHandlerMapping` registry without coupling the adapter to springdoc internals or making a server-side HTTP request back to `/v3/api-docs`. The checked-in document creates the `FlexDocSpecProvider` required by Runtime Intelligence. +| Route | What FlexDoc records | Validation | +| --- | --- | --- | +| `GET /internal/health` | Runtime-only, listed in `flexdoc.acknowledged-undocumented` | Passes. The finding stays informational, and the route stays in the runtime record. | +| `POST /internal/reindex` | Runtime-only, not acknowledged | Fails. The finding names the method and path. | -The contract documents the normal pet/upload operations. `GET /internal/health` exists only in the running Spring application, so the **Runtime** panel reports a real implemented-but-undocumented route. The same `/docs` surface also exposes the canonical renderer/API Client workflows. +Open **Runtime**, then `POST /internal/reindex`. The page shows that OpenAPI does not declare the operation beside the route Spring registered. **Open in API Client** sends that method and path. ```bash mvn spring-boot:run @@ -12,4 +15,10 @@ mvn spring-boot:run Open `http://localhost:8080/docs`. -The starter is pinned through `0.10.0`, the Java family release published for FlexDoc 3.3. CI installs the Java adapter family built from the same commit before building this example. +```bash +npx @prauga/flexdoc-cli validate http://localhost:8080/docs/__flexdoc/runtime +``` + +The command prints the acknowledged health route and exits 1 on `POST /internal/reindex`. + +The starter pin in this example remains `0.10.0`, the published Java family release. That published starter reports the route snapshot and does not emit the validation object. CI installs the starter built from this repository before packaging the example, and that build does. diff --git a/examples/java-spring/src/main/java/com/prauga/example/FlexDocExampleApplication.java b/examples/java-spring/src/main/java/com/prauga/example/FlexDocExampleApplication.java index 5cb27a01..11dec19b 100644 --- a/examples/java-spring/src/main/java/com/prauga/example/FlexDocExampleApplication.java +++ b/examples/java-spring/src/main/java/com/prauga/example/FlexDocExampleApplication.java @@ -109,12 +109,18 @@ public Map upload( "caption", caption); } - // Intentionally absent from the checked-in OpenAPI contract used by FlexDoc Runtime Intelligence. + // Acknowledged in application.properties. The finding stays informational. @GetMapping("/internal/health") public Map internalHealth() { return Map.of("status", "internal-ok"); } + // Absent from the checked-in OpenAPI contract and not acknowledged, so validation fails. + @PostMapping("/internal/reindex") + public Map internalReindex() { + return Map.of("accepted", true); + } + public record PetInput( @Schema(description = "Pet name", example = "Miso", minLength = 1) String name, diff --git a/examples/java-spring/src/main/resources/application.properties b/examples/java-spring/src/main/resources/application.properties index 07f63a83..ca7184d0 100644 --- a/examples/java-spring/src/main/resources/application.properties +++ b/examples/java-spring/src/main/resources/application.properties @@ -9,3 +9,5 @@ flexdoc.try-it-default-server=http://localhost:8080 flexdoc.try-it-credentials=same-origin flexdoc.try-it-api-client-persistence-key=flexdoc-spring-3-showcase flexdoc.runtime-intelligence=true +flexdoc.acknowledged-undocumented[0].method=GET +flexdoc.acknowledged-undocumented[0].path=/internal/health diff --git a/packages/backend/src/contract-validation.conformance.test.ts b/packages/backend/src/contract-validation.conformance.test.ts new file mode 100644 index 00000000..cfb0e443 --- /dev/null +++ b/packages/backend/src/contract-validation.conformance.test.ts @@ -0,0 +1,17 @@ +import fs from 'fs'; +import path from 'path'; +import { validateRuntimeContract } from './contract-validation'; + +const fixturePath = path.resolve(__dirname, '../../../contracts/contract-validation-fixtures.json'); +const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as { + cases: Array<{ name: string; input: Parameters[0]; expected: ReturnType }>; +}; + +describe('shared contract validation fixture', () => { + it.each(fixture.cases)('$name matches the language-neutral fixture', ({ input, expected }) => { + expect(validateRuntimeContract({ + ...input, + acknowledgedUndocumented: input.acknowledgedUndocumented || [], + })).toEqual(expected); + }); +});