diff --git a/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OpenApiClient.java b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OpenApiClient.java index 67d5515..e22002a 100644 --- a/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OpenApiClient.java +++ b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/OpenApiClient.java @@ -133,33 +133,85 @@ private static OpenAPIParser parseSpec(@NotNull String specLocation, @NotNull IH return new OpenAPIParser(specLocation, httpClient, adhoc, extraHeaders); } + /** + * Instance-level wrapper: picks {@code servers[serverIndex].url} (or "/" if absent) + * and delegates to the static resolver. Kept package-private for testing. + */ @NotNull private String resolveBaseUrl() { List servers = parser.getServers(); - String serverUrl = !servers.isEmpty() ? servers.get(serverIndex) : ""; - boolean isRelativeUrl = serverUrl.isEmpty() || serverUrl.startsWith("/"); + // Per OpenAPI 3.0+ §4.7.5, absent/empty `servers` implies `[{ "url": "/" }]`; + // the constructor guarantees serverIndex == 0 in that case. + String serverUrl = !servers.isEmpty() ? servers.get(serverIndex) : "/"; + return resolveBaseUrl(specLocation, serverUrl); + } - if (isRelativeUrl && specLocation.startsWith("http")) { - try { - java.net.URL url = new java.net.URL(specLocation); - String protocol = url.getProtocol(); - String host = url.getHost(); - int port = url.getPort(); - String basePath = serverUrl.isEmpty() ? "" : serverUrl; - String resolved = (port != -1) - ? protocol + "://" + host + ":" + port + basePath - : protocol + "://" + host + basePath; - logger.info("Resolved relative server URL '{}' to: {}", serverUrl, resolved); - return resolved; - } catch (java.net.MalformedURLException e) { - logger.warn("Failed to parse spec location URL: {}", e.getMessage()); + /** + * Resolves an OpenAPI {@code servers[].url} against the spec location, per + * OpenAPI 3.0+ (clarified in OpenAPI 3.2.0 §4.5.2.1). + * + *

Supports the three permitted URL forms: + *

+ * + *

Works for HTTP(S) and local-file spec locations alike. Package-private so + * tests can exercise each branch directly without spinning up an HTTP server. + */ + @NotNull + static String resolveBaseUrl(@NotNull String specLocation, @NotNull String serverUrl) { + java.net.URI specBase; + try { + specBase = specLocationAsUri(specLocation); + } catch (java.net.URISyntaxException e) { + logger.warn("Spec location '{}' is not a valid URI; returning server URL as-is: {}", + specLocation, e.getMessage()); + return serverUrl; + } + + java.net.URI serverRef; + try { + serverRef = new java.net.URI(serverUrl); + } catch (java.net.URISyntaxException e) { + logger.warn("Server URL '{}' is not a valid URI reference: {}", serverUrl, e.getMessage()); + return serverUrl; + } + + java.net.URI resolved = specBase.resolve(serverRef); + // If the spec location was a local file (file:// scheme), the resolved URI inherits + // it. Callers expect an http(s)-style base for issuing requests, so when the resolved + // URI is still local we fall back to returning the server URL as-is — this preserves + // the historical behaviour where local-file specs assume an absolute server URL. + if ("file".equalsIgnoreCase(resolved.getScheme())) { + if (serverRef.isAbsolute()) { return serverUrl; } - } else if (!serverUrl.isEmpty()) { - return serverUrl; + logger.warn("Spec location '{}' is a local file but the server URL '{}' is " + + "relative; the resulting base URL is also a file:// URI which is " + + "almost certainly not what you want. Use an absolute server URL or " + + "load the spec over HTTP.", specLocation, serverUrl); + } + logger.debug("Resolved server URL '{}' against spec '{}' -> {}", serverUrl, specLocation, resolved); + return resolved.toString(); + } + + /** + * Treat the spec location as a URI for resolution. http(s) URLs come through verbatim; + * a path or file:// URL becomes a file URI so RFC 3986 reference resolution works against + * its parent directory. + */ + @NotNull + private static java.net.URI specLocationAsUri(@NotNull String specLocation) throws java.net.URISyntaxException { + if (specLocation.startsWith("http://") || specLocation.startsWith("https://") + || specLocation.startsWith("file://")) { + return new java.net.URI(specLocation); } - logger.warn("No servers defined in OpenAPI spec and cannot infer from spec location"); - return ""; + return java.nio.file.Paths.get(specLocation).toUri(); } // ------------------------------------------------------------------------ diff --git a/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenApiClientBaseUrlTest.java b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenApiClientBaseUrlTest.java new file mode 100644 index 0000000..f341c8f --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenApiClientBaseUrlTest.java @@ -0,0 +1,194 @@ +package io.github.ndsev.zswag.shared; + +import io.github.ndsev.zswag.api.HttpConfig; +import io.github.ndsev.zswag.api.HttpException; +import io.github.ndsev.zswag.api.HttpRequest; +import io.github.ndsev.zswag.api.HttpResponse; +import io.github.ndsev.zswag.api.HttpSettings; +import io.github.ndsev.zswag.api.IHttpClient; +import io.github.ndsev.zswag.api.IKeychain; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that {@code OpenApiClient.resolveBaseUrl} resolves + * {@code servers[0].url} against the spec location, per OpenAPI 3.0+ + * (clarified in OpenAPI 3.2.0 §4.5.2.1). Covers the three permitted reference + * forms — absolute, server-relative, document-relative — and the spec-URL + * scheme variants (http, https, local file path, file:// URL). + */ +class OpenApiClientBaseUrlTest { + + @TempDir + Path tmp; + + // ------------------------------------------------------------------------ + // Unit-level: static resolveBaseUrl exercises every branch directly. + // ------------------------------------------------------------------------ + + @Test + void absoluteServerUrlReturnedAsIs() { + assertThat(OpenApiClient.resolveBaseUrl( + "https://api.example.com/v1/openapi.json", + "https://other.example.com/api")) + .isEqualTo("https://other.example.com/api"); + } + + @Test + void serverRelativePath_inheritsSpecSchemeAndHost() { + assertThat(OpenApiClient.resolveBaseUrl( + "https://api.example.com/v1/openapi.json", + "/v2")) + .isEqualTo("https://api.example.com/v2"); + } + + @Test + void documentRelativeDot_resolvesToSpecDirectory() { + assertThat(OpenApiClient.resolveBaseUrl( + "https://api.example.com/v1/openapi.json", + ".")) + .isEqualTo("https://api.example.com/v1/"); + } + + @Test + void documentRelativeDotSlashV2_appendsToSpecDirectory() { + assertThat(OpenApiClient.resolveBaseUrl( + "https://api.example.com/v1/openapi.json", + "./v2")) + .isEqualTo("https://api.example.com/v1/v2"); + } + + @Test + void documentRelativeDotDotSlashV2_goesUpOneDirectory() { + assertThat(OpenApiClient.resolveBaseUrl( + "https://api.example.com/v1/openapi.json", + "../v2")) + .isEqualTo("https://api.example.com/v2"); + } + + @Test + void documentRelativeBareV2_treatedAsDotSlashV2() { + assertThat(OpenApiClient.resolveBaseUrl( + "https://api.example.com/v1/openapi.json", + "v2")) + .isEqualTo("https://api.example.com/v1/v2"); + } + + @Test + void emptyServersDefaultsToRoot() { + // The instance method substitutes "/" for an empty servers array; + // here we just confirm the static method handles "/" correctly. + assertThat(OpenApiClient.resolveBaseUrl( + "https://api.example.com/v1/openapi.json", + "/")) + .isEqualTo("https://api.example.com/"); + } + + @Test + void portCarriesIntoResolvedUrl() { + assertThat(OpenApiClient.resolveBaseUrl( + "http://localhost:8080/api/openapi.json", + ".")) + .isEqualTo("http://localhost:8080/api/"); + } + + @Test + void fileSpecWithAbsoluteServerUrl_returnsAbsoluteAsIs() { + // Local-file spec is fine for dispatch as long as the server URL is absolute. + assertThat(OpenApiClient.resolveBaseUrl( + "/tmp/specs/openapi.yaml", + "https://api.example.com/v1")) + .isEqualTo("https://api.example.com/v1"); + } + + @Test + void fileSpecWithRelativeServerUrl_returnsFileUriWithWarning() { + // Edge case: a local-file spec + relative server URL produces a file:// base. + // The method logs a warning and returns the resolved file URI verbatim. + // This is almost never useful but at least is deterministic. + String resolved = OpenApiClient.resolveBaseUrl( + "/tmp/specs/openapi.yaml", + "."); + assertThat(resolved).startsWith("file:/").endsWith("/tmp/specs/"); + } + + @Test + void fileUriSpecWithDocumentRelativeServer() { + // java.net.URI.toString collapses an empty authority to "file:/...". + // RFC 3986 considers "file:/x" and "file:///x" equivalent. + assertThat(OpenApiClient.resolveBaseUrl( + "file:///tmp/specs/openapi.yaml", + "./v2")) + .isEqualTo("file:/tmp/specs/v2"); + } + + @Test + void invalidServerUrl_returnedVerbatimWithWarning() { + // A malformed URI reference shouldn't crash — log and return as-is. + String malformed = "bad scheme://[ here"; + assertThat(OpenApiClient.resolveBaseUrl( + "https://api.example.com/v1/openapi.json", + malformed)) + .isEqualTo(malformed); + } + + // ------------------------------------------------------------------------ + // End-to-end: a real OpenApiClient is built from a temp-file spec and the + // dispatch URL is observed. Confirms resolveBaseUrl is wired through to + // dispatch (not just a standalone function). + // ------------------------------------------------------------------------ + + private static final String SPEC_TEMPLATE = String.join("\n", + "openapi: \"3.0.0\"", + "info: { title: t, version: '1.0' }", + "%SERVERS%", + "paths:", + " /ping:", + " get:", + " operationId: ping", + " responses: { '200': { description: ok } }" + ); + + private static final class CapturingHttpClient implements IHttpClient { + final AtomicReference last = new AtomicReference<>(); + + @Override + public HttpSettings getPersistentSettings() { return HttpSettings.empty(); } + + @Override + public HttpResponse execute(HttpRequest request, HttpConfig adhoc) { + last.set(request); + return new HttpResponse(200, null, new LinkedHashMap<>(), new byte[0]); + } + } + + private static IKeychain noKeychain() { + return (s, u) -> { throw new RuntimeException("keychain not expected"); }; + } + + private Path writeSpec(String serversBlock) throws IOException { + Path p = tmp.resolve("openapi.yaml"); + Files.writeString(p, SPEC_TEMPLATE.replace("%SERVERS%", serversBlock)); + return p; + } + + @Test + void e2e_absoluteServerWithLocalFileSpec_dispatchHitsAbsoluteUrl() throws Exception { + Path spec = writeSpec("servers: [ { url: 'https://api.example.com/v1' } ]"); + CapturingHttpClient http = new CapturingHttpClient(); + OpenApiClient client = new OpenApiClient(spec.toString(), http, HttpConfig.empty(), noKeychain()); + client.callMethod("ping", Collections.emptyMap(), null); + HttpRequest sent = http.last.get(); + assertThat(sent).isNotNull(); + assertThat(sent.getUrl()).startsWith("https://api.example.com/v1/ping"); + } +}