diff --git a/README.md b/README.md index 271810b..45a1c06 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/libs/httpcl/CMakeLists.txt b/libs/httpcl/CMakeLists.txt index 6b5ef23..94e1ea2 100644 --- a/libs/httpcl/CMakeLists.txt +++ b/libs/httpcl/CMakeLists.txt @@ -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) diff --git a/libs/httpcl/include/httpcl/uri.hpp b/libs/httpcl/include/httpcl/uri.hpp index ec517ca..02819e5 100644 --- a/libs/httpcl/include/httpcl/uri.hpp +++ b/libs/httpcl/include/httpcl/uri.hpp @@ -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. diff --git a/libs/httpcl/src/uri.cpp b/libs/httpcl/src/uri.cpp index cf3d3cc..00dfd8a 100644 --- a/libs/httpcl/src/uri.cpp +++ b/libs/httpcl/src/uri.cpp @@ -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 "(||+|-|.)*:" (the parser will validate). + auto colonIdx = reference.find(':'); + if (colonIdx != std::string::npos && colonIdx > 0) { + bool looksLikeScheme = std::isalpha(static_cast(reference.front())); + for (size_t i = 1; i < colonIdx && looksLikeScheme; ++i) { + auto c = static_cast(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(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, diff --git a/libs/httpcl/test/src/uri.cpp b/libs/httpcl/test/src/uri.cpp index 8d958d3..f154b85 100644 --- a/libs/httpcl/test/src/uri.cpp +++ b/libs/httpcl/test/src/uri.cpp @@ -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/"); + } +} diff --git a/libs/zswagcl/src/openapi-parser.cpp b/libs/zswagcl/src/openapi-parser.cpp index a1f21f5..4b7b96d 100644 --- a/libs/zswagcl/src/openapi-parser.cpp +++ b/libs/zswagcl/src/openapi-parser.cpp @@ -443,12 +443,28 @@ static void parseServer(const YAMLScope& serverNode, { if (auto urlNode = serverNode["url"]) { auto urlStr = urlNode.as(); - 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)); } } } @@ -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()); diff --git a/libs/zswagcl/test/src/oaclient.cpp b/libs/zswagcl/test/src/oaclient.cpp index 40532f0..24e8cea 100644 --- a/libs/zswagcl/test/src/oaclient.cpp +++ b/libs/zswagcl/test/src/oaclient.cpp @@ -1858,3 +1858,110 @@ openapi: "3.0.0" ); } } + +TEST_CASE("Relative server URLs are resolved against the spec URL", "[oaclient][server-resolution]") { + // Minimal OpenAPI spec with a single GET / operation, parameterised servers field. + auto makeSpec = [](std::string const& serversBlock) { + return std::string(R"( +openapi: "3.0.0" +info: { title: t, version: "1.0" } +)") + serversBlock + R"( +paths: + /ping: + get: + operationId: ping + responses: { '200': { description: ok } } +)"; + }; + + auto mockSpecFetch = [&](std::string const& specUrl, std::string const& specBody) { + httpcl::MockHttpClient client; + client.getFun = [specBody](std::string_view uri) { + return httpcl::IHttpClient::Result{200, specBody}; + }; + return fetchOpenAPIConfig(specUrl, client); + }; + + SECTION("Absolute server URL — unchanged") { + auto cfg = mockSpecFetch( + "https://api.example.com/v1/openapi.json", + makeSpec("servers: [ { url: 'https://other.example.com/api' } ]")); + REQUIRE(cfg.servers.size() == 1); + REQUIRE(cfg.servers[0].scheme == "https"); + REQUIRE(cfg.servers[0].host == "other.example.com"); + REQUIRE(cfg.servers[0].path == "/api"); + } + + SECTION("Server-relative path '/v2' inherits spec's scheme+host") { + auto cfg = mockSpecFetch( + "https://api.example.com/v1/openapi.json", + makeSpec("servers: [ { url: '/v2' } ]")); + REQUIRE(cfg.servers[0].scheme == "https"); + REQUIRE(cfg.servers[0].host == "api.example.com"); + REQUIRE(cfg.servers[0].path == "/v2"); + } + + SECTION("Document-relative '.' resolves to the spec's directory") { + auto cfg = mockSpecFetch( + "https://api.example.com/v1/openapi.json", + makeSpec("servers: [ { url: '.' } ]")); + REQUIRE(cfg.servers[0].scheme == "https"); + REQUIRE(cfg.servers[0].host == "api.example.com"); + REQUIRE(cfg.servers[0].path == "/v1/"); + } + + SECTION("Document-relative './v2' appends to the spec's directory") { + auto cfg = mockSpecFetch( + "https://api.example.com/v1/openapi.json", + makeSpec("servers: [ { url: './v2' } ]")); + REQUIRE(cfg.servers[0].path == "/v1/v2"); + } + + SECTION("Document-relative '../v2' goes one directory up") { + auto cfg = mockSpecFetch( + "https://api.example.com/v1/openapi.json", + makeSpec("servers: [ { url: '../v2' } ]")); + REQUIRE(cfg.servers[0].path == "/v2"); + } + + SECTION("Document-relative bare 'v2' is equivalent to './v2'") { + auto cfg = mockSpecFetch( + "https://api.example.com/v1/openapi.json", + makeSpec("servers: [ { url: 'v2' } ]")); + REQUIRE(cfg.servers[0].path == "/v1/v2"); + } + + SECTION("Empty servers array defaults to '/' resolved against spec's origin") { + auto cfg = mockSpecFetch( + "https://api.example.com/v1/openapi.json", + makeSpec("servers: []")); + REQUIRE(cfg.servers[0].scheme == "https"); + REQUIRE(cfg.servers[0].host == "api.example.com"); + REQUIRE(cfg.servers[0].path == "/"); + } + + SECTION("Spec URL with port — port carries into the resolved server URL") { + auto cfg = mockSpecFetch( + "http://localhost:8080/api/openapi.json", + makeSpec("servers: [ { url: '.' } ]")); + REQUIRE(cfg.servers[0].host == "localhost"); + REQUIRE(cfg.servers[0].port == 8080); + REQUIRE(cfg.servers[0].path == "/api/"); + } + + SECTION("Multiple servers — each resolved independently") { + auto cfg = mockSpecFetch( + "https://api.example.com/v1/openapi.json", + makeSpec(R"(servers: + - url: 'https://other.example.com/api' + - url: '/v2' + - url: '.' + - url: '../v3' +)")); + REQUIRE(cfg.servers.size() == 4); + REQUIRE(cfg.servers[0].build() == "https://other.example.com/api"); + REQUIRE(cfg.servers[1].build() == "https://api.example.com/v2"); + REQUIRE(cfg.servers[2].build() == "https://api.example.com/v1/"); + REQUIRE(cfg.servers[3].build() == "https://api.example.com/v3"); + } +}