Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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++
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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).
*
* <p>Supports the three permitted URL forms:
* <ul>
* <li><b>Absolute</b> — {@code https://api.example.com/v1}: returned as-is.</li>
* <li><b>Server-relative</b> — {@code /v1}: base scheme+host from the spec URL, with the
* given path.</li>
* <li><b>Document-relative</b> — {@code .}, {@code ./v2}, {@code ../v2}, {@code v2}:
* resolved as a URI reference against the spec URL via {@link java.net.URI#resolve},
* which implements RFC 3986 §5.3.</li>
* </ul>
*
* <p>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();
}

// ------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HttpRequest> 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");
}
}
Loading