Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,7 @@ bin/
gradle-plugin-publishing.md
sedr-library-maven-central-publishing.md

/**/*prompts.md
/**/*prompts.md

# Scratch/validation projects (matches the Gradle repo's own convention)
/tmp/
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -82,7 +86,64 @@ public List<DescribedEndpoint> collect(File rootDocument) {
*/
public List<DescribedEndpoint> collect(File rootDocument, Consumer<File> onDocumentResolved) {
discoverReferencedDocuments(rootDocument, new HashSet<>(), onDocumentResolved);
OpenAPI openApi = parseOpenApi(rootDocument);

List<DescribedEndpoint> 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)}.
*
* <p>An OpenAPI document's {@code paths} are always relative to that base path: a client
* actually requests {@code <server url>/<path>}, 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.</p>
*
* @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<String> firstServerBasePath(File rootDocument) {
OpenAPI openApi = parseOpenApi(rootDocument);
List<Server> 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);
Expand All @@ -98,19 +159,7 @@ public List<DescribedEndpoint> collect(File rootDocument, Consumer<File> onDocum
throw new IllegalStateException(
"apiDetectorCore: failed to parse OpenAPI document " + rootDocument + ": " + messages);
}

List<DescribedEndpoint> 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<String> operationTags(Operation operation) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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<String> 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<String> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading