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/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"] diff --git a/tests/unit/WebServerTests.cpp b/tests/unit/WebServerTests.cpp new file mode 100644 index 0000000..765b567 --- /dev/null +++ b/tests/unit/WebServerTests.cpp @@ -0,0 +1,52 @@ +#include "../../visualization/WebServer.h" + +#include + +namespace pinnacle::visualization { + +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(QueryString, SupportsEncodedValuesAndOptionalQuestionMark) { + auto params = + 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(QueryString, ReturnsEmptyMapForEmptyQuery) { + EXPECT_TRUE(parseQueryString("").empty()); +} + +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(QueryString, PreservesEqualsCharactersInValues) { + auto params = parseQueryString("token=abc=123"); + + EXPECT_EQ(params.at("token"), "abc=123"); +} + +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); + EXPECT_EQ(params.count("truncated"), 0); + EXPECT_EQ(params.count("empty-key"), 1); +} + +} // namespace pinnacle::visualization diff --git a/visualization/WebServer.cpp b/visualization/WebServer.cpp index 73c037b..42f077c 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 @@ -1016,10 +1068,32 @@ json RestAPIServer::createSuccessResponse(const json& data) { } std::unordered_map -RestAPIServer::parseQueryString(const std::string& query) { +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; } diff --git a/visualization/WebServer.h b/visualization/WebServer.h index c5367e6..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}; @@ -244,8 +247,6 @@ 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::unordered_map - parseQueryString(const std::string& query); std::string extractPath(const std::string& target); std::string getContentType(const std::string& path);