Skip to content
12 changes: 12 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ target_link_libraries(dbps_server_lib PUBLIC OpenSSL::SSL OpenSSL::Crypto)
# Client components library (depends on httplib, nlohmann/json, and cppcodec)
add_library(dbps_client_lib STATIC
src/client/dbps_api_client.cpp
src/client/http_client_interface.cpp
src/client/httplib_client.cpp
src/client/httplib_pool_registry.cpp
src/client/httplib_pooled_client.cpp
Expand Down Expand Up @@ -364,6 +365,14 @@ if(BUILD_TESTS)
gtest_main
)

# Http client interface tests
add_executable(http_client_interface_test src/client/http_client_interface_test.cpp)
target_link_libraries(http_client_interface_test
dbps_client_lib
dbps_common_lib
gtest_main
)

# Remote DBPA tests
add_executable(dbpa_remote_test src/common/dbpa_remote_test.cpp)
target_link_libraries(dbpa_remote_test
Expand Down Expand Up @@ -393,6 +402,7 @@ if(BUILD_SHARED_LIBS)
src/common/dbps_remote_shared_lib_wrapper.cpp
src/common/dbpa_remote.cpp
src/client/dbps_api_client.cpp
src/client/http_client_interface.cpp
src/client/httplib_client.cpp
src/common/json_request.cpp
)
Expand Down Expand Up @@ -496,6 +506,7 @@ if(BUILD_TESTS)
dbpa_local_test
httplib_pool_registry_test
httplib_pooled_client_test
http_client_interface_test
COMMENT "Building all tests"
)

Expand All @@ -517,6 +528,7 @@ if(BUILD_TESTS)
gtest_discover_tests(dbpa_local_test)
gtest_discover_tests(httplib_pool_registry_test)
gtest_discover_tests(httplib_pooled_client_test)
gtest_discover_tests(http_client_interface_test)
endif()

# All target (everything)
Expand Down
2 changes: 1 addition & 1 deletion src/client/dbps_api_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ DBPSApiClient::DBPSApiClient(std::shared_ptr<HttpClientInterface> http_client)
}

