From ed88a6ae3dc5523d5908c6d3f225a0e6c73c6b09 Mon Sep 17 00:00:00 2001 From: Vishnu R Date: Wed, 23 Sep 2026 10:45:47 +0530 Subject: [PATCH 1/4] feat: emit the Node validation object from Spring MVC Spring already reads the live request mappings and an exact OpenAPI document. The snapshot now carries the same disagreement findings, including acknowledgement that keeps the route in the runtime record. Signed-off-by: Vishnu R --- CHANGELOG.md | 2 + README.md | 2 +- .../flexdoc/spring/FlexDocProperties.java | 30 ++- .../spring/SpringContractValidation.java | 190 ++++++++++++++++++ .../spring/SpringRuntimeIntelligence.java | 31 ++- .../spring/SpringRuntimeIntelligenceTest.java | 62 ++++++ docs/runtime-intelligence.md | 4 +- examples/java-spring/README.md | 19 +- .../example/FlexDocExampleApplication.java | 8 +- .../src/main/resources/application.properties | 2 + 10 files changed, 339 insertions(+), 11 deletions(-) create mode 100644 adapters/java-spring/src/main/java/com/prauga/flexdoc/spring/SpringContractValidation.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 2231984d..3006d36d 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 emits the same runtime-contract validation object as Node. A complete-discovery undocumented route fails `flexdoc validate` by method and path. `flexdoc.acknowledged-undocumented` keeps that route registered as acknowledged info. + ## 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/README.md b/README.md index 7295c72a..4afaa35d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@

-FlexDoc runs inside the backend and reads the routes the framework actually registered. **On Node today,** it compares that runtime surface with OpenAPI, fails `flexdoc validate` on unacknowledged disagreement, and lets you open an undocumented runtime route directly in the built-in API Client. +FlexDoc runs inside the backend and reads the routes the framework actually registered. **On Node and Spring,** it compares that runtime surface with OpenAPI, fails `flexdoc validate` on unacknowledged disagreement, and lets you open an undocumented runtime route directly in the built-in API Client. It also provides self-hosted API documentation, exploration, and execution. No FlexDoc account or control plane required. 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..d928ab5f 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,6 +67,17 @@ 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<>(); @@ -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; 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..667a3454 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 diff --git a/docs/runtime-intelligence.md b/docs/runtime-intelligence.md index 5d7a250c..a6cc7c15 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 emits the same structured `validation` object as the Node integrations, including `runtimeIntelligence` acknowledgement through `flexdoc.acknowledged-undocumented`. FastAPI and ASP.NET Core continue to emit the compatible route-level Runtime Intelligence snapshot without that object. The renderer keeps showing route presence for those snapshots; the CLI refuses to invent a validator when `validation` is absent. 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 and Spring MVC produce it. ## 3.1 Contract Validation 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 From e2ee3bb1914f55145e2330c3920d9857d5c61c2d Mon Sep 17 00:00:00 2001 From: Vishnu R Date: Wed, 23 Sep 2026 12:03:07 +0530 Subject: [PATCH 2/4] feat: emit the same validation object from ASP.NET Core EndpointDataSource already supplies the live routes and the exact OpenAPI document. The snapshot now carries the same disagreement findings as Node and Spring, including acknowledgement that keeps the route registered. Signed-off-by: Vishnu R --- CHANGELOG.md | 2 +- README.md | 2 +- .../AspNetContractValidation.cs | 183 ++++++++++++++++++ .../AspNetRuntimeIntelligence.cs | 22 ++- .../FlexDocEndpointRouteBuilderExtensions.cs | 12 +- .../FlexDocOptions.cs | 15 +- .../Program.cs | 42 ++++ docs/runtime-intelligence.md | 4 +- examples/dotnet-aspnetcore/Program.cs | 7 +- examples/dotnet-aspnetcore/README.md | 22 ++- 10 files changed, 296 insertions(+), 15 deletions(-) create mode 100644 adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetContractValidation.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3006d36d..7da15e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Notable FlexDoc product releases are recorded here. Detailed release notes remai ## Unreleased -- Spring MVC emits the same runtime-contract validation object as Node. A complete-discovery undocumented route fails `flexdoc validate` by method and path. `flexdoc.acknowledged-undocumented` keeps that route registered as acknowledged info. +- 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. ## 3.5.0 — Node disagreement loop diff --git a/README.md b/README.md index 4afaa35d..2eb61d66 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@

