diff --git a/apps/hello-cpp/main.cpp b/apps/hello-cpp/main.cpp index 75a2df119..0657b01e4 100644 --- a/apps/hello-cpp/main.cpp +++ b/apps/hello-cpp/main.cpp @@ -3,7 +3,6 @@ #include -#include "config/detail/builders/data_source_builder.hpp" #include "console_backend.hpp" #include "context_builder.hpp" #include "launchdarkly/client_side/data_sources/detail/streaming_data_source.hpp" @@ -35,6 +34,12 @@ int main() { Client client( ConfigBuilder(key) + .ServiceEndpoints( + launchdarkly::client_side::EndpointsBuilder() + // Set to http to demonstrate redirect to https. + .PollingBaseUrl("http://sdk.launchdarkly.com") + .StreamingBaseUrl("https://stream.launchdarkly.com") + .EventsBaseUrl("https://events.launchdarkly.com")) .DataSource(DataSourceBuilder() .Method(DataSourceBuilder::Polling().PollInterval( std::chrono::seconds{30})) diff --git a/libs/client-sdk/src/api.cpp b/libs/client-sdk/src/api.cpp index e2413a9d6..dad10674f 100644 --- a/libs/client-sdk/src/api.cpp +++ b/libs/client-sdk/src/api.cpp @@ -23,11 +23,10 @@ static std::unique_ptr MakeDataSource( return std::make_unique( config, executor, context, &flag_updater, status_manager, logger); - } else { - return std::make_unique< - launchdarkly::client_side::data_sources::detail::PollingDataSource>( - config, executor, context, &flag_updater, status_manager, logger); } + return std::make_unique< + launchdarkly::client_side::data_sources::detail::PollingDataSource>( + config, executor, context, &flag_updater, status_manager, logger); } Client::Client(Config config, Context context) @@ -48,8 +47,6 @@ Client::Client(Config config, Context context) status_manager_, logger_)), initialized_(false) { - data_source_->Start(); - status_manager_.OnDataSourceStatusChange([this](auto status) { if (status.State() == DataSourceStatus::DataSourceState::kValid || status.State() == DataSourceStatus::DataSourceState::kShutdown || @@ -62,6 +59,9 @@ Client::Client(Config config, Context context) } }); + // Should listen to status before attempting to start. + data_source_->Start(); + run_thread_ = std::move(std::thread([&]() { ioc_.run(); })); } diff --git a/libs/client-sdk/src/data_sources/polling_data_source.cpp b/libs/client-sdk/src/data_sources/polling_data_source.cpp index fb5093e18..77b275b6e 100644 --- a/libs/client-sdk/src/data_sources/polling_data_source.cpp +++ b/libs/client-sdk/src/data_sources/polling_data_source.cpp @@ -9,13 +9,16 @@ namespace launchdarkly::client_side::data_sources::detail { +static char const* const kCouldNotParseEndpoint = + "Could not parse polling endpoint URL."; + static network::detail::HttpRequest MakeRequest(Config const& config, Context const& context) { - std::string url = config.ServiceEndpoints().PollingBaseUrl(); + auto url = std::make_optional(config.ServiceEndpoints().PollingBaseUrl()); - auto& data_source_config = config.DataSourceConfig(); + auto const& data_source_config = config.DataSourceConfig(); - auto& polling_config = boost::get< + auto const& polling_config = boost::get< config::detail::built::PollingConfig>( config.DataSourceConfig().method); @@ -28,19 +31,23 @@ static network::detail::HttpRequest MakeRequest(Config const& config, network::detail::HttpMethod method = network::detail::HttpMethod::kGet; if (data_source_config.use_report) { - url.append(polling_config.polling_report_path); + url = + network::detail::AppendUrl(url, polling_config.polling_report_path); method = network::detail::HttpMethod::kReport; body = string_context; } else { - url.append(polling_config.polling_get_path); + url = network::detail::AppendUrl(url, polling_config.polling_get_path); // When not using 'REPORT' we need to base64 // encode the context so that we can safely // put it in a url. - url.append("/" + Base64UrlEncode(string_context)); + url = network::detail::AppendUrl(url, Base64UrlEncode(string_context)); } if (data_source_config.with_reasons) { - url.append("?withReasons=true"); + // TODO: Handle better. + if (url) { + url->append("?withReasons=true"); + } } config::detail::builders::HttpPropertiesBuilder @@ -48,7 +55,8 @@ static network::detail::HttpRequest MakeRequest(Config const& config, builder.Header("authorization", config.SdkKey()); - return {url, method, builder.Build(), body}; + // If no URL is set, then we will fail the request. + return {url.value_or(""), method, builder.Build(), body}; } PollingDataSource::PollingDataSource(Config const& config, @@ -70,7 +78,7 @@ PollingDataSource::PollingDataSource(Config const& config, config.DataSourceConfig().method) .poll_interval), request_(MakeRequest(config, context)) { - auto& polling_config = boost::get< + auto const& polling_config = boost::get< config::detail::built::PollingConfig>( config.DataSourceConfig().method); if (polling_interval_ < polling_config.min_polling_interval) { @@ -147,7 +155,6 @@ void PollingDataSource::DoPoll() { } void PollingDataSource::StartPollingTimer() { - // TODO: Calculate interval based on request time. auto time_since_poll_seconds = std::chrono::duration_cast( std::chrono::system_clock::now() - last_poll_start_); @@ -181,6 +188,17 @@ void PollingDataSource::StartPollingTimer() { } void PollingDataSource::Start() { + if (!request_.Valid()) { + LD_LOG(logger_, LogLevel::kError) << kCouldNotParseEndpoint; + status_manager_.SetState( + DataSourceStatus::DataSourceState::kShutdown, + DataSourceStatus::ErrorInfo::ErrorKind::kNetworkError, + kCouldNotParseEndpoint); + + // No need to attempt to poll if the URL is not valid. + return; + } + DoPoll(); } diff --git a/libs/client-sdk/src/data_sources/streaming_data_source.cpp b/libs/client-sdk/src/data_sources/streaming_data_source.cpp index 8984b47e2..99168af19 100644 --- a/libs/client-sdk/src/data_sources/streaming_data_source.cpp +++ b/libs/client-sdk/src/data_sources/streaming_data_source.cpp @@ -10,10 +10,14 @@ #include "context_builder.hpp" #include "launchdarkly/client_side/data_sources/detail/base_64.hpp" #include "launchdarkly/client_side/data_sources/detail/streaming_data_source.hpp" +#include "network/detail/http_requester.hpp" #include "serialization/json_context.hpp" namespace launchdarkly::client_side::data_sources::detail { +static char const* const kCouldNotParseEndpoint = + "Could not parse streaming endpoint URL."; + StreamingDataSource::StreamingDataSource( Config const& config, boost::asio::any_io_executor ioc, @@ -25,13 +29,6 @@ StreamingDataSource::StreamingDataSource( status_manager_(status_manager), data_source_handler_( DataSourceEventHandler(handler, logger, status_manager_)) { - auto uri_components = - boost::urls::parse_uri(config.ServiceEndpoints().StreamingBaseUrl()); - - // TODO: Handle parsing error? - // TODO: Initial reconnect delay. - boost::urls::url url = uri_components.value(); - auto string_context = boost::json::serialize(boost::json::value_from(context)); @@ -41,15 +38,32 @@ StreamingDataSource::StreamingDataSource( config::detail::built::StreamingConfig>( data_source_config.method); - // Add the eval endpoint. - url.set_path(url.path().append(streaming_config.streaming_path)); + auto updated_url = + network::detail::AppendUrl(config.ServiceEndpoints().StreamingBaseUrl(), + streaming_config.streaming_path); if (!data_source_config.use_report) { // When not using 'REPORT' we need to base64 // encode the context so that we can safely // put it in a url. - url.set_path(url.path().append("/" + Base64UrlEncode(string_context))); + updated_url = network::detail::AppendUrl( + updated_url, Base64UrlEncode(string_context)); + } + // Bad URL, don't set the client. Start will then report the bad status. + if (!updated_url) { + return; } + + auto uri_components = boost::urls::parse_uri(*updated_url); + + // Unlikely that it could be parsed earlier and it cannot be parsed now. + if (!uri_components) { + return; + } + + // TODO: Initial reconnect delay. + boost::urls::url url = uri_components.value(); + if (data_source_config.with_reasons) { url.params().set("withReasons", "true"); } @@ -86,12 +100,22 @@ StreamingDataSource::StreamingDataSource( } void StreamingDataSource::Start() { + if (!client_) { + LD_LOG(logger_, LogLevel::kError) << kCouldNotParseEndpoint; + status_manager_.SetState( + DataSourceStatus::DataSourceState::kShutdown, + DataSourceStatus::ErrorInfo::ErrorKind::kNetworkError, + kCouldNotParseEndpoint); + return; + } client_->run(); } void StreamingDataSource::Close() { status_manager_.SetState(DataSourceStatus::DataSourceState::kShutdown); - client_->close(); + if (client_) { + client_->close(); + } } } // namespace launchdarkly::client_side::data_sources::detail diff --git a/libs/common/include/config/detail/built/data_source_config.hpp b/libs/common/include/config/detail/built/data_source_config.hpp index 40be3adc5..f288cd326 100644 --- a/libs/common/include/config/detail/built/data_source_config.hpp +++ b/libs/common/include/config/detail/built/data_source_config.hpp @@ -14,8 +14,7 @@ struct StreamingConfig; template <> struct StreamingConfig { std::chrono::milliseconds initial_reconnect_delay; - - inline static const std::string streaming_path = "/meval"; + std::string streaming_path; }; template <> @@ -29,13 +28,9 @@ struct PollingConfig; template <> struct PollingConfig { std::chrono::seconds poll_interval; - - inline const static std::string polling_get_path = "/msdk/evalx/contexts"; - - inline const static std::string polling_report_path = "/msdk/evalx/context"; - - inline const static std::chrono::seconds min_polling_interval = - std::chrono::seconds{30}; + std::string polling_get_path; + std::string polling_report_path; + std::chrono::seconds min_polling_interval; }; template <> diff --git a/libs/common/include/config/detail/defaults.hpp b/libs/common/include/config/detail/defaults.hpp index 141a245a8..5d1639ae7 100644 --- a/libs/common/include/config/detail/defaults.hpp +++ b/libs/common/include/config/detail/defaults.hpp @@ -47,7 +47,7 @@ struct Defaults { } static auto StreamingConfig() -> built::StreamingConfig { - return {std::chrono::milliseconds{1000}}; + return {std::chrono::milliseconds{1000}, "/meval"}; } static auto DataSourceConfig() -> built::DataSourceConfig { @@ -56,7 +56,8 @@ struct Defaults { static auto PollingConfig() -> built::PollingConfig { // Default to 5 minutes; - return {std::chrono::seconds{5 * 60}}; + return {std::chrono::seconds{5 * 60}, "/msdk/evalx/contexts", + "/msdk/evalx/context", std::chrono::seconds{30}}; } }; diff --git a/libs/common/include/network/detail/asio_requester.hpp b/libs/common/include/network/detail/asio_requester.hpp index 012cb4954..7ca90b76c 100644 --- a/libs/common/include/network/detail/asio_requester.hpp +++ b/libs/common/include/network/detail/asio_requester.hpp @@ -27,6 +27,23 @@ using tcp = boost::asio::ip::tcp; namespace launchdarkly::network::detail { +static unsigned char const kRedirectLimit = 20; + +static bool IsAbsolute(std::string_view str) { + return str.find("://") != std::string::npos || str.find("//") == 0; +} + +static bool NeedsRedirect(HttpResult const& res) { + // 300, multiple choices. Not actionable. + // 302, found, but not available for unforeseen reasons is not actionable. + // 303, attempting to change the method. We only want the original method. + // Redirect from a PUT to a GET for instance. + // 304, for use with etags, needs to be handled by the caller. + // 307, same as 303, but for non-GET operations. + return res.Status() == 301 || + res.Status() == 308 && res.Headers().count("location") != 0; +} + static http::verb ConvertMethod(HttpMethod method) { switch (method) { case HttpMethod::kPost: @@ -52,7 +69,11 @@ static http::request MakeBeastRequest( } else { beast_request.body() = ""; } - beast_request.target(request.Path()); + if (request.Path().empty()) { + beast_request.target("/"); + } else { + beast_request.target(request.Path()); + } beast_request.prepare_payload(); beast_request.set(http::field::host, request.Host()); @@ -67,6 +88,26 @@ static http::request MakeBeastRequest( return beast_request; } +static std::optional MakeRedirectRequest(HttpRequest const& req, + HttpResult const& res) { + auto location = res.Headers().find("location"); + // Location should be verified to be present before attempting to + // make the redirect request. + assert(location != res.Headers().end()); + // Start the request over with the new URL. + if (IsAbsolute(location->second)) { + return HttpRequest(location->second, req.Method(), req.Properties(), + req.Body()); + } + auto new_url = AppendUrl(req.Url(), location->second); + if (new_url) { + return HttpRequest(*new_url, req.Method(), req.Properties(), + req.Body()); + } + + return std::nullopt; +} + template class Session { // NOLINT(cppcoreguidelines-non-private-member-variables-in-classes) @@ -104,9 +145,7 @@ class connect_timeout_(connect_timeout), response_timeout_(response_timeout), read_timeout_(read_timeout), - handler_(std::move(handler)) { - parser_.get(); - } + handler_(std::move(handler)) {} void Fail(beast::error_code ec, char const* what) { // TODO: Is it safe to cancel this if it has already failed? @@ -148,8 +187,9 @@ class } void OnHandshake(beast::error_code ec) { - if (ec) + if (ec) { return Fail(ec, "handshake"); + } DoWrite(); } @@ -204,9 +244,9 @@ class * is done. * @return The created HttpResult. */ - HttpResult MakeResult() const { + [[nodiscard]] HttpResult MakeResult() const { auto headers = HttpResult::HeadersType(); - for (auto& field : parser_.get().base()) { + for (auto const& field : parser_.get().base()) { headers.insert_or_assign(field.name_string(), field.value()); } auto result = HttpResult(parser_.get().result_int(), @@ -221,6 +261,7 @@ class class PlaintextClient : public Session, public std::enable_shared_from_this { public: + virtual ~PlaintextClient() = default; using ResponseHandler = Session::ResponseHandler; PlaintextClient(net::any_io_executor ex, @@ -257,6 +298,7 @@ class PlaintextClient : public Session, class EncryptedClient : public Session, public std::enable_shared_from_this { public: + virtual ~EncryptedClient() = default; using ResponseHandler = Session::ResponseHandler; EncryptedClient(net::any_io_executor ex, @@ -380,52 +422,76 @@ class AsioRequester { Handler handler(std::forward(token)); Result result(handler); + InnerRequest(request, std::move(handler), 0); + + return result.get(); + } + + private: + net::any_io_executor ctx_; + /** + * The SSL context is a shared pointer to reduce the complexity of the + * relationship between a requests lifetime and the lifetime of the + * requester. + */ + std::shared_ptr ssl_ctx_; + + void InnerRequest(std::optional request, + std::function callback, + unsigned char redirect_count) { auto strand = net::make_strand(ctx_); + if (redirect_count > kRedirectLimit) { + boost::asio::post(strand, [callback, request]() mutable { + callback( + HttpResult("Redirects exceeded 20, cancelling request.")); + }); + return; + } + // The request is invalid and cannot be made, so produce an error // result. - if (!request.Valid()) { - boost::asio::post( - strand, [strand, handler, request, this]() mutable { - std::optional error_string = - "The request was malformed and could not be made."; - handler(HttpResult(error_string)); - }); + if (!request || !request->Valid()) { + boost::asio::post(strand, [callback, request]() mutable { + callback(HttpResult( + "The request was malformed and could not be made.")); + }); return; } - boost::asio::post(strand, [strand, handler, request, this]() mutable { - auto beast_request = MakeBeastRequest(request); + boost::asio::post(strand, [strand, callback, request, this, + redirect_count]() mutable { + auto beast_request = MakeBeastRequest(*request); - const auto& properties = request.Properties(); + const auto& properties = request->Properties(); - if (request.Https()) { + if (request->Https()) { std::make_shared( - strand, ssl_ctx_, beast_request, request.Host(), - request.Port(), properties.ConnectTimeout(), + strand, ssl_ctx_, beast_request, request->Host(), + request->Port(), properties.ConnectTimeout(), properties.ResponseTimeout(), properties.ReadTimeout(), - std::move(handler)) + [callback, request, this, redirect_count](auto res) { + NeedsRedirect(res) + ? InnerRequest(MakeRedirectRequest(*request, res), + callback, redirect_count + 1) + : callback(res); + }) ->Run(); } else { std::make_shared( - strand, beast_request, request.Host(), request.Port(), + strand, beast_request, request->Host(), request->Port(), properties.ConnectTimeout(), properties.ResponseTimeout(), - properties.ReadTimeout(), std::move(handler)) + properties.ReadTimeout(), + [callback, request, this, redirect_count](auto res) { + NeedsRedirect(res) + ? InnerRequest(MakeRedirectRequest(*request, res), + callback, redirect_count + 1) + : callback(res); + }) ->Run(); } }); - - return result.get(); } - - private: - net::any_io_executor ctx_; - /** - * The SSL context is a shared pointer to reduce the complexity of the - * relationship between a requests lifetime and the lifetime of the - * requester. - */ - std::shared_ptr ssl_ctx_; }; } // namespace launchdarkly::network::detail diff --git a/libs/common/include/network/detail/http_requester.hpp b/libs/common/include/network/detail/http_requester.hpp index 5fc094674..8da763ad3 100644 --- a/libs/common/include/network/detail/http_requester.hpp +++ b/libs/common/include/network/detail/http_requester.hpp @@ -14,8 +14,9 @@ namespace launchdarkly::network::detail { struct CaseInsensitiveComparator { - bool operator()(std::string const& a, std::string const& b) const noexcept { - return ::strcasecmp(a.c_str(), b.c_str()) < 0; + bool operator()(std::string const& lhs, + std::string const& rhs) const noexcept { + return ::strcasecmp(lhs.c_str(), rhs.c_str()) < 0; } }; @@ -26,15 +27,15 @@ class HttpResult { std::map; using BodyType = std::optional; - bool IsError() const; + [[nodiscard]] bool IsError() const; - std::optional const& ErrorMessage() const; + [[nodiscard]] std::optional const& ErrorMessage() const; - StatusCode Status() const; + [[nodiscard]] StatusCode Status() const; - BodyType const& Body() const; + [[nodiscard]] BodyType const& Body() const; - HeadersType const& Headers() const; + [[nodiscard]] HeadersType const& Headers() const; HttpResult(StatusCode status, BodyType body, HeadersType headers); @@ -53,7 +54,7 @@ class HttpResult { if (!res.headers_.empty()) { out << ", {"; bool first = true; - for (auto& pair : res.headers_) { + for (auto const& pair : res.headers_) { if (first) { first = false; } else { @@ -81,17 +82,19 @@ enum class HttpMethod { kPost, kGet, kReport, kPut }; class HttpRequest { public: - using HeadersType = - std::map; using BodyType = std::optional; - HttpMethod Method() const; - BodyType const& Body() const; - config::detail::built::HttpProperties const& Properties() const; - std::string const& Host() const; - std::string const& Port() const; - std::string const& Path() const; - bool Https() const; + [[nodiscard]] HttpMethod Method() const; + [[nodiscard]] BodyType const& Body() const; + [[nodiscard]] config::detail::built::HttpProperties const& Properties() + const; + [[nodiscard]] std::string const& Host() const; + [[nodiscard]] std::string const& Port() const; + [[nodiscard]] std::string const& Path() const; + + [[nodiscard]] std::string const& Url() const; + + [[nodiscard]] bool Https() const; /** * Indicates if a request is valid. Meaning that it has correctly formed @@ -99,7 +102,7 @@ class HttpRequest { * * @return True if the request is valid. */ - bool Valid() const; + [[nodiscard]] bool Valid() const; HttpRequest(std::string const& url, HttpMethod method, @@ -116,6 +119,7 @@ class HttpRequest { config::detail::built::HttpProperties properties); private: + std::string url_; HttpMethod method_; std::optional body_; config::detail::built::HttpProperties properties_; @@ -129,4 +133,20 @@ class HttpRequest { bool IsRecoverableStatus(HttpResult::StatusCode status); +/** + * Append a path to a URL. This will account for query parameters on the + * original URL. This will also normalize the URL. + * + * If the input URL doesn't parse, then std::nullopt will be returned. + * + * @param url_in Input URL, if std::nullopt, the method will return + * std::nullopt. This is to facilitate multiple appends without having to check + * intermediate results. + * + * @param to_append Path to append to the URL. + * @return The appended URL, or std::nullopt if the URL could not be parsed. + */ +std::optional AppendUrl(std::optional url_in, + std::string const& to_append); + } // namespace launchdarkly::network::detail diff --git a/libs/common/src/network/http_requester.cpp b/libs/common/src/network/http_requester.cpp index b79c178de..e6054fd99 100644 --- a/libs/common/src/network/http_requester.cpp +++ b/libs/common/src/network/http_requester.cpp @@ -1,4 +1,5 @@ -#include +#include + #include #include @@ -57,13 +58,18 @@ std::string const& HttpRequest::Path() const { return path_; } +std::string const& HttpRequest::Url() const { + return url_; +} + HttpRequest::HttpRequest(std::string const& url, HttpMethod method, config::detail::built::HttpProperties properties, HttpRequest::BodyType body) : properties_(std::move(properties)), method_(method), - body_(std::move(body)) { + body_(std::move(body)), + url_(url) { auto uri_components = boost::urls::parse_uri(url); // If the URI cannot be parsed, then the request is not valid. @@ -72,11 +78,18 @@ HttpRequest::HttpRequest(std::string const& url, return; } + boost::urls::url boost_url = uri_components.value(); + // Make paths absolute and slashes consistent. + boost_url.normalize(); + host_ = uri_components->host(); - // For a boost beast request we need the query string in the path. - path_ = uri_components->path() + "?" + uri_components->query(); - if (path_.empty()) { - path_ = "/"; + // The c_str here is to remove extra nulls from normalizing the path. + // Clang calls this redundant, but it is very much required. + path_ = + boost_url.path().c_str(); // NOLINT(readability-redundant-string-cstr) + if (!boost_url.query().empty()) { + // For a boost beast request we need the query string in the path. + path_ = path_ + "?" + uri_components->query(); } is_https_ = uri_components->scheme_id() == boost::urls::scheme::https; @@ -91,14 +104,14 @@ HttpRequest::HttpRequest(std::string const& url, HttpRequest::HttpRequest(HttpRequest& base_request, config::detail::built::HttpProperties properties) : properties_(std::move(properties)), + host_(base_request.host_), + port_(base_request.port_), + path_(base_request.path_), + is_https_(base_request.is_https_), + valid_(base_request.valid_), + url_(base_request.url_), method_(base_request.method_), - body_(std::move(base_request.body_)) { - path_ = base_request.path_; - host_ = base_request.host_; - port_ = base_request.port_; - is_https_ = base_request.is_https_; - valid_ = base_request.valid_; -} + body_(std::move(base_request.body_)) {} std::string const& HttpRequest::Port() const { return port_; @@ -115,4 +128,56 @@ bool IsRecoverableStatus(HttpResult::StatusCode status) { return status < 400 || status > 499 || status == 400 || status == 408 || status == 429; } + +std::optional AppendUrl(std::optional url_in, + std::string const& to_append) { + if (!url_in) { + return std::nullopt; + } + + if (to_append.empty()) { + return url_in; + } + + auto uri_components = boost::urls::parse_uri(*url_in); + + if (!uri_components) { + return std::nullopt; + } + + boost::urls::url url = uri_components.value(); + url.normalize(); + // The c_str here is to remove extra nulls from normalizing the path. + // Clang calls this redundant, but it is very much required. + std::string path = + url.path().c_str(); // NOLINT(readability-redundant-string-cstr) + + // This sizing may not be perfect, but should be close enough on average. + // The extra to is to account for a '/' and possible a '?'. + path.reserve(url.path().size() + to_append.size() + url.query().length() + + 2); + + // We want a single '/' between things. + bool path_has_trailing_slash = + !path.empty() && path[path.length() - 1] == '/'; + bool append_has_leading_slash = to_append[0] == '/'; + + // One other the other already has a '/', so we can just append them. + if ((path_has_trailing_slash && !append_has_leading_slash) || + (!path_has_trailing_slash && append_has_leading_slash)) { + path.append(to_append); + } else if (!path_has_trailing_slash && !append_has_leading_slash) { + // Neither had a '/', so we need to add one. + path.append("/"); + path.append(to_append); + } else { + // Both have a '/' so append the second starting after the '/'. + path.append(to_append, 1, to_append.length() - 1); + } + + url.set_path(path); + url.normalize(); + return url.c_str(); +} + } // namespace launchdarkly::network::detail diff --git a/libs/common/tests/data_source_builder_test.cpp b/libs/common/tests/data_source_builder_test.cpp index e92904112..148d541ae 100644 --- a/libs/common/tests/data_source_builder_test.cpp +++ b/libs/common/tests/data_source_builder_test.cpp @@ -5,6 +5,7 @@ #include "value.hpp" #include +#include using namespace launchdarkly; diff --git a/libs/common/tests/http_requester_test.cpp b/libs/common/tests/http_requester_test.cpp new file mode 100644 index 000000000..6c1350090 --- /dev/null +++ b/libs/common/tests/http_requester_test.cpp @@ -0,0 +1,74 @@ +#include + +#include "config/detail/builders/http_properties_builder.hpp" +#include "config/detail/sdks.hpp" +#include "network/detail/http_requester.hpp" + +using launchdarkly::config::detail::ClientSDK; +using launchdarkly::config::detail::builders::HttpPropertiesBuilder; +using launchdarkly::network::detail::AppendUrl; +using launchdarkly::network::detail::HttpMethod; +using launchdarkly::network::detail::HttpRequest; + +TEST(HttpRequestTests, NormalizesRelativeUrl) { + HttpRequest normalized( + "https://some.domain.com/potato/../ham?egg=true&cheese=true", + launchdarkly::network::detail::HttpMethod::kGet, + HttpPropertiesBuilder().Build(), std::nullopt); + + EXPECT_EQ("some.domain.com", normalized.Host()); + EXPECT_EQ("/ham?egg=true&cheese=true", normalized.Path()); +} + +TEST(HttpRequestTests, UsesCorrectDefaultPortForSchemes) { + HttpRequest secure("https://some.domain.com/", + launchdarkly::network::detail::HttpMethod::kGet, + HttpPropertiesBuilder().Build(), + std::nullopt); + + EXPECT_EQ("443", secure.Port()); + + HttpRequest insecure("http://some.domain.com/", + launchdarkly::network::detail::HttpMethod::kGet, + HttpPropertiesBuilder().Build(), + std::nullopt); + + EXPECT_EQ("80", insecure.Port()); +} + +TEST(HttpRequestTests, CanAppendBasicPath) { + EXPECT_EQ("https://the.url.com/potato", + AppendUrl("https://the.url.com", "/potato")); + + EXPECT_EQ("https://the.url.com/potato", + AppendUrl("https://the.url.com/", "potato")); + + EXPECT_EQ("https://the.url.com/potato", + AppendUrl("https://the.url.com/", "/potato")); + + EXPECT_EQ("https://the.url.com/ham/potato", + AppendUrl("https://the.url.com/ham", "/potato")); +} + +TEST(HttpRequestTests, AppendEmpty) { + EXPECT_EQ("https://the.url.com", AppendUrl("https://the.url.com", "")); +} + +TEST(HttpRequestTests, AppendRelativeUrls) { + EXPECT_EQ("https://the.url.com/cheese", + AppendUrl("https://the.url.com/ham", "../cheese")); + + EXPECT_EQ("https://the.url.com/cheese", + AppendUrl("https://the.url.com/ham/", "../cheese")); + + EXPECT_EQ("https://the.url.com/cheese", + AppendUrl("https://the.url.com/ham", "/../cheese")); + + EXPECT_EQ("https://the.url.com/cheese", + AppendUrl("https://the.url.com/ham/", "/../cheese")); +} + +TEST(HttpRequestTests, CanAppendWithParameters) { + EXPECT_EQ("https://the.url.com/cheese?ham=true&egg=true", + AppendUrl("https://the.url.com?ham=true&egg=true", "cheese")); +}