diff --git a/CMakeLists.txt b/CMakeLists.txt index cf61627..031fac7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,11 +23,13 @@ endif() find_package(CURL QUIET) find_package(OpenSSL QUIET) find_package(Threads REQUIRED) +find_package(ZLIB QUIET) add_library(alternator_client_cpp src/attribute_value.cpp src/config.cpp src/go_random.cpp + src/http_compression.cpp src/http_client.cpp src/key_route_affinity.cpp src/live_nodes.cpp @@ -48,6 +50,13 @@ target_link_libraries(alternator_client_cpp PUBLIC Threads::Threads) +if(ZLIB_FOUND) + target_link_libraries(alternator_client_cpp PUBLIC ZLIB::ZLIB) + target_compile_definitions(alternator_client_cpp PUBLIC SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB=1) +else() + message(STATUS "ZLIB not found; gzip/deflate response decoding requires a caller-provided HttpContentEncodingDecoder") +endif() + if(CURL_FOUND) target_link_libraries(alternator_client_cpp PUBLIC CURL::libcurl) target_compile_definitions(alternator_client_cpp PUBLIC SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_CURL=1) @@ -60,17 +69,7 @@ else() endif() if(ALTERNATOR_CLIENT_CPP_ENABLE_AWS) - find_package(ZLIB QUIET) - if(ZLIB_FOUND) - find_package(AWSSDK QUIET COMPONENTS dynamodb) - else() - set(AWSSDK_FOUND FALSE) - if(ALTERNATOR_CLIENT_CPP_REQUIRE_AWS) - message(FATAL_ERROR "ZLIB not found; AWS DynamoDB adapter target cannot be built") - else() - message(STATUS "ZLIB not found; AWS DynamoDB adapter target will not be built") - endif() - endif() + find_package(AWSSDK QUIET COMPONENTS dynamodb) if(AWSSDK_FOUND) add_library(alternator_client_cpp_aws src/aws_dynamodb_helper.cpp) diff --git a/README.md b/README.md index c30f197..28a5c10 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ make build make test ``` -The core target requires C++17. The default discovery HTTP client uses libcurl when CMake can find the libcurl development package; otherwise it falls back to a small POSIX plain-HTTP client. HTTPS discovery requires libcurl or a caller-provided `HttpClient`. Tests use GoogleTest when it is installed. The AWS adapter target is built only when CMake can find `AWSSDK` with the `dynamodb` component. `make test-integration` requires the AWS adapter and its tests by configuring with `ALTERNATOR_CLIENT_CPP_REQUIRE_AWS=ON`. +The core target requires C++17. The default discovery HTTP client uses libcurl when CMake can find the libcurl development package; otherwise it falls back to a small POSIX plain-HTTP client. HTTPS discovery requires libcurl or a caller-provided `HttpClient`. Built-in gzip and deflate response decoding is available when CMake finds zlib; callers can provide a custom decoder for other content encodings or for builds without zlib. Tests use GoogleTest when it is installed. The AWS adapter target is built only when CMake can find `AWSSDK` with the `dynamodb` component. `make test-integration` requires the AWS adapter and its tests by configuring with `ALTERNATOR_CLIENT_CPP_REQUIRE_AWS=ON`. ## Core Usage @@ -87,6 +87,71 @@ The AWS adapter uses the SDK's per-client `DynamoDBEndpointProviderBase` hook by The HTTP client factory runs after request signing. This is the closest public AWS SDK for C++ hook for retry-aware endpoint rewriting, but it means applications that depend on strict SigV4 host validation should prefer endpoint-provider routing or implement their own SDK wrapper. Automatic middleware features such as transparent header optimization and request-content-based endpoint rewriting are not available in the same form. The core library does provide deterministic key-route affinity primitives, and `DynamoDBHelper` exposes them for applications that integrate request-specific routing in their own AWS SDK wrapper. +### HTTP Response Compression + +Response compression is disabled by default. When configured, the built-in +discovery client sends the configured `Accept-Encoding` value on `/localnodes` +requests. If zlib was found at build time, configuring +`ZlibContentEncodingDecoder` enables matching `Content-Encoding: gzip` and +`Content-Encoding: deflate` responses. + +For DynamoDB operations, response compression is supported when +`DynamoDBHelper::ApplyToSDKOptions()` installs the Alternator HTTP client +factory before `Aws::InitAPI()`. The factory advertises the configured response +encodings only for requests routed to Alternator nodes and decodes compressed +responses before the AWS SDK parses the JSON body. A DynamoDB client that only +uses the endpoint provider does not get this factory-level response decoding. + +Configure content-encoding decoders before constructing the helper or discovery +client. The default empty decoder list disables compression advertisement. +Each decoder advertises the encodings it supports, and each advertised encoding +may be owned by only one decoder. Responses using an encoding outside the +configured decoder list are rejected. + +```cpp +scylladb::alternator::Config cfg; +cfg.content_encoding_decoders.push_back( + std::make_shared()); +``` + +When zlib is available, `ZlibContentEncodingDecoder` implements gzip and deflate +decoding and advertises both encodings by default. Pass a subset to advertise +only one of them, for example `ZlibContentEncodingDecoder({"gzip"})`. zlib is +optional at build time; without it, applications can still provide their own +decoder implementations. + +For generic content-encoding support, provide another decoder. Its +`AcceptedResponseEncodings()` method declares the encodings to advertise, and +`Decode()` receives the encoded response body and the specific encoding token to +decode: + +```cpp +class BrotliDecoder final : public scylladb::alternator::HttpContentEncodingDecoder { +public: + std::vector AcceptedResponseEncodings() const override { + return {"br"}; + } + + std::string Decode(std::string body, const std::string& content_encoding) const override { + // Decode body according to content_encoding. + return body; + } +}; + +scylladb::alternator::Config cfg; +cfg.content_encoding_decoders.push_back(std::make_shared()); +``` + +To request gzip and zstd, configure zlib for gzip only and add a zstd decoder: + +```cpp +scylladb::alternator::Config cfg; +cfg.content_encoding_decoders.push_back( + std::make_shared( + std::vector{"gzip"})); +cfg.content_encoding_decoders.push_back(std::make_shared()); +``` + ### DynamoDB HTTP Connections DynamoDB requests use the AWS SDK for C++ HTTP transport. The helper passes diff --git a/cmake/scylladb-alternator-client-cpp-config.cmake.in b/cmake/scylladb-alternator-client-cpp-config.cmake.in index 915385d..9b3de75 100644 --- a/cmake/scylladb-alternator-client-cpp-config.cmake.in +++ b/cmake/scylladb-alternator-client-cpp-config.cmake.in @@ -4,6 +4,10 @@ include(CMakeFindDependencyMacro) find_dependency(Threads) +if(@ZLIB_FOUND@) + find_dependency(ZLIB) +endif() + if(@CURL_FOUND@) find_dependency(CURL) endif() @@ -13,7 +17,6 @@ if(@OpenSSL_FOUND@) endif() if(@ALTERNATOR_CLIENT_CPP_AWS_TARGET_BUILT@) - find_dependency(ZLIB) find_dependency(AWSSDK COMPONENTS dynamodb) endif() diff --git a/include/scylladb/alternator/config.h b/include/scylladb/alternator/config.h index a541bf9..6336eb0 100644 --- a/include/scylladb/alternator/config.h +++ b/include/scylladb/alternator/config.h @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include #include @@ -15,6 +17,25 @@ struct Credentials { std::string secret_access_key; }; +class HttpContentEncodingDecoder { +public: + virtual ~HttpContentEncodingDecoder() = default; + + [[nodiscard]] virtual std::vector AcceptedResponseEncodings() const = 0; + [[nodiscard]] virtual std::string Decode(std::string body, const std::string& content_encoding) const = 0; +}; + +class ZlibContentEncodingDecoder final : public HttpContentEncodingDecoder { +public: + explicit ZlibContentEncodingDecoder(std::vector accepted_response_encodings = {"gzip", "deflate"}); + + [[nodiscard]] std::vector AcceptedResponseEncodings() const override; + [[nodiscard]] std::string Decode(std::string body, const std::string& content_encoding) const override; + +private: + std::vector accepted_response_encodings_; +}; + struct Config { std::uint16_t port = 8080; std::string scheme = "http"; @@ -37,6 +58,7 @@ struct Config { unsigned max_connections = 100; bool reuse_discovery_connections = true; + std::vector> content_encoding_decoders; std::string user_agent = "scylladb-alternator-client-cpp/devel"; NodeHealthStoreConfig node_health; diff --git a/src/aws_dynamodb_helper.cpp b/src/aws_dynamodb_helper.cpp index b531002..9ce0d15 100644 --- a/src/aws_dynamodb_helper.cpp +++ b/src/aws_dynamodb_helper.cpp @@ -1,5 +1,7 @@ #include +#include "http_compression.h" + #include #include #include @@ -9,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +29,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -108,6 +114,56 @@ NodeHealthObservation ObservationFromHttpResult(const std::shared_ptr(body), std::istreambuf_iterator()); +} + +void WriteResponseBody(Aws::IOStream& body, const std::string& value) { + if (!value.empty()) { + body.write(value.data(), static_cast(value.size())); + } + body.flush(); + body.clear(); + body.seekg(0, std::ios::beg); +} + +std::shared_ptr DecodeCompressedAwsResponse( + const std::shared_ptr& request, + const std::shared_ptr& response, + const std::vector>& content_encoding_decoders) { + if (!request || !response || response->HasClientError() || + !response->HasHeader(Aws::Http::CONTENT_ENCODING_HEADER)) { + return response; + } + + const auto content_encoding = std::string(response->GetHeader(Aws::Http::CONTENT_ENCODING_HEADER).c_str()); + auto decoded_body = detail::DecodeHttpResponseBody( + ReadResponseBody(response->GetResponseBody()), + content_encoding, + content_encoding_decoders); + + auto decoded_response = Aws::MakeShared( + kAllocationTag, + request); + decoded_response->SetResponseCode(response->GetResponseCode()); + + for (const auto& header : response->GetHeaders()) { + const auto header_name = detail::ToLowerAscii(std::string(header.first.c_str())); + if (header_name == Aws::Http::CONTENT_ENCODING_HEADER || + header_name == Aws::Http::CONTENT_LENGTH_HEADER) { + continue; + } + decoded_response->AddHeader(header.first, header.second); + } + decoded_response->AddHeader( + Aws::Http::CONTENT_LENGTH_HEADER, + Aws::String(std::to_string(decoded_body.size()).c_str())); + WriteResponseBody(decoded_response->GetResponseBody(), decoded_body); + return decoded_response; +} + bool IsFirstSdkAttempt(const Aws::Http::HttpRequest& request) { if (!request.HasHeader(Aws::Http::SDK_REQUEST_HEADER)) { return false; @@ -151,9 +207,12 @@ class AlternatorHttpClient final : public Aws::Http::HttpClient { public: AlternatorHttpClient(std::shared_ptr nodes, std::uint16_t endpoint_override_port, + std::vector> content_encoding_decoders, std::shared_ptr delegate) : nodes_(std::move(nodes)) , endpoint_override_port_(endpoint_override_port) + , content_encoding_decoders_(std::move(content_encoding_decoders)) + , accept_encoding_value_(detail::BuildAcceptEncodingValue(content_encoding_decoders_)) , delegate_(std::move(delegate)) { if (!nodes_) { throw std::invalid_argument("nodes must not be null"); @@ -168,8 +227,17 @@ class AlternatorHttpClient final : public Aws::Http::HttpClient { Aws::Utils::RateLimits::RateLimiterInterface* read_limiter = nullptr, Aws::Utils::RateLimits::RateLimiterInterface* write_limiter = nullptr) const override { auto node = SelectAttemptNode(request, nodes_, endpoint_override_port_); + if (!node.Empty() && !accept_encoding_value_.empty()) { + request->SetAcceptEncoding(Aws::String(accept_encoding_value_.c_str())); + } auto response = delegate_->MakeRequest(request, read_limiter, write_limiter); + if (!node.Empty()) { + response = DecodeCompressedAwsResponse( + request, + response, + content_encoding_decoders_); + } if (!node.Empty()) { nodes_->ReportNodeResult(node, ObservationFromHttpResult(response)); } @@ -183,15 +251,19 @@ class AlternatorHttpClient final : public Aws::Http::HttpClient { private: std::shared_ptr nodes_; std::uint16_t endpoint_override_port_ = 0; + std::vector> content_encoding_decoders_; + std::string accept_encoding_value_; std::shared_ptr delegate_; }; class AlternatorHttpClientFactory final : public Aws::Http::HttpClientFactory { public: AlternatorHttpClientFactory(std::shared_ptr nodes, - std::uint16_t endpoint_override_port) + std::uint16_t endpoint_override_port, + std::vector> content_encoding_decoders) : nodes_(std::move(nodes)) - , endpoint_override_port_(endpoint_override_port) { + , endpoint_override_port_(endpoint_override_port) + , content_encoding_decoders_(std::move(content_encoding_decoders)) { if (!nodes_) { throw std::invalid_argument("nodes must not be null"); } @@ -208,6 +280,7 @@ class AlternatorHttpClientFactory final : public Aws::Http::HttpClientFactory { kAllocationTag, nodes_, endpoint_override_port_, + content_encoding_decoders_, std::move(delegate)); } @@ -241,6 +314,7 @@ class AlternatorHttpClientFactory final : public Aws::Http::HttpClientFactory { private: std::shared_ptr nodes_; std::uint16_t endpoint_override_port_ = 0; + std::vector> content_encoding_decoders_; }; std::string EndpointOverride(std::uint16_t port, const std::string& scheme) { @@ -249,11 +323,13 @@ std::string EndpointOverride(std::uint16_t port, const std::string& scheme) { std::shared_ptr NewAlternatorHttpClientFactory( std::shared_ptr nodes, - std::uint16_t endpoint_override_port) { + std::uint16_t endpoint_override_port, + std::vector> content_encoding_decoders) { return Aws::MakeShared( kAllocationTag, std::move(nodes), - endpoint_override_port); + endpoint_override_port, + std::move(content_encoding_decoders)); } class FixedEndpointProvider final : public Aws::DynamoDB::Endpoint::DynamoDBEndpointProviderBase { @@ -662,7 +738,10 @@ std::shared_ptr DynamoDBHelper::NewEndpointProvider( } std::shared_ptr DynamoDBHelper::NewHttpClientFactory() const { - return NewAlternatorHttpClientFactory(nodes_, config_.port); + return NewAlternatorHttpClientFactory( + nodes_, + config_.port, + config_.content_encoding_decoders); } Aws::DynamoDB::DynamoDBClientConfiguration DynamoDBHelper::NewClientConfiguration() const { @@ -692,8 +771,9 @@ std::shared_ptr DynamoDBHelper::Nodes() const { void DynamoDBHelper::ApplyToSDKOptions(Aws::SDKOptions& options) const { auto nodes = nodes_; const auto port = config_.port; - options.httpOptions.httpClientFactory_create_fn = [nodes, port] { - return NewAlternatorHttpClientFactory(nodes, port); + const auto content_encoding_decoders = config_.content_encoding_decoders; + options.httpOptions.httpClientFactory_create_fn = [nodes, port, content_encoding_decoders] { + return NewAlternatorHttpClientFactory(nodes, port, content_encoding_decoders); }; } diff --git a/src/config.cpp b/src/config.cpp index 9479437..3a45524 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -1,5 +1,7 @@ #include +#include "http_compression.h" + #include namespace scylladb::alternator { @@ -17,6 +19,7 @@ void ValidateConfig(const Config& config) { if (config.max_connections == 0) { throw std::invalid_argument("max_connections must be > 0"); } + (void)detail::BuildAcceptEncodingValue(config.content_encoding_decoders); if (config.key_route_affinity.partition_key_discovery_attempts == 0) { throw std::invalid_argument("partition_key_discovery_attempts must be > 0"); } diff --git a/src/http_client.cpp b/src/http_client.cpp index 58eeace..da7cee1 100644 --- a/src/http_client.cpp +++ b/src/http_client.cpp @@ -1,5 +1,7 @@ #include +#include "http_compression.h" + #if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_CURL #include #if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_OPENSSL @@ -39,6 +41,12 @@ std::size_t WriteBody(char* ptr, std::size_t size, std::size_t nmemb, void* user return size * nmemb; } +std::size_t WriteHeader(char* ptr, std::size_t size, std::size_t nmemb, void* userdata) { + auto* headers = static_cast(userdata); + headers->append(ptr, size * nmemb); + return size * nmemb; +} + void SetDuration(CURL* curl, CURLoption option, std::chrono::milliseconds value) { if (value > std::chrono::milliseconds::zero()) { curl_easy_setopt(curl, option, static_cast(value.count())); @@ -76,7 +84,12 @@ CURLcode ConfigureSslContext(CURL*, void* ssl_context, void* userdata) { } #endif -void ConfigureCurlForGet(CURL* curl, const Url& url, const Config& config, std::string& body) { +void ConfigureCurlForGet( + CURL* curl, + const Url& url, + const Config& config, + std::string& body, + std::string& response_headers) { curl_easy_reset(curl); const auto url_string = url.ToString(); @@ -85,6 +98,8 @@ void ConfigureCurlForGet(CURL* curl, const Url& url, const Config& config, std:: curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &WriteBody); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, &WriteHeader); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &response_headers); curl_easy_setopt(curl, CURLOPT_TCP_KEEPALIVE, 1L); curl_easy_setopt(curl, CURLOPT_SSL_SESSIONID_CACHE, config.tls_session_cache_enabled ? 1L : 0L); curl_easy_setopt(curl, CURLOPT_MAXCONNECTS, static_cast(config.max_connections)); @@ -122,12 +137,18 @@ void ConfigureCurlForGet(CURL* curl, const Url& url, const Config& config, std:: HttpResponse PerformCurlGet(CURL* curl, const Url& url, const Config& config) { std::string body; - ConfigureCurlForGet(curl, url, config, body); + std::string response_headers; + ConfigureCurlForGet(curl, url, config, body, response_headers); curl_slist* headers = nullptr; headers = curl_slist_append( headers, config.reuse_discovery_connections ? "Connection: keep-alive" : "Connection: close"); + const auto accept_encoding_value = detail::BuildAcceptEncodingValue(config.content_encoding_decoders); + if (!accept_encoding_value.empty()) { + const auto accept_encoding = "Accept-Encoding: " + accept_encoding_value; + headers = curl_slist_append(headers, accept_encoding.c_str()); + } curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); const auto code = curl_easy_perform(curl); @@ -141,6 +162,10 @@ HttpResponse PerformCurlGet(CURL* curl, const Url& url, const Config& config) { long status_code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status_code); + body = detail::DecodeHttpResponseBody( + std::move(body), + detail::FindHttpHeaderValue(response_headers, "content-encoding"), + config.content_encoding_decoders); return HttpResponse{status_code, std::move(body)}; } @@ -277,7 +302,9 @@ FdGuard ConnectTcp(const Url& url) { throw std::runtime_error("connect failed"); } -HttpResponse ParseHttpResponse(const std::string& raw) { +HttpResponse ParseHttpResponse( + const std::string& raw, + const std::vector>& content_encoding_decoders) { const auto header_end = raw.find("\r\n\r\n"); if (header_end == std::string::npos) { throw std::runtime_error("invalid HTTP response"); @@ -297,7 +324,12 @@ HttpResponse ParseHttpResponse(const std::string& raw) { throw std::runtime_error("invalid HTTP status line"); } - return HttpResponse{status_code, raw.substr(header_end + 4)}; + auto body = raw.substr(header_end + 4); + body = detail::DecodeHttpResponseBody( + std::move(body), + detail::FindHttpHeaderValue(raw.substr(0, header_end), "content-encoding"), + content_encoding_decoders); + return HttpResponse{status_code, std::move(body)}; } } // namespace @@ -318,6 +350,10 @@ HttpResponse CurlHttpClient::Get(const Url& url) const { request << "GET " << target << " HTTP/1.1\r\n" << "Host: " << url.Authority() << "\r\n" << "Connection: close\r\n"; + const auto accept_encoding_value = detail::BuildAcceptEncodingValue(config_.content_encoding_decoders); + if (!accept_encoding_value.empty()) { + request << "Accept-Encoding: " << accept_encoding_value << "\r\n"; + } if (!config_.user_agent.empty()) { request << "User-Agent: " << config_.user_agent << "\r\n"; } @@ -325,7 +361,9 @@ HttpResponse CurlHttpClient::Get(const Url& url) const { auto fd = ConnectTcp(url); SendAll(fd.get(), request.str()); - return ParseHttpResponse(ReadAll(fd.get())); + return ParseHttpResponse( + ReadAll(fd.get()), + config_.content_encoding_decoders); } #endif diff --git a/src/http_compression.cpp b/src/http_compression.cpp new file mode 100644 index 0000000..2b051a2 --- /dev/null +++ b/src/http_compression.cpp @@ -0,0 +1,286 @@ +#include "http_compression.h" + +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB +#include +#endif + +#include +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB +#include +#endif +#include +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB +#include +#endif +#include +#include +#include +#include + +namespace scylladb::alternator::detail { +namespace { + +struct ContentEncodingDecoderEntry { + std::string encoding; + std::shared_ptr decoder; +}; + +[[nodiscard]] std::string TrimAscii(std::string value) { + const auto first = std::find_if(value.begin(), value.end(), [](unsigned char ch) { + return !std::isspace(ch); + }); + const auto last = std::find_if(value.rbegin(), value.rend(), [](unsigned char ch) { + return !std::isspace(ch); + }).base(); + + if (first >= last) { + return {}; + } + return std::string(first, last); +} + +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB +[[nodiscard]] uInt CheckedZlibSize(std::size_t size) { + if (size > std::numeric_limits::max()) { + throw std::runtime_error("compressed HTTP response is too large"); + } + return static_cast(size); +} + +[[nodiscard]] std::string InflateBody(const std::string& body, int window_bits) { + z_stream stream{}; + const auto init_code = inflateInit2(&stream, window_bits); + if (init_code != Z_OK) { + throw std::runtime_error("inflateInit2 failed"); + } + + struct InflateGuard { + z_stream* stream; + ~InflateGuard() { + inflateEnd(stream); + } + } guard{&stream}; + + auto* input = reinterpret_cast(body.data()); + stream.next_in = const_cast(input); + stream.avail_in = CheckedZlibSize(body.size()); + + std::array buffer{}; + std::string output; + + while (true) { + stream.next_out = reinterpret_cast(buffer.data()); + stream.avail_out = static_cast(buffer.size()); + + const auto code = inflate(&stream, Z_NO_FLUSH); + const auto produced = buffer.size() - stream.avail_out; + output.append(buffer.data(), produced); + + if (code == Z_STREAM_END) { + return output; + } + if (code != Z_OK) { + throw std::runtime_error("failed to inflate compressed HTTP response"); + } + if (stream.avail_in == 0 && produced == 0) { + throw std::runtime_error("truncated compressed HTTP response"); + } + } +} + +[[nodiscard]] std::string InflateDeflateBody(const std::string& body) { + try { + return InflateBody(body, MAX_WBITS); + } catch (const std::runtime_error&) { + return InflateBody(body, -MAX_WBITS); + } +} +#endif + +[[nodiscard]] std::vector ParseContentEncodings(const std::string& content_encoding) { + std::vector encodings; + std::size_t start = 0; + while (start <= content_encoding.size()) { + const auto comma = content_encoding.find(',', start); + auto token = content_encoding.substr( + start, + comma == std::string::npos ? std::string::npos : comma - start); + token = ToLowerAscii(TrimAscii(std::move(token))); + if (!token.empty()) { + encodings.push_back(std::move(token)); + } + if (comma == std::string::npos) { + break; + } + start = comma + 1; + } + return encodings; +} + +[[nodiscard]] std::vector BuildDecoderEntries( + const std::vector>& content_encoding_decoders) { + std::vector entries; + for (const auto& decoder : content_encoding_decoders) { + if (!decoder) { + throw std::invalid_argument("content_encoding_decoders must not contain null values"); + } + + const auto accepted_encodings = decoder->AcceptedResponseEncodings(); + if (accepted_encodings.empty()) { + throw std::invalid_argument("content_encoding_decoders must advertise at least one encoding"); + } + + for (const auto& encoding : accepted_encodings) { + const auto normalized = NormalizeResponseEncoding(encoding); + if (normalized.empty()) { + throw std::invalid_argument("content_encoding_decoders must not advertise empty encodings"); + } + const auto duplicate = std::any_of(entries.begin(), entries.end(), [&](const auto& entry) { + return entry.encoding == normalized; + }); + if (duplicate) { + throw std::invalid_argument( + "content_encoding_decoders must not advertise duplicate response encoding: " + normalized); + } + entries.push_back(ContentEncodingDecoderEntry{normalized, decoder}); + } + } + return entries; +} + +[[nodiscard]] const ContentEncodingDecoderEntry* FindDecoderEntry( + const std::vector& entries, + const std::string& encoding) { + const auto it = std::find_if(entries.begin(), entries.end(), [&](const auto& entry) { + return entry.encoding == encoding; + }); + return it == entries.end() ? nullptr : &*it; +} + +[[nodiscard]] std::vector NormalizeZlibResponseEncodings(std::vector encodings) { +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB + for (auto& encoding : encodings) { + encoding = NormalizeResponseEncoding(std::move(encoding)); + if (encoding != "gzip" && encoding != "deflate") { + throw std::invalid_argument("ZlibContentEncodingDecoder supports only gzip and deflate"); + } + } + return encodings; +#else + (void)encodings; + throw std::invalid_argument("zlib response decoding is not available"); +#endif +} + +} // namespace + +std::string ToLowerAscii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return value; +} + +std::string NormalizeResponseEncoding(std::string encoding) { + return ToLowerAscii(TrimAscii(std::move(encoding))); +} + +std::string BuildAcceptEncodingValue( + const std::vector>& content_encoding_decoders) { + std::string value; + for (const auto& entry : BuildDecoderEntries(content_encoding_decoders)) { + if (!value.empty()) { + value += ", "; + } + value += entry.encoding; + } + return value; +} + +std::string FindHttpHeaderValue(const std::string& headers, const std::string& name) { + const auto wanted = ToLowerAscii(name); + std::string value; + + std::istringstream lines(headers); + std::string line; + while (std::getline(lines, line)) { + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + if (line.rfind("HTTP/", 0) == 0) { + value.clear(); + continue; + } + + const auto colon = line.find(':'); + if (colon == std::string::npos) { + continue; + } + + auto header_name = line.substr(0, colon); + header_name = ToLowerAscii(TrimAscii(std::move(header_name))); + if (header_name == wanted) { + value = TrimAscii(line.substr(colon + 1)); + } + } + + return value; +} + +} // namespace scylladb::alternator::detail + +namespace scylladb::alternator { + +ZlibContentEncodingDecoder::ZlibContentEncodingDecoder(std::vector accepted_response_encodings) + : accepted_response_encodings_(detail::NormalizeZlibResponseEncodings(std::move(accepted_response_encodings))) {} + +std::vector ZlibContentEncodingDecoder::AcceptedResponseEncodings() const { + return accepted_response_encodings_; +} + +std::string ZlibContentEncodingDecoder::Decode(std::string body, const std::string& content_encoding) const { +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB + const auto normalized = detail::NormalizeResponseEncoding(content_encoding); + if (normalized == "gzip") { + return detail::InflateBody(body, MAX_WBITS + 16); + } + if (normalized == "deflate") { + return detail::InflateDeflateBody(body); + } + throw std::runtime_error("unsupported HTTP content encoding: " + normalized); +#else + (void)body; + (void)content_encoding; + throw std::runtime_error("zlib response decoding is not available"); +#endif +} + +} // namespace scylladb::alternator + +namespace scylladb::alternator::detail { + +std::string DecodeHttpResponseBody( + std::string body, + const std::string& content_encoding, + const std::vector>& content_encoding_decoders) { + auto encodings = ParseContentEncodings(content_encoding); + if (encodings.empty()) { + return body; + } + + const auto decoder_entries = BuildDecoderEntries(content_encoding_decoders); + for (auto it = encodings.rbegin(); it != encodings.rend(); ++it) { + if (*it == "identity") { + continue; + } + const auto* decoder_entry = FindDecoderEntry(decoder_entries, *it); + if (decoder_entry == nullptr) { + throw std::runtime_error("unexpected HTTP content encoding: " + *it); + } + body = decoder_entry->decoder->Decode(std::move(body), *it); + } + + return body; +} + +} // namespace scylladb::alternator::detail diff --git a/src/http_compression.h b/src/http_compression.h new file mode 100644 index 0000000..32e9c43 --- /dev/null +++ b/src/http_compression.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +#include + +namespace scylladb::alternator::detail { + +[[nodiscard]] std::string BuildAcceptEncodingValue( + const std::vector>& content_encoding_decoders); +[[nodiscard]] std::string DecodeHttpResponseBody( + std::string body, + const std::string& content_encoding, + const std::vector>& content_encoding_decoders); +[[nodiscard]] std::string FindHttpHeaderValue(const std::string& headers, const std::string& name); +[[nodiscard]] std::string NormalizeResponseEncoding(std::string encoding); +[[nodiscard]] std::string ToLowerAscii(std::string value); + +} // namespace scylladb::alternator::detail diff --git a/tests/aws_dynamodb_helper_test.cpp b/tests/aws_dynamodb_helper_test.cpp index a7a49fe..26ae6d9 100644 --- a/tests/aws_dynamodb_helper_test.cpp +++ b/tests/aws_dynamodb_helper_test.cpp @@ -17,11 +17,19 @@ #include #include +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB +#include +#include +#endif #include #include #include #include +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB +#include +#endif +#include #include #include #include @@ -141,6 +149,7 @@ struct SequencedHttpResponse { std::string reason = "OK"; std::string body; std::chrono::milliseconds delay{0}; + std::vector> headers; }; class KeepAliveSequenceHttpServer { @@ -295,8 +304,11 @@ class KeepAliveSequenceHttpServer { } std::ostringstream text; text << "HTTP/1.1 " << response.status_code << " " << response.reason << "\r\n" - << "Content-Type: application/x-amz-json-1.0\r\n" - << "Content-Length: " << response.body.size() << "\r\n" + << "Content-Type: application/x-amz-json-1.0\r\n"; + for (const auto& [name, value] : response.headers) { + text << name << ": " << value << "\r\n"; + } + text << "Content-Length: " << response.body.size() << "\r\n" << "Connection: " << (keep_connection ? "keep-alive" : "close") << "\r\n" << "\r\n" << response.body; @@ -327,6 +339,69 @@ std::string HostHeader(const std::string& request) { return request.substr(start, end - start); } +std::string ToLowerAscii(std::string value) { + for (auto& ch : value) { + ch = static_cast(std::tolower(static_cast(ch))); + } + return value; +} + +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB +std::string CompressBody(const std::string& body, int window_bits) { + if (body.size() > std::numeric_limits::max()) { + throw std::runtime_error("body too large to compress"); + } + + z_stream stream{}; + const auto init_code = deflateInit2( + &stream, + Z_BEST_SPEED, + Z_DEFLATED, + window_bits, + 8, + Z_DEFAULT_STRATEGY); + if (init_code != Z_OK) { + throw std::runtime_error("deflateInit2 failed"); + } + + struct DeflateGuard { + z_stream* stream; + ~DeflateGuard() { + deflateEnd(stream); + } + } guard{&stream}; + + auto* input = reinterpret_cast(body.data()); + stream.next_in = const_cast(input); + stream.avail_in = static_cast(body.size()); + + std::array buffer{}; + std::string output; + while (true) { + stream.next_out = reinterpret_cast(buffer.data()); + stream.avail_out = static_cast(buffer.size()); + + const auto code = deflate(&stream, Z_FINISH); + const auto produced = buffer.size() - stream.avail_out; + output.append(buffer.data(), produced); + + if (code == Z_STREAM_END) { + return output; + } + if (code != Z_OK) { + throw std::runtime_error("deflate failed"); + } + } +} + +SequencedHttpResponse GzipJsonResponse(const std::string& json) { + SequencedHttpResponse response; + response.body = CompressBody(json, MAX_WBITS + 16); + response.headers = {{"Content-Encoding", "gzip"}}; + return response; +} +#endif + std::string DescribeTableResponse(const std::string& table_name, const std::string& partition_key_name) { return R"({"Table":{"TableName":")" + table_name + R"(","KeySchema":[{"AttributeName":")" + @@ -702,6 +777,99 @@ TEST(AwsDynamoDBHelper, HttpClientFactoryRotatesNodesAcrossRetries) { EXPECT_NE(first_host, second_host); } +TEST(AwsDynamoDBHelper, HttpClientFactoryDecodesGzipResponses) { +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB + KeepAliveSequenceHttpServer server({ + GzipJsonResponse(R"({"TableNames":[]})"), + }); + + Config cfg; + cfg.port = server.Port(); + cfg.scheme = "http"; + cfg.aws_region = "us-east-1"; + cfg.credentials = {"alternator", "secret"}; + cfg.nodes_list_update_period = std::chrono::milliseconds{0}; + cfg.http_client_timeout = std::chrono::milliseconds{2000}; + cfg.connect_timeout = std::chrono::milliseconds{1000}; + cfg.content_encoding_decoders = {std::make_shared()}; + + aws::DynamoDBHelper helper({"127.0.0.1"}, cfg); + + Aws::SDKOptions sdk_options; + helper.ApplyToSDKOptions(sdk_options); + AwsApiGuard api(sdk_options); + + auto client_config = helper.NewClientConfiguration(); + client_config.retryStrategy = Aws::MakeShared( + "AlternatorClientCppCompressionTestRetryStrategy", + 0, + 0); + client_config.version = Aws::Http::Version::HTTP_VERSION_1_1; + + Aws::Auth::AWSCredentials credentials("alternator", "secret"); + Aws::DynamoDB::DynamoDBClient client(credentials, helper.NewEndpointProvider(), client_config); + + Aws::DynamoDB::Model::ListTablesRequest request; + auto outcome = client.ListTables(request); + EXPECT_TRUE(outcome.IsSuccess()) << outcome.GetError().GetMessage(); + + server.Wait(); + + ASSERT_EQ(server.Requests().size(), 1U); + const auto request_text = ToLowerAscii(server.Requests()[0]); + EXPECT_NE(request_text.find("accept-encoding: gzip, deflate"), std::string::npos); +#else + GTEST_SKIP() << "zlib support is not enabled"; +#endif +} + +TEST(AwsDynamoDBHelper, HttpClientFactoryDoesNotAdvertiseCompressionForNonAlternatorEndpoints) { +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB + KeepAliveSequenceHttpServer server({ + {200, "OK", R"({"TableNames":[]})"}, + }); + + Config cfg; + cfg.port = server.Port(); + cfg.scheme = "http"; + cfg.aws_region = "us-east-1"; + cfg.credentials = {"alternator", "secret"}; + cfg.nodes_list_update_period = std::chrono::milliseconds{0}; + cfg.http_client_timeout = std::chrono::milliseconds{2000}; + cfg.connect_timeout = std::chrono::milliseconds{1000}; + cfg.content_encoding_decoders = {std::make_shared()}; + + aws::DynamoDBHelper helper({"node1.example.com"}, cfg); + + Aws::SDKOptions sdk_options; + helper.ApplyToSDKOptions(sdk_options); + AwsApiGuard api(sdk_options); + + auto client_config = helper.NewClientConfiguration(); + client_config.endpointOverride = Aws::String(("http://127.0.0.1:" + std::to_string(server.Port())).c_str()); + client_config.retryStrategy = Aws::MakeShared( + "AlternatorClientCppCompressionHeaderTestRetryStrategy", + 0, + 0); + client_config.version = Aws::Http::Version::HTTP_VERSION_1_1; + + Aws::Auth::AWSCredentials credentials("alternator", "secret"); + Aws::DynamoDB::DynamoDBClient client(credentials, client_config); + + Aws::DynamoDB::Model::ListTablesRequest request; + auto outcome = client.ListTables(request); + EXPECT_TRUE(outcome.IsSuccess()) << outcome.GetError().GetMessage(); + + server.Wait(); + + ASSERT_EQ(server.Requests().size(), 1U); + const auto request_text = ToLowerAscii(server.Requests()[0]); + EXPECT_EQ(request_text.find("accept-encoding:"), std::string::npos); +#else + GTEST_SKIP() << "zlib support is not enabled"; +#endif +} + TEST(AwsDynamoDBHelper, HandlesRepeatedNonSuccessDynamoDbResponses) { KeepAliveSequenceHttpServer server({ {400, "Bad Request", R"({"__type":"ValidationException","message":"bad"})"}, diff --git a/tests/http_client_test.cpp b/tests/http_client_test.cpp index 1ffb2ac..49999ec 100644 --- a/tests/http_client_test.cpp +++ b/tests/http_client_test.cpp @@ -6,9 +6,17 @@ #include #include +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB +#include +#include +#endif #include #include +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB +#include +#endif +#include #include #include #include @@ -22,7 +30,9 @@ namespace { class LocalHttpServer { public: - LocalHttpServer() { + explicit LocalHttpServer(std::string body = "[\"node1.local\"]", std::string content_encoding = {}) + : body_(std::move(body)) + , content_encoding_(std::move(content_encoding)) { fd_ = socket(AF_INET, SOCK_STREAM, 0); if (fd_ < 0) { throw std::runtime_error("socket failed"); @@ -60,13 +70,17 @@ class LocalHttpServer { request_.assign(buffer, static_cast(n)); } - const std::string response = - "HTTP/1.1 200 OK\r\n" - "Content-Length: 15\r\n" - "Connection: close\r\n" - "\r\n" - "[\"node1.local\"]"; - send(client, response.data(), response.size(), 0); + std::ostringstream response; + response << "HTTP/1.1 200 OK\r\n"; + if (!content_encoding_.empty()) { + response << "Content-Encoding: " << content_encoding_ << "\r\n"; + } + response << "Content-Length: " << body_.size() << "\r\n" + << "Connection: close\r\n" + << "\r\n" + << body_; + const auto response_text = response.str(); + send(client, response_text.data(), response_text.size(), 0); close(client); }); } @@ -91,6 +105,8 @@ class LocalHttpServer { private: int fd_ = -1; std::uint16_t port_ = 0; + std::string body_; + std::string content_encoding_; std::string request_; std::thread worker_; }; @@ -231,6 +247,72 @@ class CountingHttpServer { std::thread worker_; }; +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB +std::string CompressBody(const std::string& body, int window_bits) { + if (body.size() > std::numeric_limits::max()) { + throw std::runtime_error("body too large to compress"); + } + + z_stream stream{}; + const auto init_code = deflateInit2( + &stream, + Z_BEST_SPEED, + Z_DEFLATED, + window_bits, + 8, + Z_DEFAULT_STRATEGY); + if (init_code != Z_OK) { + throw std::runtime_error("deflateInit2 failed"); + } + + struct DeflateGuard { + z_stream* stream; + ~DeflateGuard() { + deflateEnd(stream); + } + } guard{&stream}; + + auto* input = reinterpret_cast(body.data()); + stream.next_in = const_cast(input); + stream.avail_in = static_cast(body.size()); + + std::array buffer{}; + std::string output; + while (true) { + stream.next_out = reinterpret_cast(buffer.data()); + stream.avail_out = static_cast(buffer.size()); + + const auto code = deflate(&stream, Z_FINISH); + const auto produced = buffer.size() - stream.avail_out; + output.append(buffer.data(), produced); + + if (code == Z_STREAM_END) { + return output; + } + if (code != Z_OK) { + throw std::runtime_error("deflate failed"); + } + } +} +#endif + +class TestContentEncodingDecoder final : public HttpContentEncodingDecoder { +public: + explicit TestContentEncodingDecoder(std::vector accepted_encodings = {"br"}) + : accepted_encodings_(std::move(accepted_encodings)) {} + + std::vector AcceptedResponseEncodings() const override { + return accepted_encodings_; + } + + std::string Decode(std::string body, const std::string& content_encoding) const override { + return content_encoding + ":" + body; + } + +private: + std::vector accepted_encodings_; +}; + } // namespace TEST(HttpClient, PerformsPlainHttpGet) { @@ -245,6 +327,165 @@ TEST(HttpClient, PerformsPlainHttpGet) { EXPECT_EQ(response.status_code, 200); EXPECT_EQ(response.body, "[\"node1.local\"]"); EXPECT_NE(server.Request().find("GET /localnodes?dc=dc1 HTTP/1.1"), std::string::npos); + EXPECT_EQ(server.Request().find("Accept-Encoding:"), std::string::npos); +} + +TEST(HttpClient, RequestsAndDecodesGzipResponse) { +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB + const std::string body = "[\"node1.local\"]"; + LocalHttpServer server(CompressBody(body, MAX_WBITS + 16), "gzip"); + + Config cfg; + cfg.scheme = "http"; + cfg.content_encoding_decoders = {std::make_shared()}; + CurlHttpClient client(cfg); + + auto response = client.Get(Url("http", "127.0.0.1", server.Port()).WithPathAndQuery("/localnodes")); + + EXPECT_EQ(response.status_code, 200); + EXPECT_EQ(response.body, body); + EXPECT_NE(server.Request().find("Accept-Encoding: gzip"), std::string::npos); +#else + GTEST_SKIP() << "zlib support is not enabled"; +#endif +} + +TEST(HttpClient, CanUseExplicitZlibContentEncodingDecoder) { +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB + const std::string body = "[\"node1.local\"]"; + LocalHttpServer server(CompressBody(body, MAX_WBITS + 16), "gzip"); + + Config cfg; + cfg.scheme = "http"; + cfg.content_encoding_decoders = {std::make_shared()}; + CurlHttpClient client(cfg); + + auto response = client.Get(Url("http", "127.0.0.1", server.Port()).WithPathAndQuery("/localnodes")); + + EXPECT_EQ(response.status_code, 200); + EXPECT_EQ(response.body, body); + EXPECT_NE(server.Request().find("Accept-Encoding: gzip"), std::string::npos); +#else + GTEST_SKIP() << "zlib support is not enabled"; +#endif +} + +TEST(HttpClient, CanAdvertiseZlibContentEncodingSubset) { +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB + const std::string body = "[\"node1.local\"]"; + LocalHttpServer server(CompressBody(body, MAX_WBITS + 16), "gzip"); + + Config cfg; + cfg.scheme = "http"; + cfg.content_encoding_decoders = { + std::make_shared(std::vector{"gzip"}), + }; + CurlHttpClient client(cfg); + + auto response = client.Get(Url("http", "127.0.0.1", server.Port()).WithPathAndQuery("/localnodes")); + + EXPECT_EQ(response.status_code, 200); + EXPECT_EQ(response.body, body); + EXPECT_NE(server.Request().find("Accept-Encoding: gzip"), std::string::npos); + EXPECT_EQ(server.Request().find("deflate"), std::string::npos); +#else + GTEST_SKIP() << "zlib support is not enabled"; +#endif +} + +TEST(HttpClient, RequestsAndDecodesDeflateResponse) { +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB + const std::string body = "[\"node1.local\"]"; + LocalHttpServer server(CompressBody(body, MAX_WBITS), "deflate"); + + Config cfg; + cfg.scheme = "http"; + cfg.content_encoding_decoders = {std::make_shared()}; + CurlHttpClient client(cfg); + + auto response = client.Get(Url("http", "127.0.0.1", server.Port()).WithPathAndQuery("/localnodes")); + + EXPECT_EQ(response.status_code, 200); + EXPECT_EQ(response.body, body); + EXPECT_NE(server.Request().find("Accept-Encoding: gzip, deflate"), std::string::npos); +#else + GTEST_SKIP() << "zlib support is not enabled"; +#endif +} + +TEST(HttpClient, CanAdvertiseGzipAndCustomZstdResponseEncodings) { +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB + LocalHttpServer server("encoded-body", "zstd"); + + Config cfg; + cfg.scheme = "http"; + cfg.content_encoding_decoders = { + std::make_shared(std::vector{"gzip"}), + std::make_shared(std::vector{"zstd"}), + }; + CurlHttpClient client(cfg); + + auto response = client.Get(Url("http", "127.0.0.1", server.Port()).WithPathAndQuery("/localnodes")); + + EXPECT_EQ(response.status_code, 200); + EXPECT_EQ(response.body, "zstd:encoded-body"); + EXPECT_NE(server.Request().find("Accept-Encoding: gzip, zstd"), std::string::npos); + EXPECT_EQ(server.Request().find("deflate"), std::string::npos); +#else + GTEST_SKIP() << "zlib support is not enabled"; +#endif +} + +TEST(HttpClient, RejectsUnacceptedResponseEncoding) { + LocalHttpServer server("encoded", "deflate"); + + Config cfg; + cfg.scheme = "http"; + cfg.content_encoding_decoders = {std::make_shared(std::vector{"gzip"})}; + CurlHttpClient client(cfg); + + EXPECT_THROW( + client.Get(Url("http", "127.0.0.1", server.Port()).WithPathAndQuery("/localnodes")), + std::runtime_error); +} + +TEST(HttpClient, UsesCustomContentEncodingDecoder) { + LocalHttpServer server("encoded-body", "br"); + + Config cfg; + cfg.scheme = "http"; + cfg.content_encoding_decoders = {std::make_shared()}; + CurlHttpClient client(cfg); + + auto response = client.Get(Url("http", "127.0.0.1", server.Port()).WithPathAndQuery("/localnodes")); + + EXPECT_EQ(response.status_code, 200); + EXPECT_EQ(response.body, "br:encoded-body"); + EXPECT_NE(server.Request().find("Accept-Encoding: br"), std::string::npos); +} + +TEST(HttpClient, RejectsUnsupportedZlibResponseEncoding) { +#if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB + EXPECT_THROW( + ZlibContentEncodingDecoder(std::vector{"zstd"}), + std::invalid_argument); +#else + GTEST_SKIP() << "zlib support is not enabled"; +#endif +} + +TEST(HttpClient, DoesNotAdvertiseResponseCompressionByDefault) { + LocalHttpServer server; + + Config cfg; + cfg.scheme = "http"; + CurlHttpClient client(cfg); + + auto response = client.Get(Url("http", "127.0.0.1", server.Port()).WithPathAndQuery("/localnodes")); + + EXPECT_EQ(response.status_code, 200); + EXPECT_EQ(response.body, "[\"node1.local\"]"); + EXPECT_EQ(server.Request().find("Accept-Encoding:"), std::string::npos); } TEST(HttpClient, ReusesDiscoveryConnectionByDefaultWithCurl) { diff --git a/tests/live_nodes_integration_test.cpp b/tests/live_nodes_integration_test.cpp index d8be5c1..155c688 100644 --- a/tests/live_nodes_integration_test.cpp +++ b/tests/live_nodes_integration_test.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -74,6 +75,20 @@ TEST(AlternatorLiveNodesIntegration, RoutingFallbackLearnsNodes) { EXPECT_NE(discovered, IntegrationNodes()); } +TEST(AlternatorLiveNodesIntegration, CompressedHttpDiscoveryWorks) { + REQUIRE_INTEGRATION(); +#if !SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB + GTEST_SKIP() << "zlib support is not enabled"; +#endif + + auto cfg = IntegrationConfig(IntegrationHttpPort()); + cfg.content_encoding_decoders = {std::make_shared()}; + + AlternatorLiveNodes nodes(IntegrationNodes(), cfg); + EXPECT_NO_THROW(nodes.UpdateLiveNodes()); + EXPECT_FALSE(nodes.GetNodes().empty()); +} + TEST(AlternatorLiveNodesIntegration, RejectsWrongDatacenter) { REQUIRE_INTEGRATION(); diff --git a/tests/live_nodes_test.cpp b/tests/live_nodes_test.cpp index ace29e0..cb5f54c 100644 --- a/tests/live_nodes_test.cpp +++ b/tests/live_nodes_test.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,23 @@ class FakeHttpClient final : public HttpClient { Handler handler_; }; +class PassthroughContentEncodingDecoder final : public HttpContentEncodingDecoder { +public: + explicit PassthroughContentEncodingDecoder(std::vector accepted_encodings = {"br"}) + : accepted_encodings_(std::move(accepted_encodings)) {} + + std::vector AcceptedResponseEncodings() const override { + return accepted_encodings_; + } + + std::string Decode(std::string body, const std::string&) const override { + return body; + } + +private: + std::vector accepted_encodings_; +}; + static std::vector Hosts(const std::vector& nodes) { std::vector out; out.reserve(nodes.size()); @@ -444,3 +462,41 @@ TEST(AlternatorLiveNodes, RejectsInvalidTlsSessionCacheConfig) { EXPECT_THROW(AlternatorLiveNodes({"node1.local"}, cfg, http), std::invalid_argument); } + +TEST(AlternatorLiveNodes, RejectsDuplicateContentEncodingDecoders) { + Config cfg; + cfg.content_encoding_decoders = { + std::make_shared(std::vector{"br"}), + std::make_shared(std::vector{"BR"}), + }; + + auto http = std::make_shared([](const Url&) { + return HttpResponse{200, "[]"}; + }); + + EXPECT_THROW(AlternatorLiveNodes({"node1.local"}, cfg, http), std::invalid_argument); +} + +TEST(AlternatorLiveNodes, RejectsEmptyContentEncodingDecoderEncoding) { + Config cfg; + cfg.content_encoding_decoders = { + std::make_shared(std::vector{""}), + }; + + auto http = std::make_shared([](const Url&) { + return HttpResponse{200, "[]"}; + }); + + EXPECT_THROW(AlternatorLiveNodes({"node1.local"}, cfg, http), std::invalid_argument); +} + +TEST(AlternatorLiveNodes, AcceptsCustomContentEncodingDecoder) { + Config cfg; + cfg.content_encoding_decoders = {std::make_shared()}; + + auto http = std::make_shared([](const Url&) { + return HttpResponse{200, "[]"}; + }); + + EXPECT_NO_THROW(AlternatorLiveNodes({"node1.local"}, cfg, http)); +}