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