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
29 changes: 22 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1169,21 +1169,36 @@ instead of a field with a scalar value. **This is currently not supported.**
OpenAPI allows for a `servers` field in the spec that lists URL path prefixes
under which the specified API may be reached. The OpenAPI clients
looks into this list to determine a URL base path from
the first entry in this list. A sample entry might look as follows:
the first entry in this list. Per OpenAPI 3.0+ (clarified in
[3.2.0 §4.5.2.1](https://spec.openapis.org/oas/v3.2.0.html#examples-of-api-base-url-determination)),
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 OpenAPI client will then call methods with your specified host
and port, but prefix the `/path/to/my/api` string.
An absent or empty `servers` array defaults to `[{ "url": "/" }]`
(server-relative to the spec's origin root).

#### Component Support

| Feature | C++ Client | Python Client | OAServer | zswag.gen |
| ------------------ | ---------- | ------------- | -------- | --------- |
| `servers` | ✔️ | ✔️ | ✔️ | ✔️ |
| `servers` (absolute URL) | ✔️ | ✔️ | ✔️ | ✔️ |
| `servers` (server-relative `/path`) | ✔️ | ✔️ | ✔️ | ✔️ |
| `servers` (document-relative `.`, `./v2`, `../v2`) | ✔️ | ✔️ | n/a | n/a |

### Authentication Schemes

Expand Down
7 changes: 7 additions & 0 deletions libs/httpcl/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ if (ZSWAG_KEYCHAIN_SUPPORT)
target_link_libraries(httpcl PUBLIC keychain)
endif()

# httplib's load_system_certs uses Security.framework on Apple platforms.
# Without this PUBLIC link, downstream test executables fail at link time
# with undefined SecCertificateCopyData / SecTrustCopyAnchorCertificates.
if(APPLE)
target_link_libraries(httpcl PUBLIC "-framework Security" "-framework CoreFoundation")
endif()

# Enable coverage for httpcl
if(ZSWAG_ENABLE_COVERAGE)
target_enable_coverage(httpcl)
Expand Down
18 changes: 18 additions & 0 deletions libs/httpcl/include/httpcl/uri.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ struct URIComponents
*/
static URIComponents fromStrPath(std::string const& pathAndQueryString);

/**
* Resolve a URI reference against a base URI per RFC 3986 §5.3 (Strict
* Resolution). Supports the three reference forms permitted in OpenAPI
* 3.0+ {@code servers[].url}:
* - Absolute URI: {@code https://example.com/v1} returned as-is
* - Server-relative path: {@code /v1} base scheme+host + /v1
* - Document-relative path: {@code .}, {@code ./v2}, {@code ../v2}, {@code v2}
* base scheme+host + base
* directory merged with the
* reference, with dot-segments
* (§5.2.4) removed.
*
* The base URI must be absolute (scheme and host present); otherwise
* URIError is thrown for non-absolute references.
*/
static URIComponents resolveReference(std::string const& reference,
URIComponents const& base);

/**
* Append one or multiple path-parts ("/a/b/c") to the URIs
* path.
Expand Down
110 changes: 110 additions & 0 deletions libs/httpcl/src/uri.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,116 @@ URIComponents URIComponents::fromStrPath(std::string const& pathAndQueryString)
return result;
}

/**
* Remove dot segments from a path, per RFC 3986 §5.2.4.
*
* The input must be a path string starting with '/' (or empty). Standard
* algorithm: walk the input segment-by-segment, push to the output buffer
* unless the segment is "." (drop) or ".." (pop the previous output segment).
*/
static std::string removeDotSegments(std::string const& path)
{
if (path.empty())
return path;

std::string out;
std::string in = path;

while (!in.empty()) {
if (in.rfind("../", 0) == 0) {
// Leading "../" outside an absolute path — drop it.
in.erase(0, 3);
} else if (in.rfind("./", 0) == 0) {
in.erase(0, 2);
} else if (in.rfind("/./", 0) == 0) {
in.replace(0, 3, "/");
} else if (in == "/.") {
in = "/";
} else if (in.rfind("/../", 0) == 0) {
in.replace(0, 4, "/");
auto lastSlash = out.find_last_of('/');
out.erase(lastSlash == std::string::npos ? 0 : lastSlash);
} else if (in == "/..") {
in = "/";
auto lastSlash = out.find_last_of('/');
out.erase(lastSlash == std::string::npos ? 0 : lastSlash);
} else if (in == "." || in == "..") {
// Bare dot at end — drop entirely.
in.clear();
} else {
// Move the first segment (up to but not including the next '/'
// after position 0) from in to out.
auto end = in.find('/', in.front() == '/' ? 1 : 0);
if (end == std::string::npos) {
out += in;
in.clear();
} else {
out.append(in, 0, end);
in.erase(0, end);
}
}
}
return out;
}

URIComponents URIComponents::resolveReference(std::string const& reference,
URIComponents const& base)
{
if (reference.empty())
return base;

// RFC 3986 §5.3 step 1: a reference with its own scheme is absolute.
// Detect via "<alpha>(<alpha>|<digit>|+|-|.)*:" (the parser will validate).
auto colonIdx = reference.find(':');
if (colonIdx != std::string::npos && colonIdx > 0) {
bool looksLikeScheme = std::isalpha(static_cast<unsigned char>(reference.front()));
for (size_t i = 1; i < colonIdx && looksLikeScheme; ++i) {
auto c = static_cast<unsigned char>(reference[i]);
if (!std::isalnum(c) && c != '+' && c != '-' && c != '.')
looksLikeScheme = false;
}
if (looksLikeScheme)
return fromStrRfc3986(reference);
}

if (base.scheme.empty() || base.host.empty()) {
throw logRuntimeError<URIError>(stx::format(
"[URIComponents::resolveReference] Cannot resolve relative reference '{}' "
"against base URI lacking scheme/host", reference));
}

URIComponents result;
result.scheme = base.scheme;
result.host = base.host;
result.port = base.port;

// Split reference into path + query (OpenAPI server URLs don't carry fragments).
std::string refPath;
std::string refQuery;
if (auto qIdx = reference.find('?'); qIdx != std::string::npos) {
refPath = reference.substr(0, qIdx);
refQuery = reference.substr(qIdx + 1);
} else {
refPath = reference;
}

if (!refPath.empty() && refPath.front() == '/') {
// Server-relative — replace base path entirely (RFC 3986 §5.2.2 case 3).
result.path = removeDotSegments(refPath);
} else {
// Document-relative — merge base directory with the reference per §5.2.3.
std::string baseDir = base.path;
auto lastSlash = baseDir.find_last_of('/');
baseDir = (lastSlash != std::string::npos)
? baseDir.substr(0, lastSlash + 1)
: std::string("/");
result.path = removeDotSegments(baseDir + refPath);
}

result.query = refQuery;
return result;
}

URIComponents::URIComponents(
std::string scheme,
std::string host,
Expand Down
84 changes: 84 additions & 0 deletions libs/httpcl/test/src/uri.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,87 @@ TEST_CASE("Build URIs", "[uri-builder]") {
REQUIRE(builder.build() == "ftp://host:123/this/is/%3a)/the/path?hello;&%3cvar%3e=%3cvalue%3e");
}
}

TEST_CASE("Resolve URI reference against base", "[uri-resolve]") {
using httpcl::URIComponents;

// Typical OpenAPI scenario: spec at https://api.example.com/v1/openapi.json,
// various server URL forms.
auto base = URIComponents::fromStrRfc3986("https://api.example.com/v1/openapi.json");

SECTION("Absolute reference returned as-is") {
auto r = URIComponents::resolveReference("https://other.example.com/api", base);
REQUIRE(r.scheme == "https");
REQUIRE(r.host == "other.example.com");
REQUIRE(r.path == "/api");
}

SECTION("Server-relative path replaces base path entirely") {
auto r = URIComponents::resolveReference("/v2", base);
REQUIRE(r.scheme == "https");
REQUIRE(r.host == "api.example.com");
REQUIRE(r.path == "/v2");
}

SECTION("Document-relative '.' resolves to base directory") {
auto r = URIComponents::resolveReference(".", base);
REQUIRE(r.scheme == "https");
REQUIRE(r.host == "api.example.com");
REQUIRE(r.path == "/v1/");
}

SECTION("Document-relative './v2' appends to base directory") {
auto r = URIComponents::resolveReference("./v2", base);
REQUIRE(r.path == "/v1/v2");
}

SECTION("Document-relative bare 'v2' (no './' prefix) treated like './v2'") {
auto r = URIComponents::resolveReference("v2", base);
REQUIRE(r.path == "/v1/v2");
}

SECTION("Document-relative '../v2' resolves one directory up") {
auto r = URIComponents::resolveReference("../v2", base);
REQUIRE(r.path == "/v2");
}

SECTION("Document-relative '../../v2' resolves further up; capped at root") {
// Two levels up from /v1/openapi.json -> /v2 (clamped — can't go above root)
auto r = URIComponents::resolveReference("../../v2", base);
REQUIRE(r.path == "/v2");
}

SECTION("Empty reference returns base unchanged") {
auto r = URIComponents::resolveReference("", base);
REQUIRE(r.scheme == base.scheme);
REQUIRE(r.host == base.host);
REQUIRE(r.path == base.path);
}

SECTION("Reference with query string preserves query") {
auto r = URIComponents::resolveReference("./v2?token=abc", base);
REQUIRE(r.path == "/v1/v2");
REQUIRE(r.query == "token=abc");
}

SECTION("Base port carries over") {
auto basePort = URIComponents::fromStrRfc3986("http://localhost:8080/api/openapi.json");
auto r = URIComponents::resolveReference(".", basePort);
REQUIRE(r.host == "localhost");
REQUIRE(r.port == 8080);
REQUIRE(r.path == "/api/");
}

SECTION("Relative reference against base lacking scheme throws") {
URIComponents bareBase;
bareBase.path = "/foo/bar";
REQUIRE_THROWS_AS(
URIComponents::resolveReference(".", bareBase),
httpcl::URIError);
}

SECTION("Resolved URI is buildable") {
auto r = URIComponents::resolveReference(".", base);
REQUIRE(r.build() == "https://api.example.com/v1/");
}
}
51 changes: 40 additions & 11 deletions libs/zswagcl/src/openapi-parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -443,12 +443,28 @@ static void parseServer(const YAMLScope& serverNode,
{
if (auto urlNode = serverNode["url"]) {
auto urlStr = urlNode.as<std::string>();
if (urlStr.empty()) {
// Ignore empty URLs.
if (urlStr.empty())
return;

// OpenAPI 3.0+ allows three URL forms in `servers[].url` (per
// OpenAPI 3.2.0 §4.5.2.1):
//
// 1. Absolute: https://api.example.com/v1
// 2. Server-relative: /v1
// 3. Document-relative: . ./v2 ../v2 v2
//
// Forms 1 and 2 can be parsed immediately. Form 3 needs the spec
// URL as the resolution base, which we don't have here — defer to
// fetchOpenAPIConfig() by stashing the raw reference into `path`
// (scheme/host left empty signals "resolve me").
if (urlStr.find("://") != std::string::npos) {
config.servers.emplace_back(httpcl::URIComponents::fromStrRfc3986(urlStr));
} else if (urlStr.front() == '/') {
config.servers.emplace_back(httpcl::URIComponents::fromStrPath(urlStr));
} else {
config.servers.emplace_back(httpcl::URIComponents::fromStrRfc3986(urlStr));
httpcl::URIComponents deferred;
deferred.path = urlStr; // raw document-relative reference
config.servers.emplace_back(std::move(deferred));
}
}
}
Expand Down Expand Up @@ -538,15 +554,28 @@ OpenAPIConfig fetchOpenAPIConfig(const std::string& url,

httpcl::log().debug("{} Parsing OpenAPI spec", debugContext);
auto config = parseOpenAPIConfig(ss);
// Add a default server and add missing server uri parts.
if (config.servers.empty())
config.servers.emplace_back();
// Per OpenAPI 3.0+ §4.7.5, an absent or empty `servers` array implies
// `[{ "url": "/" }]` (server-relative to the spec's origin root).
if (config.servers.empty()) {
httpcl::URIComponents implicit;
implicit.path = "/";
config.servers.emplace_back(std::move(implicit));
}
// Resolve relative server URLs against the spec URL.
// parseServer leaves scheme/host empty when the URL is server-relative
// (path-only) or document-relative; URIComponents::resolveReference
// implements RFC 3986 §5.3 reference resolution.
for (auto& server : config.servers) {
if (server.scheme.empty())
server.scheme = uriParts.scheme;
if (server.host.empty()) {
server.host = uriParts.host;
server.port = uriParts.port;
if (server.scheme.empty() || server.host.empty()) {
try {
auto rawRef = server.path;
server = httpcl::URIComponents::resolveReference(rawRef, uriParts);
}
catch (httpcl::URIError const& e) {
throw httpcl::logRuntimeError(stx::format(
"Cannot resolve relative server URL '{}' against spec URL '{}': {}",
server.path, url, e.what()));
}
}
}
httpcl::log().debug("{} Parsed spec has {} methods.", debugContext, config.methodPath.size());
Expand Down
Loading
Loading