-FlexDoc runs inside the backend and reads the routes the framework actually registered. **On Node and Spring,** it compares that runtime surface with OpenAPI, fails `flexdoc validate` on unacknowledged disagreement, and lets you open an undocumented runtime route directly in the built-in API Client. +FlexDoc runs inside the backend and reads the routes the framework actually registered. **On Node, Spring, and ASP.NET Core,** it compares that runtime surface with OpenAPI, fails `flexdoc validate` on unacknowledged disagreement, and lets you open an undocumented runtime route directly in the built-in API Client. It also provides self-hosted API documentation, exploration, and execution. No FlexDoc account or control plane required. 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..4a1877c9 100644 --- a/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetRuntimeIntelligence.cs +++ b/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetRuntimeIntelligence.cs @@ -34,7 +34,8 @@ 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); @@ -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, 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..53450ce7 100644 --- a/adapters/dotnet/tests/Prauga.FlexDoc.AspNetCore.Tests/Program.cs +++ b/adapters/dotnet/tests/Prauga.FlexDoc.AspNetCore.Tests/Program.cs @@ -125,12 +125,54 @@ 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"); + await HostExecutionConformance.RunAsync(); await HostExecutionHttpSecurityConformance.RunAsync(); await HostExecutionObservabilityConformance.RunAsync(); diff --git a/docs/runtime-intelligence.md b/docs/runtime-intelligence.md index a6cc7c15..d65d8bc0 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. -Spring MVC emits the same structured `validation` object as the Node integrations, including `runtimeIntelligence` acknowledgement through `flexdoc.acknowledged-undocumented`. FastAPI and ASP.NET Core continue to emit the compatible route-level Runtime Intelligence snapshot without that object. The renderer keeps showing route presence for those snapshots; the CLI refuses to invent a 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 continues to emit the compatible route-level Runtime Intelligence snapshot without that object. The renderer keeps showing route presence for that snapshot; the CLI refuses to invent a validator when `validation` is absent. 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 route snapshots without it remain compatible. Node Express/Fastify/Hono/Nest and Spring MVC produce it. +`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. From 0897a4df1dc12eb508f17ae658af444db3f08f50 Mon Sep 17 00:00:00 2001 From: Vishnu R Date: Wed, 23 Sep 2026 12:44:57 +0530 Subject: [PATCH 3/4] feat: emit the same validation object from FastAPI FastAPI already reads the live Starlette routes and the generated OpenAPI document. The snapshot now carries the same disagreement findings, including acknowledgement of a route hidden with include_in_schema=False. Signed-off-by: Vishnu R --- CHANGELOG.md | 2 +- README.md | 2 +- .../python/src/prauga_flexdoc/integrations.py | 6 +- .../prauga_flexdoc/runtime_intelligence.py | 131 +++++++++++++++++- .../python/tests/test_runtime_intelligence.py | 40 ++++++ docs/runtime-intelligence.md | 4 +- examples/python-fastapi/README.md | 4 +- examples/python-fastapi/app.py | 9 +- 8 files changed, 189 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7da15e59..f9347abb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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. +- Spring MVC, ASP.NET Core, and FastAPI 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. ## 3.5.0 — Node disagreement loop diff --git a/README.md b/README.md index 2eb61d66..57879ee2 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@

