From 96be44f46d5c215e160ef3715228b6ccefe1120e Mon Sep 17 00:00:00 2001 From: Iwan Eising Date: Sun, 16 Aug 2026 16:35:44 +0400 Subject: [PATCH 1/2] maintenance: update .gitignore to include temporary directories --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6ddfca7..5015cbc 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,7 @@ bin/ gradle-plugin-publishing.md sedr-library-maven-central-publishing.md -/**/*prompts.md \ No newline at end of file +/**/*prompts.md + +# Scratch/validation projects (matches the Gradle repo's own convention) +/tmp/ \ No newline at end of file From 49fc78541e6d40c151357707598e1053686bf4af Mon Sep 17 00:00:00 2001 From: Iwan Eising Date: Mon, 17 Aug 2026 11:38:50 +0400 Subject: [PATCH 2/2] feat(api-detector-core): add PathTemplates.stripBasePath and OpenApiEndpointCollector.firstServerBasePath A WireMock stub mapping records the full request path a client actually sends - including whatever deployment-time context path the server runs under, e.g. /crm-service - while an OpenAPI-declared path (and a @RequestMapping-derived one) never includes it, since an OpenAPI document's paths are always relative to its own servers entry. Left unaccounted for, mirage-api-detector's scanMocks mode could never match a stub against its corresponding declared endpoint: verified against a real project's data, every single stub-derived record ended up as an orphaned, unmatched row instead of being recognised as evidence for the declared endpoint it actually stubs. PathTemplates.stripBasePath(path, basePath) removes a matching leading segment, both normalised first, and is a no-op for a blank or root base path. OpenApiEndpointCollector.firstServerBasePath(rootDocument) returns the path component of the document's first servers entry's url (e.g. http://localhost:9011/crm-service yields /crm-service), so a consumer can default to it without requiring explicit configuration for the common case. Both are consumed by mirage-api-detector's new basePath DSL option in a follow-up change, once this is released. --- .../detector/core/model/PathTemplates.java | 30 ++++++++ .../openapi/OpenApiEndpointCollector.java | 75 +++++++++++++++---- .../core/model/PathTemplatesTest.java | 49 ++++++++++++ .../openapi/OpenApiEndpointCollectorTest.java | 26 +++++++ .../openapi/with-rootless-server/openapi.yaml | 14 ++++ .../openapi/with-servers/openapi.yaml | 14 ++++ 6 files changed, 195 insertions(+), 13 deletions(-) create mode 100644 api-detector-core/src/test/resources/openapi/with-rootless-server/openapi.yaml create mode 100644 api-detector-core/src/test/resources/openapi/with-servers/openapi.yaml diff --git a/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/model/PathTemplates.java b/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/model/PathTemplates.java index c7d4f25..f8aba98 100644 --- a/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/model/PathTemplates.java +++ b/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/model/PathTemplates.java @@ -52,6 +52,36 @@ public static boolean isPlaceholder(String segment) { return segment.startsWith("{") && segment.endsWith("}"); } + /** + * Removes a leading {@code basePath} segment from {@code path}, both normalised via + * {@link #normalize(String)} first. Meant for a path recorded against the full request URL a + * real client actually sends - e.g. one read from a WireMock stub mapping file, which always + * includes whatever deployment-time context path the server runs under - so it can be compared + * against a path template that never includes one, such as an OpenAPI-declared path or one + * read from a {@code @RequestMapping} annotation. + * + * @param path the path to strip {@code basePath} from + * @param basePath the base path to remove; blank or {@code "/"} - i.e. no real base path - + * leaves {@code path} unchanged + * @return {@code path}, normalised, with a leading {@code basePath} removed; unchanged + * (other than normalising) when {@code path} doesn't actually start with + * {@code basePath}, so a mismatched configuration never corrupts an unrelated path + */ + public static String stripBasePath(String path, String basePath) { + String normalizedPath = normalize(path); + String normalizedBase = normalize(basePath); + if (normalizedBase.equals("/")) { + return normalizedPath; + } + if (normalizedPath.equals(normalizedBase)) { + return "/"; + } + if (normalizedPath.startsWith(normalizedBase + "/")) { + return normalizedPath.substring(normalizedBase.length()); + } + return normalizedPath; + } + private static String blankToEmpty(String s) { return s == null ? "" : s.trim(); } diff --git a/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollector.java b/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollector.java index 18818df..c0d79e4 100644 --- a/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollector.java +++ b/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollector.java @@ -5,17 +5,21 @@ import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.servers.Server; import io.swagger.v3.parser.OpenAPIV3Parser; import io.swagger.v3.parser.core.models.ParseOptions; import io.swagger.v3.parser.core.models.SwaggerParseResult; import java.io.File; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.function.Consumer; import java.util.regex.Matcher; @@ -82,7 +86,64 @@ public List collect(File rootDocument) { */ public List collect(File rootDocument, Consumer onDocumentResolved) { discoverReferencedDocuments(rootDocument, new HashSet<>(), onDocumentResolved); + OpenAPI openApi = parseOpenApi(rootDocument); + List endpoints = new ArrayList<>(); + Paths paths = openApi.getPaths(); + if (paths == null) { + return endpoints; + } + paths.forEach((path, item) -> item.readOperationsMap().forEach((method, operation) -> + endpoints.add(new DescribedEndpoint( + HttpVerb.valueOf(method.name()), + PathTemplates.normalize(path), + operation.getOperationId(), + operationTags(operation))))); + return endpoints; + } + + /** + * Parses {@code rootDocument} and returns the path component of its first declared + * {@code servers} entry's {@code url} - e.g. {@code http://localhost:9011/crm-service} yields + * {@code /crm-service} - normalised via {@link PathTemplates#normalize(String)}. + * + *

