From fbe5eb790f1cb9f2f229f4ea998f30a7d2d49225 Mon Sep 17 00:00:00 2001 From: Fabian Klebert Date: Mon, 18 May 2026 16:28:46 +0200 Subject: [PATCH 1/3] docs: document all three permitted server URL forms in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAPI Options Interoperability section showed servers as a single ✔️ row with an example using only the historically-supported form (absolute URL with path prefix). Expand to show all three forms from OpenAPI 3.0+ (clarified in 3.2.0 §4.5.2.1) with concrete examples, and split the matrix row to make clear which forms are supported per client. The 'document-relative' row is marked n/a for OAServer and zswag.gen since neither consumes servers[].url at runtime — OAServer routes based on operation paths, zswag.gen emits whatever the user supplies. --- README.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 89e4972..1b2ab66 100644 --- a/README.md +++ b/README.md @@ -492,14 +492,26 @@ Compound (struct-typed) `x-zserio-request-part` is unsupported across all compon ### Server URL base path -Each client takes the URL base path from `servers[N]` (default `N = 0`): +Each client takes the URL base path from `servers[N]` (default `N = 0`). Per OpenAPI 3.0+ (clarified in 3.2.0 §4.5.2.1), three URL forms are supported, all resolved against the spec URL via RFC 3986 §5.3 reference resolution: ```yaml servers: -- http://unused-host-information/path/to/my/api + # 1. Absolute — used as-is + - url: https://api.example.com/v1 + + # 2. Server-relative path — host+scheme from the spec URL, given path + - url: /v1 + + # 3. Document-relative — resolved against the spec's directory: + - url: . # spec at https://x/foo/openapi.json -> https://x/foo/ + - url: ./v2 # -> https://x/foo/v2 + - url: ../v2 # -> https://x/v2 + - url: v2 # -> https://x/foo/v2 (same as ./v2) ``` -The host/port comes from the request, but the path prefix is taken from this entry. To target a non-default server entry, pass `serverIndex` / `server_index`: +An absent or empty `servers` array defaults to `[{ "url": "/" }]` (server-relative to the spec's origin root). + +To target a non-default server entry, pass `serverIndex` / `server_index`: ```cpp // C++ @@ -520,7 +532,9 @@ OAClient transport = new OAClient(context, url, persistent, adhoc, 1); | Feature | C++ Client | Python Client | Java Client | OAServer | zswag.gen | |---|---|---|---|---|---| -| `servers` (URL pickup) | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| `servers` (absolute URL) | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| `servers` (server-relative `/path`) | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| `servers` (document-relative `.`, `./v2`, `../v2`) | ✔️ | ✔️ | ✔️ | n/a | n/a | | Selecting `servers[N]` (multi-server) | ✔️ | ✔️ | ✔️ | n/a | n/a | ### Authentication schemes From a61765439ce8c8b68936ae9b86109e16f5bf4183 Mon Sep 17 00:00:00 2001 From: Fabian Klebert Date: Mon, 18 May 2026 16:28:35 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(#159):=20support=20relative=20URLs=20i?= =?UTF-8?q?n=20OpenAPI=20servers[].url=20=E2=80=94=20Java?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites OpenApiClient.resolveBaseUrl to use java.net.URI.resolve, which natively implements RFC 3986 §5.3 reference resolution. Previous implementation handled only: - Empty server URL (used spec origin) - URL starting with '/' (path-only, combined with spec origin) - Absolute URL (returned as-is) It silently broke for document-relative forms ('.', './v2', '../v2', bare 'v2') — the else-branch returned them unchanged, producing nonsense like "." concatenated with the operation path at request time. The new implementation: * Converts spec location to java.net.URI (http(s)://, file://, or local path -> file URI) * Converts server URL to java.net.URI as a reference * Returns specBase.resolve(serverRef) Works for all three URL forms against both HTTP and local-file spec locations. The earlier "absolute server URL with local-file spec" behaviour is preserved via an explicit check (avoids producing a file:// base URL when the server URL is absolute). OpenApiClientBaseUrlTest covers each reference form via direct URI resolution (decoupling the test from the spec-fetch path) plus two end-to-end tests against a real OpenApiClient using a temp-file spec. Closes #159 (Java side; C++/Python handled in the preceding commit). --- .../ndsev/zswag/shared/OpenApiClient.java | 85 +++++++--- .../shared/OpenApiClientBaseUrlTest.java | 157 ++++++++++++++++++ 2 files changed, 222 insertions(+), 20 deletions(-) create mode 100644 libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenApiClientBaseUrlTest.java 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..ebe450a 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,78 @@ private static OpenAPIParser parseSpec(@NotNull String specLocation, @NotNull IH return new OpenAPIParser(specLocation, httpClient, adhoc, extraHeaders); } + /** + * Resolves the base URL for API requests by resolving the OpenAPI spec's + * {@code servers[serverIndex].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. An empty/absent + * {@code servers} array defaults to {@code "/"} per the spec. + */ @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) : "/"; + + java.net.URI specBase; + try { + specBase = specLocationAsUri(); + } catch (java.net.URISyntaxException e) { + logger.warn("Spec location '{}' is not a valid URI; returning server URL as-is: {}", + specLocation, e.getMessage()); + return 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()); + 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 java.net.URI specLocationAsUri() 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..b5cf14a --- /dev/null +++ b/libs/jzswag/jzswag-shared/src/test/java/io/github/ndsev/zswag/shared/OpenApiClientBaseUrlTest.java @@ -0,0 +1,157 @@ +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.charset.StandardCharsets; +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} resolves {@code servers[0].url} against the + * spec URL 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 + * — exercised against both HTTP and local-file spec locations. + * + *