-FlexDoc runs inside the backend and reads the routes the framework actually registered. **On Node, Spring, and ASP.NET Core,** it compares that runtime surface with OpenAPI, fails `flexdoc validate` on unacknowledged disagreement, and lets you open an undocumented runtime route directly in the built-in API Client. +FlexDoc runs inside the backend and reads the routes the framework actually registered. **On Node, Spring, ASP.NET Core, and FastAPI,** it compares that runtime surface with OpenAPI, fails `flexdoc validate` on unacknowledged disagreement, and lets you open an undocumented runtime route directly in the built-in API Client. It also provides self-hosted API documentation, exploration, and execution. No FlexDoc account or control plane required. diff --git a/adapters/python/src/prauga_flexdoc/integrations.py b/adapters/python/src/prauga_flexdoc/integrations.py index bbf1801b..a812f8c8 100644 --- a/adapters/python/src/prauga_flexdoc/integrations.py +++ b/adapters/python/src/prauga_flexdoc/integrations.py @@ -45,6 +45,7 @@ def setup_fastapi_flexdoc( try_it_credentials: Literal["omit", "same-origin", "include"] | None = None, try_it_api_client_persistence_key: str | Literal[False] | None = None, runtime_intelligence: bool = False, + acknowledged_undocumented: list[dict[str, str]] | None = None, try_it_host_execution: bool = False, try_it_host_execution_allowed_origins: list[str] | tuple[str, ...] | None = None, try_it_host_execution_metric_sink: Callable[[object], None] | None = None, @@ -62,6 +63,8 @@ def setup_fastapi_flexdoc( try_it_credentials: Optional fetch credentials mode for Try It requests. try_it_api_client_persistence_key: Optional persistence key, or ``False``. runtime_intelligence: When ``True``, expose a live runtime snapshot endpoint. + acknowledged_undocumented: Runtime routes accepted as intentionally absent from OpenAPI. + They stay registered and are reported as informational findings. try_it_host_execution: When ``True``, mount the native API-host execution route. try_it_host_execution_allowed_origins: Required exact HTTP(S) origins for native execution. Wildcards and paths are not accepted. @@ -86,7 +89,8 @@ def setup_fastapi_flexdoc( runtime_provider = None runtime_framework = None if runtime_intelligence: - runtime_provider = lambda scope: build_fastapi_runtime_snapshot(app, scope, normalized_path) + runtime_provider = lambda scope: build_fastapi_runtime_snapshot( + app, scope, normalized_path, acknowledged_undocumented=acknowledged_undocumented) runtime_framework = "fastapi" host_execution = None diff --git a/adapters/python/src/prauga_flexdoc/runtime_intelligence.py b/adapters/python/src/prauga_flexdoc/runtime_intelligence.py index 992474af..55da0d5f 100644 --- a/adapters/python/src/prauga_flexdoc/runtime_intelligence.py +++ b/adapters/python/src/prauga_flexdoc/runtime_intelligence.py @@ -203,7 +203,130 @@ def server_port_from_asgi_scope(scope) -> int | None: return port if isinstance(port, int) and 0 < port <= 65535 else None -def build_fastapi_runtime_snapshot(app, scope, exclude_prefix: str | None = None) -> dict: +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 _acknowledged_routes(value) -> list[dict[str, str]]: + routes: list[dict[str, str]] = [] + for entry in value or []: + if not isinstance(entry, dict): + continue + method = str(entry.get("method", "")).strip().upper() + path = entry.get("path") + if method not in _HTTP_METHODS or not isinstance(path, str) or not path.strip(): + continue + routes.append({"method": method, "path": normalize_runtime_path(path)}) + return _unique_sorted(routes) + + +def validate_runtime_contract(documented_routes, runtime_routes, discovery_complete: bool, acknowledged_undocumented=None) -> dict: + """Compare documented operations with discovered routes using the Node validator's rules. + + Args: + documented_routes: Normalized OpenAPI operations. + runtime_routes: Normalized routes the framework registered. + discovery_complete: Whether the runtime inventory is complete enough for absence claims. + acknowledged_undocumented: Routes accepted as intentionally absent from OpenAPI. + + Returns: + The same validation object Node, Spring, and ASP.NET Core emit. + """ + documented_keys = {_exact_shape_key(route) for route in documented_routes} + runtime_keys = {_exact_shape_key(route) for route in runtime_routes} + acknowledged_keys = {_exact_shape_key(route) for route in _acknowledged_routes(acknowledged_undocumented)} + findings = [] + + def grouped(routes): + groups: dict[str, set[str]] = {} + for route in routes: + groups.setdefault(_route_shape(route["path"]), set()).add(route["method"].upper()) + return {shape: sorted(methods) for shape, methods in groups.items()} + + documented_methods = grouped(documented_routes) + runtime_methods = grouped(runtime_routes) + mismatch_shapes = set() + for shape, expected_methods in documented_methods.items(): + observed_methods = runtime_methods.get(shape) + if observed_methods is None or any(method in observed_methods for method in expected_methods): + continue + mismatch_shapes.add(shape) + path = next((route["path"] for route in documented_routes if _route_shape(route["path"]) == shape), shape) + findings.append(_finding( + "runtime.method-mismatch", None, path, + "error" if discovery_complete else "warning", + expected_methods[0], + f"Runtime route {path} is registered for different HTTP methods than OpenAPI documents.", + expected_methods, observed_methods, None, + )) + + for route in runtime_routes: + shape = _route_shape(route["path"]) + if shape in mismatch_shapes or _exact_shape_key(route) in documented_keys: + continue + acknowledged = _exact_shape_key(route) in acknowledged_keys + findings.append(_finding( + "runtime.operation-undocumented", route["method"], route["path"], + "info" if acknowledged else "error" if discovery_complete else "warning", + route["method"], + f'Runtime implements {route["method"]} {route["path"]}, but OpenAPI does not document that operation.', + "Operation is represented in OpenAPI", + "Operation exists only in the running backend and is acknowledged" if acknowledged else "Operation exists only in the running backend", + "acknowledged" if acknowledged else None, + )) + + for route in documented_routes: + shape = _route_shape(route["path"]) + if shape in mismatch_shapes or _exact_shape_key(route) in runtime_keys: + continue + findings.append(_finding( + "runtime.operation-unobserved", route["method"], route["path"], + "error" if discovery_complete else "info", + route["method"], + f'OpenAPI documents {route["method"]} {route["path"]}, but the running backend does not expose that operation.' if discovery_complete + else f'OpenAPI documents {route["method"]} {route["path"]}, but it was not observed during partial runtime discovery.', + "Operation is exposed by the running backend", + "No matching runtime operation exists" if discovery_complete else "No matching operation was observed during partial discovery", + None, + )) + + rank = {"error": 0, "warning": 1, "info": 2} + findings.sort(key=lambda finding: ( + rank[finding["severity"]], + finding["location"]["path"], + finding["location"].get("method", ""), + finding["code"], + )) + summary = { + "total": len(findings), + "errors": sum(finding["severity"] == "error" for finding in findings), + "warnings": sum(finding["severity"] == "warning" for finding in findings), + "info": sum(finding["severity"] == "info" for finding in findings), + } + status = "fail" if summary["errors"] else "warn" if summary["warnings"] else "pass" if discovery_complete else "partial" + return {"status": status, "complete": discovery_complete, "findings": findings, "summary": summary} + + +def _finding(code, id_method, path, severity, location_method, message, expected, observed, disposition): + finding = { + "id": f'{code}:' + (f"{id_method}:" if id_method else "") + _route_shape(path), + "code": code, + "severity": severity, + "location": {"kind": "operation", "path": path, **({"method": location_method} if location_method else {})}, + "message": message, + "expected": expected, + "observed": observed, + } + if disposition: + finding["disposition"] = disposition + return finding + + +def build_fastapi_runtime_snapshot(app, scope, exclude_prefix: str | None = None, acknowledged_undocumented=None) -> dict: """Build a Runtime Intelligence route-presence snapshot for FastAPI. Args: @@ -244,6 +367,12 @@ def build_fastapi_runtime_snapshot(app, scope, exclude_prefix: str | None = None "runtimeOnly": len(runtime_only), "documentedOnly": len(documented_only), }, + "validation": validate_runtime_contract( + documented, + runtime_routes, + discovery["complete"], + acknowledged_undocumented, + ), } diff --git a/adapters/python/tests/test_runtime_intelligence.py b/adapters/python/tests/test_runtime_intelligence.py index 080bb7c6..3360aba5 100644 --- a/adapters/python/tests/test_runtime_intelligence.py +++ b/adapters/python/tests/test_runtime_intelligence.py @@ -80,6 +80,46 @@ 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.assertEqual(snapshot["validation"]["status"], "fail") + self.assertEqual(snapshot["validation"]["summary"]["errors"], 2) + + def test_acknowledged_health_stays_registered_while_reindex_fails(self): + app = FakeFastAPI([ + FakeRoute("/pets", {"GET"}), + FakeRoute("/internal/health", {"GET"}), + FakeRoute("/internal/reindex", {"POST"}), + ]) + app.openapi = lambda: {"paths": {"/pets": {"get": {}}}} + + snapshot = build_fastapi_runtime_snapshot( + app, + {"scheme": "https", "headers": [(b"host", b"staging.internal")], "server": ("127.0.0.1", 8000)}, + acknowledged_undocumented=[{"method": "get", "path": "/internal/health"}], + ) + findings = snapshot["validation"]["findings"] + + self.assertEqual(snapshot["summary"]["runtime"], 3) + self.assertEqual(len(snapshot["runtimeOnly"]), 2) + self.assertEqual(snapshot["validation"]["status"], "fail") + self.assertEqual(snapshot["validation"]["summary"], {"total": 2, "errors": 1, "warnings": 0, "info": 1}) + self.assertEqual(findings[0]["severity"], "error") + self.assertEqual(findings[0]["location"], {"kind": "operation", "path": "/internal/reindex", "method": "POST"}) + self.assertNotIn("disposition", findings[0]) + self.assertEqual(findings[1]["disposition"], "acknowledged") + self.assertEqual(findings[1]["location"]["path"], "/internal/health") + + def test_partial_discovery_keeps_an_undocumented_route_a_warning(self): + app = FakeFastAPI([ + FakeRoute("/internal/reindex", {"POST"}), + FakeMount("/opaque", []), + ]) + app.openapi = lambda: {"paths": {}} + + snapshot = build_fastapi_runtime_snapshot(app, {"scheme": "http", "headers": [], "server": ("127.0.0.1", 8000)}) + + self.assertFalse(snapshot["validation"]["complete"]) + self.assertEqual(snapshot["validation"]["status"], "warn") + self.assertEqual(snapshot["validation"]["findings"][0]["severity"], "warning") def test_marks_opaque_mounts_partial(self): app = FakeFastAPI([FakeMount("/opaque", [])]) diff --git a/docs/runtime-intelligence.md b/docs/runtime-intelligence.md index d65d8bc0..98bcebff 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. -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 continues to emit the compatible route-level Runtime Intelligence snapshot without that object. The renderer keeps showing route presence for that snapshot; the CLI refuses to invent a validator when `validation` is absent. +Spring MVC, ASP.NET Core, and FastAPI emit the same structured `validation` object as the Node integrations. Spring acknowledgement is `flexdoc.acknowledged-undocumented`. ASP.NET Core acknowledgement is `FlexDocOptions.AcknowledgedUndocumented`. FastAPI acknowledgement is `acknowledged_undocumented` on `setup_fastapi_flexdoc`. Flask and Django still emit the route snapshot without that object. The CLI refuses to invent a validator when `validation` is absent. 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 route snapshots without it remain compatible. Node Express/Fastify/Hono/Nest, Spring MVC, and ASP.NET Core produce it. +`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, ASP.NET Core, and FastAPI produce it. ## 3.1 Contract Validation diff --git a/examples/python-fastapi/README.md b/examples/python-fastapi/README.md index 298e11ad..a9bf5fb2 100644 --- a/examples/python-fastapi/README.md +++ b/examples/python-fastapi/README.md @@ -2,7 +2,7 @@ FastAPI generates the OpenAPI 3.1 document from route declarations and Pydantic models, while `setup_fastapi_flexdoc` mounts FlexDoc inside the same running application and enables the native FastAPI/Starlette Runtime Intelligence integration. -The example includes multiple servers, API-key/Bearer/Basic security metadata, path/query/header parameters, JSON/form/multipart bodies, uploads, Try It, API Client handoff and code samples. `GET /internal/health` is registered with `include_in_schema=False`, so opening the Runtime panel shows a genuine implemented-but-undocumented route from the live Starlette route tree. +`GET /internal/health` is registered with `include_in_schema=False` and listed in `acknowledged_undocumented`, so that finding is informational and the route stays in the runtime record. `POST /internal/reindex` is also hidden from the generated schema and is not acknowledged, so `flexdoc validate` fails and the finding opens in the API Client. ```bash python -m venv .venv @@ -14,4 +14,4 @@ uvicorn app:app --reload Open `http://127.0.0.1:8000/docs`. -`prauga-flexdoc` is pinned to `0.9.0`, the Python package published for FlexDoc 3.3. Repository CI installs the wheel built from the current commit when validating source changes. +`prauga-flexdoc` is pinned to `0.9.0`. That published package reports the route snapshot and does not emit the validation object. Repository CI installs the wheel built from the current commit when validating source changes, and that build does. diff --git a/examples/python-fastapi/app.py b/examples/python-fastapi/app.py index 1aebe6e3..d7e823e6 100644 --- a/examples/python-fastapi/app.py +++ b/examples/python-fastapi/app.py @@ -84,12 +84,18 @@ async def upload_photo( return {"id": "upload-local", "filename": file.filename or "upload.bin", "caption": caption} -# Deliberately hidden from OpenAPI so the Runtime panel has a real runtime-only route to surface. +# Hidden from the generated schema and acknowledged, so the finding stays informational. @app.get("/internal/health", include_in_schema=False) async def internal_health() -> dict[str, str]: return {"status": "internal-ok"} +# Hidden from the generated schema and not acknowledged, so validation fails. +@app.post("/internal/reindex", include_in_schema=False) +async def internal_reindex() -> dict[str, bool]: + return {"accepted": True} + + setup_fastapi_flexdoc( app, "/docs", @@ -100,4 +106,5 @@ async def internal_health() -> dict[str, str]: try_it_credentials="same-origin", try_it_api_client_persistence_key="flexdoc-fastapi-3-showcase", runtime_intelligence=True, + acknowledged_undocumented=[{"method": "GET", "path": "/internal/health"}], ) From 3e78843f9abc09501468f96dcb0dd93299d1db3b Mon Sep 17 00:00:00 2001 From: Vishnu R Date: Wed, 23 Sep 2026 19:04:43 +0530 Subject: [PATCH 4/4] fix: keep one wire identity and one validation fixture Spring and ASP.NET snapshots now match routes the same way validation does, so parameter names cannot produce an empty finding beside a drift count. Node, Java, and C# share one fixture. FastAPI stays a route snapshot, and the README still describes the published Node release. Signed-off-by: Vishnu R --- .github/workflows/dotnet.yml | 12 +- CHANGELOG.md | 2 +- README.md | 2 +- .../AspNetRuntimeIntelligence.cs | 14 +- .../Program.cs | 47 +++ .../spring/SpringRuntimeIntelligence.java | 15 +- ...ringContractValidationConformanceTest.java | 48 +++ .../spring/SpringRuntimeIntelligenceTest.java | 16 + .../python/src/prauga_flexdoc/integrations.py | 6 +- .../prauga_flexdoc/runtime_intelligence.py | 133 +------ .../python/tests/test_runtime_intelligence.py | 43 +-- contracts/contract-validation-fixtures.json | 342 ++++++++++++++++++ docs/runtime-intelligence.md | 4 +- examples/python-fastapi/README.md | 4 +- examples/python-fastapi/app.py | 9 +- .../contract-validation.conformance.test.ts | 17 + 16 files changed, 520 insertions(+), 194 deletions(-) create mode 100644 adapters/java-spring/src/test/java/com/prauga/flexdoc/spring/SpringContractValidationConformanceTest.java create mode 100644 contracts/contract-validation-fixtures.json create mode 100644 packages/backend/src/contract-validation.conformance.test.ts 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 f9347abb..5eea9c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Notable FlexDoc product releases are recorded here. Detailed release notes remai ## Unreleased -- Spring MVC, ASP.NET Core, and FastAPI 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. +- 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 diff --git a/README.md b/README.md index 57879ee2..7295c72a 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@

-FlexDoc runs inside the backend and reads the routes the framework actually registered. **On Node, Spring, ASP.NET Core, and FastAPI,** it compares that runtime surface with OpenAPI, fails `flexdoc validate` on unacknowledged disagreement, and lets you open an undocumented runtime route directly in the built-in API Client. +FlexDoc runs inside the backend and reads the routes the framework actually registered. **On Node today,** it compares that runtime surface with OpenAPI, fails `flexdoc validate` on unacknowledged disagreement, and lets you open an undocumented runtime route directly in the built-in API Client. It also provides self-hosted API documentation, exploration, and execution. No FlexDoc account or control plane required. diff --git a/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetRuntimeIntelligence.cs b/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetRuntimeIntelligence.cs index 4a1877c9..3047f21a 100644 --- a/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetRuntimeIntelligence.cs +++ b/adapters/dotnet/src/Prauga.FlexDoc.AspNetCore/AspNetRuntimeIntelligence.cs @@ -39,11 +39,11 @@ internal static JsonElement OpenApiDocumentElement(object value) { 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 @@ -212,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/tests/Prauga.FlexDoc.AspNetCore.Tests/Program.cs b/adapters/dotnet/tests/Prauga.FlexDoc.AspNetCore.Tests/Program.cs index 53450ce7..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; @@ -173,6 +174,52 @@ static void Check(bool condition, string message) 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/SpringRuntimeIntelligence.java b/adapters/java-spring/src/main/java/com/prauga/flexdoc/spring/SpringRuntimeIntelligence.java index d928ab5f..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 @@ -81,13 +81,13 @@ static Map buildSnapshot( 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"); @@ -234,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 667a3454..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 @@ -174,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/integrations.py b/adapters/python/src/prauga_flexdoc/integrations.py index a812f8c8..bbf1801b 100644 --- a/adapters/python/src/prauga_flexdoc/integrations.py +++ b/adapters/python/src/prauga_flexdoc/integrations.py @@ -45,7 +45,6 @@ def setup_fastapi_flexdoc( try_it_credentials: Literal["omit", "same-origin", "include"] | None = None, try_it_api_client_persistence_key: str | Literal[False] | None = None, runtime_intelligence: bool = False, - acknowledged_undocumented: list[dict[str, str]] | None = None, try_it_host_execution: bool = False, try_it_host_execution_allowed_origins: list[str] | tuple[str, ...] | None = None, try_it_host_execution_metric_sink: Callable[[object], None] | None = None, @@ -63,8 +62,6 @@ def setup_fastapi_flexdoc( try_it_credentials: Optional fetch credentials mode for Try It requests. try_it_api_client_persistence_key: Optional persistence key, or ``False``. runtime_intelligence: When ``True``, expose a live runtime snapshot endpoint. - acknowledged_undocumented: Runtime routes accepted as intentionally absent from OpenAPI. - They stay registered and are reported as informational findings. try_it_host_execution: When ``True``, mount the native API-host execution route. try_it_host_execution_allowed_origins: Required exact HTTP(S) origins for native execution. Wildcards and paths are not accepted. @@ -89,8 +86,7 @@ def setup_fastapi_flexdoc( runtime_provider = None runtime_framework = None if runtime_intelligence: - runtime_provider = lambda scope: build_fastapi_runtime_snapshot( - app, scope, normalized_path, acknowledged_undocumented=acknowledged_undocumented) + runtime_provider = lambda scope: build_fastapi_runtime_snapshot(app, scope, normalized_path) runtime_framework = "fastapi" host_execution = None diff --git a/adapters/python/src/prauga_flexdoc/runtime_intelligence.py b/adapters/python/src/prauga_flexdoc/runtime_intelligence.py index 55da0d5f..61ceac28 100644 --- a/adapters/python/src/prauga_flexdoc/runtime_intelligence.py +++ b/adapters/python/src/prauga_flexdoc/runtime_intelligence.py @@ -211,122 +211,7 @@ def _exact_shape_key(route: dict[str, str]) -> str: return f'{route["method"].upper()} {_route_shape(route["path"])}' -def _acknowledged_routes(value) -> list[dict[str, str]]: - routes: list[dict[str, str]] = [] - for entry in value or []: - if not isinstance(entry, dict): - continue - method = str(entry.get("method", "")).strip().upper() - path = entry.get("path") - if method not in _HTTP_METHODS or not isinstance(path, str) or not path.strip(): - continue - routes.append({"method": method, "path": normalize_runtime_path(path)}) - return _unique_sorted(routes) - - -def validate_runtime_contract(documented_routes, runtime_routes, discovery_complete: bool, acknowledged_undocumented=None) -> dict: - """Compare documented operations with discovered routes using the Node validator's rules. - - Args: - documented_routes: Normalized OpenAPI operations. - runtime_routes: Normalized routes the framework registered. - discovery_complete: Whether the runtime inventory is complete enough for absence claims. - acknowledged_undocumented: Routes accepted as intentionally absent from OpenAPI. - - Returns: - The same validation object Node, Spring, and ASP.NET Core emit. - """ - documented_keys = {_exact_shape_key(route) for route in documented_routes} - runtime_keys = {_exact_shape_key(route) for route in runtime_routes} - acknowledged_keys = {_exact_shape_key(route) for route in _acknowledged_routes(acknowledged_undocumented)} - findings = [] - - def grouped(routes): - groups: dict[str, set[str]] = {} - for route in routes: - groups.setdefault(_route_shape(route["path"]), set()).add(route["method"].upper()) - return {shape: sorted(methods) for shape, methods in groups.items()} - - documented_methods = grouped(documented_routes) - runtime_methods = grouped(runtime_routes) - mismatch_shapes = set() - for shape, expected_methods in documented_methods.items(): - observed_methods = runtime_methods.get(shape) - if observed_methods is None or any(method in observed_methods for method in expected_methods): - continue - mismatch_shapes.add(shape) - path = next((route["path"] for route in documented_routes if _route_shape(route["path"]) == shape), shape) - findings.append(_finding( - "runtime.method-mismatch", None, path, - "error" if discovery_complete else "warning", - expected_methods[0], - f"Runtime route {path} is registered for different HTTP methods than OpenAPI documents.", - expected_methods, observed_methods, None, - )) - - for route in runtime_routes: - shape = _route_shape(route["path"]) - if shape in mismatch_shapes or _exact_shape_key(route) in documented_keys: - continue - acknowledged = _exact_shape_key(route) in acknowledged_keys - findings.append(_finding( - "runtime.operation-undocumented", route["method"], route["path"], - "info" if acknowledged else "error" if discovery_complete else "warning", - route["method"], - f'Runtime implements {route["method"]} {route["path"]}, but OpenAPI does not document that operation.', - "Operation is represented in OpenAPI", - "Operation exists only in the running backend and is acknowledged" if acknowledged else "Operation exists only in the running backend", - "acknowledged" if acknowledged else None, - )) - - for route in documented_routes: - shape = _route_shape(route["path"]) - if shape in mismatch_shapes or _exact_shape_key(route) in runtime_keys: - continue - findings.append(_finding( - "runtime.operation-unobserved", route["method"], route["path"], - "error" if discovery_complete else "info", - route["method"], - f'OpenAPI documents {route["method"]} {route["path"]}, but the running backend does not expose that operation.' if discovery_complete - else f'OpenAPI documents {route["method"]} {route["path"]}, but it was not observed during partial runtime discovery.', - "Operation is exposed by the running backend", - "No matching runtime operation exists" if discovery_complete else "No matching operation was observed during partial discovery", - None, - )) - - rank = {"error": 0, "warning": 1, "info": 2} - findings.sort(key=lambda finding: ( - rank[finding["severity"]], - finding["location"]["path"], - finding["location"].get("method", ""), - finding["code"], - )) - summary = { - "total": len(findings), - "errors": sum(finding["severity"] == "error" for finding in findings), - "warnings": sum(finding["severity"] == "warning" for finding in findings), - "info": sum(finding["severity"] == "info" for finding in findings), - } - status = "fail" if summary["errors"] else "warn" if summary["warnings"] else "pass" if discovery_complete else "partial" - return {"status": status, "complete": discovery_complete, "findings": findings, "summary": summary} - - -def _finding(code, id_method, path, severity, location_method, message, expected, observed, disposition): - finding = { - "id": f'{code}:' + (f"{id_method}:" if id_method else "") + _route_shape(path), - "code": code, - "severity": severity, - "location": {"kind": "operation", "path": path, **({"method": location_method} if location_method else {})}, - "message": message, - "expected": expected, - "observed": observed, - } - if disposition: - finding["disposition"] = disposition - return finding - - -def build_fastapi_runtime_snapshot(app, scope, exclude_prefix: str | None = None, acknowledged_undocumented=None) -> dict: +def build_fastapi_runtime_snapshot(app, scope, exclude_prefix: str | None = None) -> dict: """Build a Runtime Intelligence route-presence snapshot for FastAPI. Args: @@ -342,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) @@ -367,12 +252,6 @@ def build_fastapi_runtime_snapshot(app, scope, exclude_prefix: str | None = None "runtimeOnly": len(runtime_only), "documentedOnly": len(documented_only), }, - "validation": validate_runtime_contract( - documented, - runtime_routes, - discovery["complete"], - acknowledged_undocumented, - ), } diff --git a/adapters/python/tests/test_runtime_intelligence.py b/adapters/python/tests/test_runtime_intelligence.py index 3360aba5..1958afac 100644 --- a/adapters/python/tests/test_runtime_intelligence.py +++ b/adapters/python/tests/test_runtime_intelligence.py @@ -80,46 +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.assertEqual(snapshot["validation"]["status"], "fail") - self.assertEqual(snapshot["validation"]["summary"]["errors"], 2) + self.assertNotIn("validation", snapshot) - def test_acknowledged_health_stays_registered_while_reindex_fails(self): - app = FakeFastAPI([ - FakeRoute("/pets", {"GET"}), - FakeRoute("/internal/health", {"GET"}), - FakeRoute("/internal/reindex", {"POST"}), - ]) - app.openapi = lambda: {"paths": {"/pets": {"get": {}}}} - - snapshot = build_fastapi_runtime_snapshot( - app, - {"scheme": "https", "headers": [(b"host", b"staging.internal")], "server": ("127.0.0.1", 8000)}, - acknowledged_undocumented=[{"method": "get", "path": "/internal/health"}], - ) - findings = snapshot["validation"]["findings"] - - self.assertEqual(snapshot["summary"]["runtime"], 3) - self.assertEqual(len(snapshot["runtimeOnly"]), 2) - self.assertEqual(snapshot["validation"]["status"], "fail") - self.assertEqual(snapshot["validation"]["summary"], {"total": 2, "errors": 1, "warnings": 0, "info": 1}) - self.assertEqual(findings[0]["severity"], "error") - self.assertEqual(findings[0]["location"], {"kind": "operation", "path": "/internal/reindex", "method": "POST"}) - self.assertNotIn("disposition", findings[0]) - self.assertEqual(findings[1]["disposition"], "acknowledged") - self.assertEqual(findings[1]["location"]["path"], "/internal/health") - - def test_partial_discovery_keeps_an_undocumented_route_a_warning(self): - app = FakeFastAPI([ - FakeRoute("/internal/reindex", {"POST"}), - FakeMount("/opaque", []), - ]) - app.openapi = lambda: {"paths": {}} + 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.assertFalse(snapshot["validation"]["complete"]) - self.assertEqual(snapshot["validation"]["status"], "warn") - self.assertEqual(snapshot["validation"]["findings"][0]["severity"], "warning") + 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 98bcebff..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. -Spring MVC, ASP.NET Core, and FastAPI emit the same structured `validation` object as the Node integrations. Spring acknowledgement is `flexdoc.acknowledged-undocumented`. ASP.NET Core acknowledgement is `FlexDocOptions.AcknowledgedUndocumented`. FastAPI acknowledgement is `acknowledged_undocumented` on `setup_fastapi_flexdoc`. Flask and Django still emit the route snapshot without that object. The CLI refuses to invent a 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 route snapshots without it remain compatible. Node Express/Fastify/Hono/Nest, Spring MVC, ASP.NET Core, and FastAPI produce it. +`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/python-fastapi/README.md b/examples/python-fastapi/README.md index a9bf5fb2..298e11ad 100644 --- a/examples/python-fastapi/README.md +++ b/examples/python-fastapi/README.md @@ -2,7 +2,7 @@ FastAPI generates the OpenAPI 3.1 document from route declarations and Pydantic models, while `setup_fastapi_flexdoc` mounts FlexDoc inside the same running application and enables the native FastAPI/Starlette Runtime Intelligence integration. -`GET /internal/health` is registered with `include_in_schema=False` and listed in `acknowledged_undocumented`, so that finding is informational and the route stays in the runtime record. `POST /internal/reindex` is also hidden from the generated schema and is not acknowledged, so `flexdoc validate` fails and the finding opens in the API Client. +The example includes multiple servers, API-key/Bearer/Basic security metadata, path/query/header parameters, JSON/form/multipart bodies, uploads, Try It, API Client handoff and code samples. `GET /internal/health` is registered with `include_in_schema=False`, so opening the Runtime panel shows a genuine implemented-but-undocumented route from the live Starlette route tree. ```bash python -m venv .venv @@ -14,4 +14,4 @@ uvicorn app:app --reload Open `http://127.0.0.1:8000/docs`. -`prauga-flexdoc` is pinned to `0.9.0`. That published package reports the route snapshot and does not emit the validation object. Repository CI installs the wheel built from the current commit when validating source changes, and that build does. +`prauga-flexdoc` is pinned to `0.9.0`, the Python package published for FlexDoc 3.3. Repository CI installs the wheel built from the current commit when validating source changes. diff --git a/examples/python-fastapi/app.py b/examples/python-fastapi/app.py index d7e823e6..1aebe6e3 100644 --- a/examples/python-fastapi/app.py +++ b/examples/python-fastapi/app.py @@ -84,18 +84,12 @@ async def upload_photo( return {"id": "upload-local", "filename": file.filename or "upload.bin", "caption": caption} -# Hidden from the generated schema and acknowledged, so the finding stays informational. +# Deliberately hidden from OpenAPI so the Runtime panel has a real runtime-only route to surface. @app.get("/internal/health", include_in_schema=False) async def internal_health() -> dict[str, str]: return {"status": "internal-ok"} -# Hidden from the generated schema and not acknowledged, so validation fails. -@app.post("/internal/reindex", include_in_schema=False) -async def internal_reindex() -> dict[str, bool]: - return {"accepted": True} - - setup_fastapi_flexdoc( app, "/docs", @@ -106,5 +100,4 @@ async def internal_reindex() -> dict[str, bool]: try_it_credentials="same-origin", try_it_api_client_persistence_key="flexdoc-fastapi-3-showcase", runtime_intelligence=True, - acknowledged_undocumented=[{"method": "GET", "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); + }); +});