An OpenAPI document's {@code paths} are always relative to that base path: a client + * actually requests {@code /}, but every operation's declared path (and every + * path a {@code @RequestMapping}-derived scan produces) omits it. A WireMock stub mapping, in + * contrast, records the full request path a client actually sends, base path included. This is + * the default source for stripping it back off before comparing the two - see + * {@code mirageApiDetector.basePath} in the Mirage API Detector plugin, which falls back to + * this method's result when left unconfigured.

+ * + * @param rootDocument the root OpenAPI document (JSON or YAML) + * @return the first server's base path, or {@link Optional#empty()} when the document declares + * no {@code servers} entry, its {@code url} is blank, or that URL has no path component + * (e.g. {@code http://localhost:9011} alone, with nothing to strip) + * @throws IllegalStateException if the document cannot be parsed + */ + public Optional firstServerBasePath(File rootDocument) { + OpenAPI openApi = parseOpenApi(rootDocument); + List servers = openApi.getServers(); + if (servers == null || servers.isEmpty()) { + return Optional.empty(); + } + String url = servers.get(0).getUrl(); + if (url == null || url.isBlank()) { + return Optional.empty(); + } + String path; + try { + path = new URI(url).getPath(); + } catch (URISyntaxException e) { + return Optional.empty(); + } + if (path == null || path.isBlank() || path.equals("/")) { + return Optional.empty(); + } + return Optional.of(PathTemplates.normalize(path)); + } + + private OpenAPI parseOpenApi(File rootDocument) { ParseOptions options = new ParseOptions(); options.setResolve(true); options.setResolveFully(true); @@ -98,19 +159,7 @@ public List collect(File rootDocument, Consumer onDocum throw new IllegalStateException( "apiDetectorCore: failed to parse OpenAPI document " + rootDocument + ": " + messages); } - - List endpoints = new ArrayList<>(); - Paths paths = openApi.getPaths(); - if (paths == null) { - return endpoints; - } - paths.forEach((path, item) -> item.readOperationsMap().forEach((method, operation) -> - endpoints.add(new DescribedEndpoint( - HttpVerb.valueOf(method.name()), - PathTemplates.normalize(path), - operation.getOperationId(), - operationTags(operation))))); - return endpoints; + return openApi; } private static List operationTags(Operation operation) { diff --git a/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/model/PathTemplatesTest.java b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/model/PathTemplatesTest.java index bc3bf5c..5454668 100644 --- a/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/model/PathTemplatesTest.java +++ b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/model/PathTemplatesTest.java @@ -69,4 +69,53 @@ void recognisesPlaceholder() { assertThat(PathTemplates.isPlaceholder("{id}")).isTrue(); assertThat(PathTemplates.isPlaceholder("users")).isFalse(); } + + @Test + @DisplayName("stripBasePath() removes a matching leading segment") + void stripBasePathRemovesMatchingLeadingSegment() { + assertThat(PathTemplates.stripBasePath("/crm-service/v1/users", "/crm-service")) + .isEqualTo("/v1/users"); + } + + @Test + @DisplayName("stripBasePath() reduces an exact match to the root path") + void stripBasePathReducesExactMatchToRoot() { + assertThat(PathTemplates.stripBasePath("/crm-service", "/crm-service")).isEqualTo("/"); + } + + @Test + @DisplayName("stripBasePath() leaves a non-matching path unchanged, other than normalising it") + void stripBasePathLeavesNonMatchingPathUnchanged() { + assertThat(PathTemplates.stripBasePath("/other/v1/users", "/crm-service")) + .isEqualTo("/other/v1/users"); + } + + @Test + @DisplayName("stripBasePath() never strips a segment that merely shares a prefix") + void stripBasePathNeverStripsAMerePrefixMatch() { + // "/crm-service-admin/..." must not be treated as starting with "/crm-service" - only a + // full path segment boundary counts as a match. + assertThat(PathTemplates.stripBasePath("/crm-service-admin/v1/users", "/crm-service")) + .isEqualTo("/crm-service-admin/v1/users"); + } + + @Test + @DisplayName("stripBasePath() is a no-op for a blank base path") + void stripBasePathNoOpForBlankBasePath() { + assertThat(PathTemplates.stripBasePath("/v1/users", "")).isEqualTo("/v1/users"); + assertThat(PathTemplates.stripBasePath("/v1/users", null)).isEqualTo("/v1/users"); + } + + @Test + @DisplayName("stripBasePath() is a no-op for a root base path") + void stripBasePathNoOpForRootBasePath() { + assertThat(PathTemplates.stripBasePath("/v1/users", "/")).isEqualTo("/v1/users"); + } + + @Test + @DisplayName("stripBasePath() normalises both the path and the base path first") + void stripBasePathNormalisesBothArguments() { + assertThat(PathTemplates.stripBasePath("crm-service//v1/users/", "crm-service")) + .isEqualTo("/v1/users"); + } } diff --git a/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollectorTest.java b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollectorTest.java index 44ce882..fa3caf6 100644 --- a/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollectorTest.java +++ b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollectorTest.java @@ -8,6 +8,7 @@ import java.net.URL; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -165,6 +166,31 @@ void callbackReceivesTheReferencedDocumentFile() { assertThat(resolved).anyMatch(file -> file.getName().equals("users.yaml")); } + @Test + @DisplayName("firstServerBasePath() returns the path component of the first server's url") + void firstServerBasePathReturnsPathComponentOfFirstServerUrl() { + Optional basePath = collector.firstServerBasePath(resource("openapi/with-servers/openapi.yaml")); + + assertThat(basePath).contains("/crm-service"); + } + + @Test + @DisplayName("firstServerBasePath() is empty when the document declares no servers entry") + void firstServerBasePathEmptyWhenNoServersDeclared() { + Optional basePath = collector.firstServerBasePath(resource("openapi/single-file/openapi.yaml")); + + assertThat(basePath).isEmpty(); + } + + @Test + @DisplayName("firstServerBasePath() is empty when the first server's url has no path component") + void firstServerBasePathEmptyWhenServerUrlHasNoPath() { + Optional basePath = + collector.firstServerBasePath(resource("openapi/with-rootless-server/openapi.yaml")); + + assertThat(basePath).isEmpty(); + } + private static File resource(String name) { URL url = OpenApiEndpointCollectorTest.class.getClassLoader().getResource(name); if (url == null) { diff --git a/api-detector-core/src/test/resources/openapi/with-rootless-server/openapi.yaml b/api-detector-core/src/test/resources/openapi/with-rootless-server/openapi.yaml new file mode 100644 index 0000000..c522408 --- /dev/null +++ b/api-detector-core/src/test/resources/openapi/with-rootless-server/openapi.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.3 +info: + title: With Rootless Server Test API + version: "1.0" +servers: + - url: http://localhost:9011 + description: No path component to strip +paths: + /v1/users: + get: + operationId: listUsers + responses: + '200': + description: OK diff --git a/api-detector-core/src/test/resources/openapi/with-servers/openapi.yaml b/api-detector-core/src/test/resources/openapi/with-servers/openapi.yaml new file mode 100644 index 0000000..7318b56 --- /dev/null +++ b/api-detector-core/src/test/resources/openapi/with-servers/openapi.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.3 +info: + title: With Servers Test API + version: "1.0" +servers: + - url: http://localhost:9011/crm-service + description: Generated server url +paths: + /v1/users: + get: + operationId: listUsers + responses: + '200': + description: OK