From d12f5da14c33cd07e12a0d56cb56db2fb3aad5ae Mon Sep 17 00:00:00 2001 From: Ooreoluwa Fasawe Date: Wed, 19 Aug 2026 19:28:41 -0700 Subject: [PATCH 1/3] fix: parse REST API query strings --- CMakeLists.txt | 7 +++ tests/unit/WebServerTests.cpp | 75 ++++++++++++++++++++++ visualization/WebServer.cpp | 115 ++++++++++++++++++++++++++++------ visualization/WebServer.h | 3 + 4 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 tests/unit/WebServerTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 614c792..10a65d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -523,6 +523,13 @@ if(BUILD_TESTS) GTest::gtest_main GTest::gtest Threads::Threads) add_test(NAME CrossMarketCorrelationTests COMMAND cross_market_correlation_tests) + + if(BUILD_VISUALIZATION) + add_executable(web_server_tests tests/unit/WebServerTests.cpp) + target_link_libraries(web_server_tests visualization GTest::gtest_main + GTest::gtest Threads::Threads) + add_test(NAME WebServerTests COMMAND web_server_tests) + endif() endif() # Benchmarks diff --git a/tests/unit/WebServerTests.cpp b/tests/unit/WebServerTests.cpp new file mode 100644 index 0000000..4178788 --- /dev/null +++ b/tests/unit/WebServerTests.cpp @@ -0,0 +1,75 @@ +#include "../../visualization/WebServer.h" + +#include + +namespace pinnacle::visualization { + +class WebServerTests : public ::testing::Test { +protected: + std::shared_ptr collector = + std::make_shared(); + RestAPIServer server{collector}; + + std::unordered_map + parseQuery(const std::string& query) { + return server.parseQueryString(query); + } + + http::status route(const std::string& target) { + http::request request{http::verb::get, target, 11}; + return server.handleRequest(std::move(request)).result(); + } +}; + +TEST_F(WebServerTests, ParsesStandardQueryParameters) { + auto params = parseQuery("start=1234567890&end=9876543210&limit=100"); + + EXPECT_EQ(params.at("start"), "1234567890"); + EXPECT_EQ(params.at("end"), "9876543210"); + EXPECT_EQ(params.at("limit"), "100"); +} + +TEST_F(WebServerTests, SupportsEncodedValuesAndOptionalQuestionMark) { + auto params = + parseQuery("?name=Oore%20Fasawe&search=C%2B%2B¬e=hello+world"); + + EXPECT_EQ(params.at("name"), "Oore Fasawe"); + EXPECT_EQ(params.at("search"), "C++"); + EXPECT_EQ(params.at("note"), "hello world"); +} + +TEST_F(WebServerTests, ReturnsEmptyMapForEmptyQuery) { + EXPECT_TRUE(parseQuery("").empty()); +} + +TEST_F(WebServerTests, HandlesEmptyMissingAndRepeatedValues) { + auto params = parseQuery("limit=&debug&metric=pnl&metric=sharpe"); + + EXPECT_EQ(params.at("limit"), ""); + EXPECT_EQ(params.at("debug"), ""); + EXPECT_EQ(params.at("metric"), "sharpe"); +} + +TEST_F(WebServerTests, PreservesEqualsCharactersInValues) { + auto params = parseQuery("token=abc=123"); + + EXPECT_EQ(params.at("token"), "abc=123"); +} + +TEST_F(WebServerTests, SkipsMalformedParameters) { + auto params = parseQuery("valid=value&bad=%ZZ&truncated=%A&empty-key=value"); + + EXPECT_EQ(params.at("valid"), "value"); + EXPECT_EQ(params.count("bad"), 0); + EXPECT_EQ(params.count("truncated"), 0); + EXPECT_EQ(params.count("empty-key"), 1); +} + +TEST_F(WebServerTests, RoutesPerformanceRequestsWithQueryStrings) { + EXPECT_EQ( + route( + "/api/v1/strategies/primary_strategy/performance?start=100&end=500"), + http::status::ok); +} + +} // namespace pinnacle::visualization diff --git a/visualization/WebServer.cpp b/visualization/WebServer.cpp index 73c037b..37704ce 100644 --- a/visualization/WebServer.cpp +++ b/visualization/WebServer.cpp @@ -14,6 +14,55 @@ namespace pinnacle { namespace visualization { +namespace { + +bool decodeQueryComponent(const std::string& encoded, std::string& decoded) { + decoded.clear(); + decoded.reserve(encoded.size()); + + const auto hexValue = [](char character) -> int { + if (character >= '0' && character <= '9') { + return character - '0'; + } + if (character >= 'a' && character <= 'f') { + return character - 'a' + 10; + } + if (character >= 'A' && character <= 'F') { + return character - 'A' + 10; + } + return -1; + }; + + for (std::size_t index = 0; index < encoded.size(); ++index) { + if (encoded[index] == '+') { + decoded.push_back(' '); + continue; + } + + if (encoded[index] != '%') { + decoded.push_back(encoded[index]); + continue; + } + + if (index + 2 >= encoded.size()) { + return false; + } + + auto high = hexValue(encoded[index + 1]); + auto low = hexValue(encoded[index + 2]); + if (high < 0 || low < 0) { + return false; + } + + decoded.push_back(static_cast((high << 4) | low)); + index += 2; + } + + return true; +} + +} // namespace + // ============================================================================ // PerformanceCollector Implementation // ============================================================================ @@ -857,33 +906,36 @@ http::response RestAPIServer::handleRequest(http::request&& req) { // Simple routing auto target = std::string(req.target()); + auto path = extractPath(target); + auto queryPos = target.find('?'); + auto query = queryPos == std::string::npos ? "" : target.substr(queryPos + 1); - if (target == "/api/v1/strategies") { + if (path == "/api/v1/strategies") { return handleGetStrategies(); - } else if (target.starts_with("/api/v1/strategies/") && - target.ends_with("/performance")) { + } else if (path.starts_with("/api/v1/strategies/") && + path.ends_with("/performance")) { // Extract strategy ID - auto start = target.find("/api/v1/strategies/") + 19; - auto end = target.find("/performance"); - auto strategyId = target.substr(start, end - start); - return handleGetPerformance(strategyId, ""); - } else if (target == "/api/risk/state") { + auto start = path.find("/api/v1/strategies/") + 19; + auto end = path.find("/performance"); + auto strategyId = path.substr(start, end - start); + return handleGetPerformance(strategyId, query); + } else if (path == "/api/risk/state") { return handleGetRiskState(); - } else if (target == "/api/risk/var") { + } else if (path == "/api/risk/var") { return handleGetRiskVaR(); - } else if (target == "/api/risk/limits") { + } else if (path == "/api/risk/limits") { return handleGetRiskLimits(); - } else if (target == "/api/risk/circuit-breaker") { + } else if (path == "/api/risk/circuit-breaker") { return handleGetCircuitBreaker(); - } else if (target == "/api/risk/alerts") { + } else if (path == "/api/risk/alerts") { return handleGetAlerts(); - } else if (target == "/api/health") { + } else if (path == "/api/health") { return handleGetHealth(); - } else if (target == "/api/ready") { + } else if (path == "/api/ready") { return handleGetReady(); - } else if (target.starts_with("/")) { + } else if (path.starts_with("/")) { // Serve static files - return handleStaticFile(target); + return handleStaticFile(path); } // Not found @@ -1018,11 +1070,38 @@ json RestAPIServer::createSuccessResponse(const json& data) { std::unordered_map RestAPIServer::parseQueryString(const std::string& query) { std::unordered_map params; - // Simple query string parsing (not implemented for brevity) - boost::ignore_unused(query); + + auto queryStart = query.starts_with('?') ? 1 : 0; + std::istringstream queryStream(query.substr(queryStart)); + std::string parameter; + + while (std::getline(queryStream, parameter, '&')) { + if (parameter.empty()) { + continue; + } + + auto equalsPos = parameter.find('='); + auto encodedKey = parameter.substr(0, equalsPos); + auto encodedValue = + equalsPos == std::string::npos ? "" : parameter.substr(equalsPos + 1); + std::string key; + std::string value; + if (!decodeQueryComponent(encodedKey, key) || + !decodeQueryComponent(encodedValue, value) || key.empty()) { + continue; + } + + params[key] = value; + } + return params; } +std::string RestAPIServer::urlDecode(const std::string& str) { + std::string decoded; + return decodeQueryComponent(str, decoded) ? decoded : std::string{}; +} + std::string RestAPIServer::extractPath(const std::string& target) { auto pos = target.find('?'); return pos != std::string::npos ? target.substr(0, pos) : target; diff --git a/visualization/WebServer.h b/visualization/WebServer.h index c5367e6..e2a18a2 100644 --- a/visualization/WebServer.h +++ b/visualization/WebServer.h @@ -195,6 +195,7 @@ class WebSocketHandler : public std::enable_shared_from_this { // Forward declaration for HTTP session class HttpSession; +class WebServerTests; /** * @class RestAPIServer @@ -244,12 +245,14 @@ class RestAPIServer : public std::enable_shared_from_this { // Utility methods json createErrorResponse(const std::string& error, int code = 400); json createSuccessResponse(const json& data); + std::string urlDecode(const std::string& str); std::unordered_map parseQueryString(const std::string& query); std::string extractPath(const std::string& target); std::string getContentType(const std::string& path); friend class HttpSession; + friend class WebServerTests; }; /** From 0bf1a85ad8989a5e0c85e319e9c2f545dc12edc5 Mon Sep 17 00:00:00 2001 From: Ooreoluwa Fasawe Date: Wed, 19 Aug 2026 19:51:27 -0700 Subject: [PATCH 2/3] ci: fix Dockerfile healthcheck lint --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 32ad6bf..a213807 100644 --- a/Dockerfile +++ b/Dockerfile @@ -129,7 +129,7 @@ ENV LD_LIBRARY_PATH=/usr/local/lib # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD curl -f http://localhost:8081/api/health || exit 1 + CMD ["curl", "-f", "http://localhost:8081/api/health"] # Default command ENTRYPOINT ["/app/pinnaclemm"] From fc25ac13b87e7581ccca8a7a16e0eecbc6e12cf3 Mon Sep 17 00:00:00 2001 From: Ooreoluwa Fasawe Date: Wed, 19 Aug 2026 21:22:38 -0700 Subject: [PATCH 3/3] Avoids exposing private implementation details through a test-only friendship. --- tests/unit/WebServerTests.cpp | 49 ++++++++++------------------------- visualization/WebServer.cpp | 7 +---- visualization/WebServer.h | 8 +++--- 3 files changed, 17 insertions(+), 47 deletions(-) diff --git a/tests/unit/WebServerTests.cpp b/tests/unit/WebServerTests.cpp index 4178788..765b567 100644 --- a/tests/unit/WebServerTests.cpp +++ b/tests/unit/WebServerTests.cpp @@ -4,60 +4,44 @@ namespace pinnacle::visualization { -class WebServerTests : public ::testing::Test { -protected: - std::shared_ptr collector = - std::make_shared(); - RestAPIServer server{collector}; - - std::unordered_map - parseQuery(const std::string& query) { - return server.parseQueryString(query); - } - - http::status route(const std::string& target) { - http::request request{http::verb::get, target, 11}; - return server.handleRequest(std::move(request)).result(); - } -}; - -TEST_F(WebServerTests, ParsesStandardQueryParameters) { - auto params = parseQuery("start=1234567890&end=9876543210&limit=100"); +TEST(QueryString, ParsesStandardQueryParameters) { + auto params = parseQueryString("start=1234567890&end=9876543210&limit=100"); EXPECT_EQ(params.at("start"), "1234567890"); EXPECT_EQ(params.at("end"), "9876543210"); EXPECT_EQ(params.at("limit"), "100"); } -TEST_F(WebServerTests, SupportsEncodedValuesAndOptionalQuestionMark) { +TEST(QueryString, SupportsEncodedValuesAndOptionalQuestionMark) { auto params = - parseQuery("?name=Oore%20Fasawe&search=C%2B%2B¬e=hello+world"); + parseQueryString("?name=Oore%20Fasawe&search=C%2B%2B¬e=hello+world"); EXPECT_EQ(params.at("name"), "Oore Fasawe"); EXPECT_EQ(params.at("search"), "C++"); EXPECT_EQ(params.at("note"), "hello world"); } -TEST_F(WebServerTests, ReturnsEmptyMapForEmptyQuery) { - EXPECT_TRUE(parseQuery("").empty()); +TEST(QueryString, ReturnsEmptyMapForEmptyQuery) { + EXPECT_TRUE(parseQueryString("").empty()); } -TEST_F(WebServerTests, HandlesEmptyMissingAndRepeatedValues) { - auto params = parseQuery("limit=&debug&metric=pnl&metric=sharpe"); +TEST(QueryString, HandlesEmptyMissingAndRepeatedValues) { + auto params = parseQueryString("limit=&debug&metric=pnl&metric=sharpe"); EXPECT_EQ(params.at("limit"), ""); EXPECT_EQ(params.at("debug"), ""); EXPECT_EQ(params.at("metric"), "sharpe"); } -TEST_F(WebServerTests, PreservesEqualsCharactersInValues) { - auto params = parseQuery("token=abc=123"); +TEST(QueryString, PreservesEqualsCharactersInValues) { + auto params = parseQueryString("token=abc=123"); EXPECT_EQ(params.at("token"), "abc=123"); } -TEST_F(WebServerTests, SkipsMalformedParameters) { - auto params = parseQuery("valid=value&bad=%ZZ&truncated=%A&empty-key=value"); +TEST(QueryString, SkipsMalformedParameters) { + auto params = + parseQueryString("valid=value&bad=%ZZ&truncated=%A&empty-key=value"); EXPECT_EQ(params.at("valid"), "value"); EXPECT_EQ(params.count("bad"), 0); @@ -65,11 +49,4 @@ TEST_F(WebServerTests, SkipsMalformedParameters) { EXPECT_EQ(params.count("empty-key"), 1); } -TEST_F(WebServerTests, RoutesPerformanceRequestsWithQueryStrings) { - EXPECT_EQ( - route( - "/api/v1/strategies/primary_strategy/performance?start=100&end=500"), - http::status::ok); -} - } // namespace pinnacle::visualization diff --git a/visualization/WebServer.cpp b/visualization/WebServer.cpp index 37704ce..42f077c 100644 --- a/visualization/WebServer.cpp +++ b/visualization/WebServer.cpp @@ -1068,7 +1068,7 @@ json RestAPIServer::createSuccessResponse(const json& data) { } std::unordered_map -RestAPIServer::parseQueryString(const std::string& query) { +parseQueryString(const std::string& query) { std::unordered_map params; auto queryStart = query.starts_with('?') ? 1 : 0; @@ -1097,11 +1097,6 @@ RestAPIServer::parseQueryString(const std::string& query) { return params; } -std::string RestAPIServer::urlDecode(const std::string& str) { - std::string decoded; - return decodeQueryComponent(str, decoded) ? decoded : std::string{}; -} - std::string RestAPIServer::extractPath(const std::string& target) { auto pos = target.find('?'); return pos != std::string::npos ? target.substr(0, pos) : target; diff --git a/visualization/WebServer.h b/visualization/WebServer.h index e2a18a2..6d3441f 100644 --- a/visualization/WebServer.h +++ b/visualization/WebServer.h @@ -32,6 +32,9 @@ namespace beast = boost::beast; namespace http = beast::http; namespace net = boost::asio; +std::unordered_map +parseQueryString(const std::string& query); + // Simple data structures for visualization struct PerformanceData { uint64_t timestamp{0}; @@ -195,7 +198,6 @@ class WebSocketHandler : public std::enable_shared_from_this { // Forward declaration for HTTP session class HttpSession; -class WebServerTests; /** * @class RestAPIServer @@ -245,14 +247,10 @@ class RestAPIServer : public std::enable_shared_from_this { // Utility methods json createErrorResponse(const std::string& error, int code = 400); json createSuccessResponse(const json& data); - std::string urlDecode(const std::string& str); - std::unordered_map - parseQueryString(const std::string& query); std::string extractPath(const std::string& target); std::string getContentType(const std::string& path); friend class HttpSession; - friend class WebServerTests; }; /**