The actual request URL is captured by a stub {@link IHttpClient} and the + * assertion checks that the prefix matches the expected resolved base. + */ +class OpenApiClientBaseUrlTest { + + @TempDir + Path tmp; + + 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 } }" + ); + + /** Captures the most recent outgoing HttpRequest so tests can assert on the URL. */ + 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"); }; + } + + /** + * Builds a temp spec file with the given servers block and returns its path. + * Local-file specs let us exercise the full code path without needing a + * real HTTP server. + */ + private Path writeSpec(String serversBlock) throws IOException { + Path p = tmp.resolve("openapi.yaml"); + Files.writeString(p, SPEC_TEMPLATE.replace("%SERVERS%", serversBlock)); + return p; + } + + /** Calls /ping and returns the URL the HTTP layer saw, so tests can assert prefixes. */ + private String dispatchAndCaptureUrl(OpenApiClient client, CapturingHttpClient http) throws HttpException { + client.callMethod("ping", Collections.emptyMap(), null); + HttpRequest sent = http.last.get(); + assertThat(sent).as("dispatch must have reached the HTTP layer").isNotNull(); + return sent.getUrl(); + } + + @Test + void absoluteServerUrlReturnedAsIs() throws Exception { + Path spec = writeSpec("servers: [ { url: 'https://other.example.com/api' } ]"); + CapturingHttpClient http = new CapturingHttpClient(); + OpenApiClient client = new OpenApiClient(spec.toString(), http, HttpConfig.empty(), noKeychain()); + assertThat(dispatchAndCaptureUrl(client, http)).startsWith("https://other.example.com/api/ping"); + } + + @Test + void serverRelativeUrlAgainstHttpSpec_pathReplaced() throws Exception { + // The spec is local but we use a fake HTTP spec URL by writing it to a temp file + // and passing the file path. Spec location resolution is independent of where the + // file actually lives — what matters is the URI we treat as the base. For HTTP + // semantics we use a stand-alone unit test via java.net.URI directly: + java.net.URI base = new java.net.URI("https://api.example.com/v1/openapi.json"); + java.net.URI ref = new java.net.URI("/v2"); + assertThat(base.resolve(ref).toString()).isEqualTo("https://api.example.com/v2"); + } + + @Test + void documentRelativeDot_resolvesToSpecDirectory() throws Exception { + java.net.URI base = new java.net.URI("https://api.example.com/v1/openapi.json"); + java.net.URI ref = new java.net.URI("."); + assertThat(base.resolve(ref).toString()).isEqualTo("https://api.example.com/v1/"); + } + + @Test + void documentRelativeDotSlashV2_appendsToSpecDirectory() throws Exception { + java.net.URI base = new java.net.URI("https://api.example.com/v1/openapi.json"); + java.net.URI ref = new java.net.URI("./v2"); + assertThat(base.resolve(ref).toString()).isEqualTo("https://api.example.com/v1/v2"); + } + + @Test + void documentRelativeDotDotSlashV2_goesUpOneDirectory() throws Exception { + java.net.URI base = new java.net.URI("https://api.example.com/v1/openapi.json"); + java.net.URI ref = new java.net.URI("../v2"); + assertThat(base.resolve(ref).toString()).isEqualTo("https://api.example.com/v2"); + } + + @Test + void documentRelativeBareV2_treatedAsDotSlashV2() throws Exception { + java.net.URI base = new java.net.URI("https://api.example.com/v1/openapi.json"); + java.net.URI ref = new java.net.URI("v2"); + assertThat(base.resolve(ref).toString()).isEqualTo("https://api.example.com/v1/v2"); + } + + @Test + void absoluteServerWithLocalFileSpec_dispatchUsesAbsoluteBase() 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()); + // With a local-file spec, an absolute server URL must still be used as-is. + assertThat(dispatchAndCaptureUrl(client, http)).startsWith("https://api.example.com/v1/ping"); + } + + @Test + void emptyServersDefaultsToRootResolvedAgainstSpec() throws Exception { + // Verified at the URI level: empty servers -> "/" -> spec_scheme://spec_host/ + java.net.URI base = new java.net.URI("https://api.example.com/v1/openapi.json"); + java.net.URI ref = new java.net.URI("/"); + assertThat(base.resolve(ref).toString()).isEqualTo("https://api.example.com/"); + } + + @Test + void portCarriesIntoResolvedUrl() throws Exception { + java.net.URI base = new java.net.URI("http://localhost:8080/api/openapi.json"); + java.net.URI ref = new java.net.URI("."); + assertThat(base.resolve(ref).toString()).isEqualTo("http://localhost:8080/api/"); + } +} From 16888912c3b124995866162ffc8253245df4e1f0 Mon Sep 17 00:00:00 2001 From: Fabian Klebert Date: Mon, 18 May 2026 19:07:16 +0200 Subject: [PATCH 3/3] test: exercise OpenApiClient.resolveBaseUrl branches directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codecov/patch on PR #160 flagged 36.36% patch coverage on OpenApiClient.java because the previous test class verified java.net.URI.resolve() in isolation — the production method's branches (file:// fallback, URISyntaxException paths, etc.) weren't hit by any test. Refactor: * Extract resolveBaseUrl(specLocation, serverUrl) as a static package-private helper. The instance method delegates to it. * OpenApiClientBaseUrlTest now calls the static helper directly, exercising every branch including the file:// + relative warning, the malformed-URI path, and the file URI + document-relative case. * Keep one end-to-end test that constructs a real OpenApiClient from a temp-file spec so the wiring stays verified. Tests in this class: 9 -> 13. All 236 Java tests still passing. --- .../ndsev/zswag/shared/OpenApiClient.java | 33 +-- .../shared/OpenApiClientBaseUrlTest.java | 217 ++++++++++-------- 2 files changed, 147 insertions(+), 103 deletions(-) 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 ebe450a..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 @@ -134,9 +134,21 @@ private static OpenAPIParser parseSpec(@NotNull String specLocation, @NotNull IH } /** - * Resolves the base URL for API requests by resolving the OpenAPI spec's - * {@code servers[serverIndex].url} against the spec location, per OpenAPI 3.0+ - * (clarified in OpenAPI 3.2.0 §4.5.2.1). + * 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(); + // 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); + } + + /** + * 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. An empty/absent - * {@code servers} array defaults to {@code "/"} per the spec. + *

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 - private String resolveBaseUrl() { - List servers = parser.getServers(); - // 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) : "/"; - + static String resolveBaseUrl(@NotNull String specLocation, @NotNull String serverUrl) { java.net.URI specBase; try { - specBase = specLocationAsUri(); + 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()); @@ -199,7 +206,7 @@ private String resolveBaseUrl() { * its parent directory. */ @NotNull - private java.net.URI specLocationAsUri() throws java.net.URISyntaxException { + 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); 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 index b5cf14a..f341c8f 100644 --- 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 @@ -11,7 +11,6 @@ import org.junit.jupiter.api.io.TempDir; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; @@ -21,19 +20,133 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Verifies that {@code OpenApiClient} resolves {@code servers[0].url} against the - * spec URL 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 - * — exercised against both HTTP and local-file spec locations. - * - *

The actual request URL is captured by a stub {@link IHttpClient} and the - * assertion checks that the prefix matches the expected resolved base. + * 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' }", @@ -45,14 +158,11 @@ class OpenApiClientBaseUrlTest { " responses: { '200': { description: ok } }" ); - /** Captures the most recent outgoing HttpRequest so tests can assert on the URL. */ private static final class CapturingHttpClient implements IHttpClient { final AtomicReference last = new AtomicReference<>(); @Override - public HttpSettings getPersistentSettings() { - return HttpSettings.empty(); - } + public HttpSettings getPersistentSettings() { return HttpSettings.empty(); } @Override public HttpResponse execute(HttpRequest request, HttpConfig adhoc) { @@ -65,93 +175,20 @@ private static IKeychain noKeychain() { return (s, u) -> { throw new RuntimeException("keychain not expected"); }; } - /** - * Builds a temp spec file with the given servers block and returns its path. - * Local-file specs let us exercise the full code path without needing a - * real HTTP server. - */ private Path writeSpec(String serversBlock) throws IOException { Path p = tmp.resolve("openapi.yaml"); Files.writeString(p, SPEC_TEMPLATE.replace("%SERVERS%", serversBlock)); return p; } - /** Calls /ping and returns the URL the HTTP layer saw, so tests can assert prefixes. */ - private String dispatchAndCaptureUrl(OpenApiClient client, CapturingHttpClient http) throws HttpException { - client.callMethod("ping", Collections.emptyMap(), null); - HttpRequest sent = http.last.get(); - assertThat(sent).as("dispatch must have reached the HTTP layer").isNotNull(); - return sent.getUrl(); - } - - @Test - void absoluteServerUrlReturnedAsIs() throws Exception { - Path spec = writeSpec("servers: [ { url: 'https://other.example.com/api' } ]"); - CapturingHttpClient http = new CapturingHttpClient(); - OpenApiClient client = new OpenApiClient(spec.toString(), http, HttpConfig.empty(), noKeychain()); - assertThat(dispatchAndCaptureUrl(client, http)).startsWith("https://other.example.com/api/ping"); - } - - @Test - void serverRelativeUrlAgainstHttpSpec_pathReplaced() throws Exception { - // The spec is local but we use a fake HTTP spec URL by writing it to a temp file - // and passing the file path. Spec location resolution is independent of where the - // file actually lives — what matters is the URI we treat as the base. For HTTP - // semantics we use a stand-alone unit test via java.net.URI directly: - java.net.URI base = new java.net.URI("https://api.example.com/v1/openapi.json"); - java.net.URI ref = new java.net.URI("/v2"); - assertThat(base.resolve(ref).toString()).isEqualTo("https://api.example.com/v2"); - } - - @Test - void documentRelativeDot_resolvesToSpecDirectory() throws Exception { - java.net.URI base = new java.net.URI("https://api.example.com/v1/openapi.json"); - java.net.URI ref = new java.net.URI("."); - assertThat(base.resolve(ref).toString()).isEqualTo("https://api.example.com/v1/"); - } - @Test - void documentRelativeDotSlashV2_appendsToSpecDirectory() throws Exception { - java.net.URI base = new java.net.URI("https://api.example.com/v1/openapi.json"); - java.net.URI ref = new java.net.URI("./v2"); - assertThat(base.resolve(ref).toString()).isEqualTo("https://api.example.com/v1/v2"); - } - - @Test - void documentRelativeDotDotSlashV2_goesUpOneDirectory() throws Exception { - java.net.URI base = new java.net.URI("https://api.example.com/v1/openapi.json"); - java.net.URI ref = new java.net.URI("../v2"); - assertThat(base.resolve(ref).toString()).isEqualTo("https://api.example.com/v2"); - } - - @Test - void documentRelativeBareV2_treatedAsDotSlashV2() throws Exception { - java.net.URI base = new java.net.URI("https://api.example.com/v1/openapi.json"); - java.net.URI ref = new java.net.URI("v2"); - assertThat(base.resolve(ref).toString()).isEqualTo("https://api.example.com/v1/v2"); - } - - @Test - void absoluteServerWithLocalFileSpec_dispatchUsesAbsoluteBase() throws Exception { + 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()); - // With a local-file spec, an absolute server URL must still be used as-is. - assertThat(dispatchAndCaptureUrl(client, http)).startsWith("https://api.example.com/v1/ping"); - } - - @Test - void emptyServersDefaultsToRootResolvedAgainstSpec() throws Exception { - // Verified at the URI level: empty servers -> "/" -> spec_scheme://spec_host/ - java.net.URI base = new java.net.URI("https://api.example.com/v1/openapi.json"); - java.net.URI ref = new java.net.URI("/"); - assertThat(base.resolve(ref).toString()).isEqualTo("https://api.example.com/"); - } - - @Test - void portCarriesIntoResolvedUrl() throws Exception { - java.net.URI base = new java.net.URI("http://localhost:8080/api/openapi.json"); - java.net.URI ref = new java.net.URI("."); - assertThat(base.resolve(ref).toString()).isEqualTo("http://localhost:8080/api/"); + client.callMethod("ping", Collections.emptyMap(), null); + HttpRequest sent = http.last.get(); + assertThat(sent).isNotNull(); + assertThat(sent.getUrl()).startsWith("https://api.example.com/v1/ping"); } }