std::string DBPSApiClient::HealthCheck() {
auto response = http_client_->Get("/healthz");
auto response = http_client_->Get("/healthz", false);

if (!response.error_message.empty()) {
return "Error: " + response.error_message;
Expand Down
17 changes: 14 additions & 3 deletions src/client/dbps_api_client_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ bool CompareJsonStrings(const std::string& json1, const std::string& json2, cons
// Mock HTTP client for testing
class MockHttpClient : public HttpClientInterface {
public:
MockHttpClient()
: HttpClientInterface(
"mock://",
ClientCredentials{{"client_id", "test_client_AAAA"}, {"api_key", "test_key_AAAA"}}) {
}

// Mock responses for different endpoints
void SetMockResponse(const std::string& endpoint, const HttpResponse& response) {
mock_responses_[endpoint] = response;
Expand All @@ -67,16 +73,21 @@ class MockHttpClient : public HttpClientInterface {
mock_post_responses_[endpoint] = {expected_body, response};
}

// Implement the interface methods
HttpResponse Get(const std::string& endpoint) override {
protected:
HttpResponse DoGet(const std::string& endpoint, const HeaderList& headers) override {
(void)headers;
auto it = mock_responses_.find(endpoint);
if (it != mock_responses_.end()) {
return it->second;
}
return HttpResponse(404, "", "Mock endpoint not found: " + endpoint);
}

HttpResponse Post(const std::string& endpoint, const std::string& json_body) override {
HttpResponse DoPost(const std::string& endpoint, const std::string& json_body, const HeaderList& headers) override {
(void)headers;
if (endpoint == "/token") {
return HttpResponse(200, R"({"token":"mock_jwt","token_type":"Bearer","expires_at":1766138275})");
}
auto it = mock_post_responses_.find(endpoint);
if (it != mock_post_responses_.end()) {
if (CompareJsonStrings(it->second.first, json_body, {"debug"})) {
Expand Down
175 changes: 175 additions & 0 deletions src/client/http_client_interface.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

#include "http_client_interface.h"

#include <chrono>

#include "json_request.h"

HttpClientInterface::HeaderList HttpClientInterface::DefaultJsonGetHeaders() {
HeaderList headers;
headers.insert({"Accept", kJsonContentType});
headers.insert({"User-Agent", kDefaultUserAgent});
return headers;
}

HttpClientInterface::HeaderList HttpClientInterface::DefaultJsonPostHeaders() {
HeaderList headers;
headers.insert({"Content-Type", kJsonContentType});
headers.insert({"Accept", kJsonContentType});
headers.insert({"User-Agent", kDefaultUserAgent});
return headers;
}

HttpClientInterface::HttpResponse HttpClientInterface::Get(const std::string& endpoint, bool auth_required) {
const auto attempt = [&]() -> HttpResponse {
auto headers = DefaultJsonGetHeaders();
if (auth_required) {
auto auth_error = AddAuthorizationHeader(headers);
if (!auth_error.empty()) {
return HttpResponse(0, "", auth_error);
}
}
return DoGet(endpoint, headers);
};

auto result = attempt();
if (auth_required && result.status_code == 401) {
InvalidateCachedToken();
result = attempt();
}
return result;
}

HttpClientInterface::HttpResponse HttpClientInterface::Post(const std::string& endpoint,
const std::string& json_body,
bool auth_required) {
const auto attempt = [&]() -> HttpResponse {
auto headers = DefaultJsonPostHeaders();
if (auth_required) {
auto auth_error = AddAuthorizationHeader(headers);
if (!auth_error.empty()) {
return HttpResponse(0, "", auth_error);
}
}
return DoPost(endpoint, json_body, headers);
};

auto result = attempt();
if (auth_required && result.status_code == 401) {
InvalidateCachedToken();
result = attempt();
}
return result;
}

std::string HttpClientInterface::AddAuthorizationHeader(HeaderList& headers) {
std::string token_or_error;
auto token_opt = EnsureValidToken(token_or_error);
if (!token_opt.has_value()) {
return token_or_error;
}

headers.erase(kAuthorizationHeader);
std::string auth_value = token_opt->token_type;
if (auth_value.back() != ' ') {
auth_value.push_back(' ');
}
auth_value += token_opt->token;
headers.insert({kAuthorizationHeader, auth_value});
return "";
}

std::optional<HttpClientInterface::CachedToken> HttpClientInterface::EnsureValidToken(std::string& error) {

const auto now_epoch_seconds = []() -> std::int64_t {
const auto now = std::chrono::system_clock::now();
const auto secs = std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch()).count();
return static_cast<std::int64_t>(secs);
};

const auto is_token_valid_at = [&](std::int64_t now) -> bool {
return cached_token_.has_value() &&
!cached_token_->token.empty() &&
cached_token_->expires_at > (now + kTokenExpirySkewSeconds);
};

error.clear();
const auto now = now_epoch_seconds();

{
std::unique_lock<std::mutex> lock(token_mutex_);
if (is_token_valid_at(now)) {
return cached_token_;
}
while (token_fetch_in_progress_) {
token_cv_.wait(lock);
if (is_token_valid_at(now_epoch_seconds())) {
return cached_token_;
}
}
token_fetch_in_progress_ = true;
}

// Fetch token without holding token_mutex_.
std::optional<CachedToken> fetched = FetchToken(error);

{
std::lock_guard<std::mutex> lock(token_mutex_);
token_fetch_in_progress_ = false;
if (fetched.has_value()) {
cached_token_ = fetched;
}
}
token_cv_.notify_all();
return fetched;
}

std::optional<HttpClientInterface::CachedToken> HttpClientInterface::FetchToken(std::string& error) {
error.clear();
TokenRequest token_req;
token_req.credential_values_ = credentials_;

// IMPORTANT: call DoPost directly (authless) to avoid recursion.
auto http_resp = DoPost("/token", token_req.ToJson(), DefaultJsonPostHeaders());
if (!http_resp.error_message.empty() || http_resp.status_code != 200) {
error = http_resp.error_message + " (status code: " + std::to_string(http_resp.status_code) + ")";
return std::nullopt;
}

TokenResponse token_resp;
token_resp.Parse(http_resp.result);
if (!token_resp.IsValid()) {
error = token_resp.GetValidationError();
if (error.empty()) {
error = "While reading token response, found an invalid token response: " + http_resp.result;
}
return std::nullopt;
}

CachedToken result;
result.token = token_resp.token_.value();
result.token_type = token_resp.token_type_.value();
result.expires_at = token_resp.expires_at_.value();
return result;
}

void HttpClientInterface::InvalidateCachedToken() {
std::lock_guard<std::mutex> lock(token_mutex_);
cached_token_ = std::nullopt;
}
58 changes: 44 additions & 14 deletions src/client/http_client_interface.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@

#pragma once

#include <condition_variable>
#include <cstdint>
#include <map>
#include <mutex>
#include <optional>
#include <string>
#include <httplib.h>

Expand All @@ -30,10 +35,13 @@ class HttpClientInterface {
public:
virtual ~HttpClientInterface() = default;

using ClientCredentials = std::map<std::string, std::string>;
using HeaderList = httplib::Headers;

static constexpr const char* kJsonContentType = "application/json";
static constexpr const char* kDefaultUserAgent = "DBPSApiClient/1.0";
static constexpr const char* kAuthorizationHeader = "Authorization";
static inline constexpr std::int64_t kTokenExpirySkewSeconds = 30;

struct HttpResponse {
int status_code;
Expand All @@ -49,21 +57,43 @@ class HttpClientInterface {
: status_code(code), result(std::move(response_result)), error_message(std::move(error)) {}
};

static HeaderList DefaultJsonGetHeaders() {
HeaderList headers;
headers.insert({"Accept", kJsonContentType});
headers.insert({"User-Agent", kDefaultUserAgent});
return headers;
}
HttpResponse Get(const std::string& endpoint, bool auth_required = true);
HttpResponse Post(const std::string& endpoint, const std::string& json_body, bool auth_required = true);

static HeaderList DefaultJsonPostHeaders() {
HeaderList headers;
headers.insert({"Content-Type", kJsonContentType});
headers.insert({"Accept", kJsonContentType});
headers.insert({"User-Agent", kDefaultUserAgent});
return headers;
protected:
explicit HttpClientInterface(std::string base_url,
ClientCredentials credentials = {})
: base_url_(std::move(base_url)),
credentials_(std::move(credentials)) {
}

virtual HttpResponse Get(const std::string& endpoint) = 0;
virtual HttpResponse Post(const std::string& endpoint, const std::string& json_body) = 0;
virtual HttpResponse DoGet(const std::string& endpoint, const HeaderList& headers) = 0;
virtual HttpResponse DoPost(const std::string& endpoint, const std::string& json_body, const HeaderList& headers) = 0;

const std::string base_url_;
const ClientCredentials credentials_;

private:
// Header list helpers
static HeaderList DefaultJsonGetHeaders();
static HeaderList DefaultJsonPostHeaders();
std::string AddAuthorizationHeader(HeaderList& headers);

// Token variable
struct CachedToken {
std::string token;
std::string token_type;
std::int64_t expires_at = 0;
};
std::optional<CachedToken> cached_token_;

// Thread-safe synchronization variables while fetching token
std::mutex token_mutex_;
std::condition_variable token_cv_;
bool token_fetch_in_progress_ = false;

// Thread-safe synchronization functions while fetching token
std::optional<CachedToken> EnsureValidToken(std::string& error);
std::optional<CachedToken> FetchToken(std::string& error);
void InvalidateCachedToken();
};
Loading