diff --git a/src/agents/command_router/http_api/CommandRouterHttpAPI.cc b/src/agents/command_router/http_api/CommandRouterHttpAPI.cc index f2b7c77d..95e8d354 100644 --- a/src/agents/command_router/http_api/CommandRouterHttpAPI.cc +++ b/src/agents/command_router/http_api/CommandRouterHttpAPI.cc @@ -7,6 +7,7 @@ #include "BusCommandRouterProcessor.h" #include "BusCommandRouterProxy.h" #include "BusCommandRouterProxyStreamPoller.h" +#include "SystemParametersSingleton.h" #define LOG_LEVEL INFO_LEVEL #include "Logger.h" @@ -150,6 +151,48 @@ void CommandRouterHttpAPI::setup_routes() { return; } + if (body.contains("parameters")) { + if (!body["parameters"].is_object()) { + this->set_json_response( + response, 400, {{"error", "Invalid parameters: expected object"}}); + return; + } + + LOG_INFO("CommandRouter HTTP API setting parameters for command=" << command_text); + + Properties known_params = + SystemParametersSingleton::get_instance()->get_command_router_params(); + vector> set_args; + for (const auto& [key, value] : body["parameters"].items()) { + string validation_error; + const optional args = + this->build_set_param_arg(known_params, key, value, validation_error); + if (!args.has_value()) { + this->set_json_response(response, 400, {{"error", validation_error}}); + return; + } + set_args.emplace_back(key, args.value()); + } + for (const auto& [key, args] : set_args) { + string router_error; + const PollStreamResult poll_result = this->execute_router_command( + "set", + args, + nullptr, + nullptr, + [&](const string& message) { router_error = message; }, + nullptr); + if (!poll_result.ok) { + LOG_ERROR("CommandRouter HTTP API setting parameter failed for command=" + << command_text << " key=" << key << " args=" << args + << " error=" << router_error); + this->set_json_response(response, 500, {{"error", router_error}}); + return; + } + } + LOG_INFO("CommandRouter HTTP API parameters set for command=" << command_text); + } + if (this->is_sync_command_type(command_type)) { LOG_INFO("CommandRouter HTTP API sync execution type=" << command_type); @@ -495,3 +538,134 @@ void CommandRouterHttpAPI::set_json_response(httplib::Response& response, int st string content = body.dump(); response.set_content(content, "application/json"); } + +optional CommandRouterHttpAPI::build_set_param_arg(Properties& known_params, + const string& key, + const json& value, + string& error_message) const { + auto param_it = known_params.find(key); + if (param_it == known_params.end()) { + error_message = "Unknown parameter: '" + key + "'"; + return nullopt; + } + + const auto fail = [&](string message) -> optional { + error_message = std::move(message); + return nullopt; + }; + + optional formatted_value; + + if (holds_alternative(param_it->second)) { + if (value.is_boolean()) { + formatted_value = value.get() ? "true" : "false"; + } else if (value.is_string()) { + const string& text = value.get(); + if (text == "true" || text == "false") { + formatted_value = text; + } else if (text == "1") { + formatted_value = "true"; + } else if (text == "0") { + formatted_value = "false"; + } else { + return fail("Parameter '" + key + "' expects bool (true, false, 1, or 0)"); + } + } else if (value.is_number_integer()) { + const long long number = value.get(); + if (number == 0) { + formatted_value = "false"; + } else if (number == 1) { + formatted_value = "true"; + } else { + return fail("Parameter '" + key + "' expects bool (true, false, 1, or 0)"); + } + } else { + return fail("Parameter '" + key + "' expects bool (true, false, 1, or 0)"); + } + } else if (holds_alternative(param_it->second)) { + const string uint_error = "Parameter '" + key + "' expects unsigned integer"; + const auto fits_uint = [](unsigned long long number) { + return static_cast(number) == number; + }; + if (value.is_number_unsigned()) { + const unsigned long long number = value.get(); + if (!fits_uint(number)) { + return fail(uint_error); + } + formatted_value = std::to_string(number); + } else if (value.is_number_integer()) { + const long long number = value.get(); + if (number < 0 || !fits_uint(static_cast(number))) { + return fail(uint_error); + } + formatted_value = std::to_string(number); + } else if (value.is_string()) { + const string& text = value.get(); + const bool all_digits = + !text.empty() && + all_of(text.begin(), text.end(), [](unsigned char c) { return isdigit(c); }); + if (!all_digits) { + return fail(uint_error); + } + try { + size_t consumed = 0; + const unsigned long long parsed = stoull(text, &consumed); + if (consumed != text.size() || !fits_uint(parsed)) { + return fail(uint_error); + } + formatted_value = text; + } catch (const exception&) { + return fail(uint_error); + } + } else { + return fail(uint_error); + } + } else if (holds_alternative(param_it->second)) { + if (value.is_number_integer()) { + formatted_value = std::to_string(value.get()); + } else if (value.is_string()) { + try { + size_t consumed = 0; + const long parsed = stol(value.get(), &consumed); + if (consumed != value.get().size()) { + return fail("Parameter '" + key + "' expects integer"); + } + formatted_value = std::to_string(parsed); + } catch (const exception&) { + return fail("Parameter '" + key + "' expects integer"); + } + } else { + return fail("Parameter '" + key + "' expects integer"); + } + } else if (holds_alternative(param_it->second)) { + if (value.is_number()) { + formatted_value = value.dump(); + } else if (value.is_string()) { + try { + size_t consumed = 0; + stod(value.get(), &consumed); + if (consumed != value.get().size()) { + return fail("Parameter '" + key + "' expects number"); + } + formatted_value = value.get(); + } catch (const exception&) { + return fail("Parameter '" + key + "' expects number"); + } + } else { + return fail("Parameter '" + key + "' expects number"); + } + } else if (holds_alternative(param_it->second)) { + if (!value.is_string()) { + return fail("Parameter '" + key + "' expects string"); + } + const string& text = value.get(); + if (text.empty()) { + return fail("Parameter '" + key + "' expects non-empty string"); + } + formatted_value = text; + } else { + return fail("Parameter '" + key + "' has unsupported type"); + } + + return "param " + key + " " + formatted_value.value(); +} diff --git a/src/agents/command_router/http_api/CommandRouterHttpAPI.h b/src/agents/command_router/http_api/CommandRouterHttpAPI.h index f7ff3ec2..915115c4 100644 --- a/src/agents/command_router/http_api/CommandRouterHttpAPI.h +++ b/src/agents/command_router/http_api/CommandRouterHttpAPI.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -133,6 +134,15 @@ class CommandRouterHttpAPI : public processor::Processor, public processor::Thre /** @brief Set response status and JSON body. */ void set_json_response(httplib::Response& res, int status_code, const json& payload); + + /** + * @brief Validate a router parameter value and build `param `. + * @return The set-command arg on success; nullopt with error_message on failure. + */ + optional build_set_param_arg(Properties& known_params, + const string& key, + const json& value, + string& error_message) const; }; } // namespace command_router diff --git a/src/tests/cpp/command_router_http_api_test.cc b/src/tests/cpp/command_router_http_api_test.cc index b7567618..c5c1e95b 100644 --- a/src/tests/cpp/command_router_http_api_test.cc +++ b/src/tests/cpp/command_router_http_api_test.cc @@ -44,6 +44,12 @@ json make_execution_body(const string& command_type = "query", return {{"command_type", command_type}, {"command_text", command_text}}; } +json make_execution_body_with_parameters(const json& parameters, + const string& command_type = "query", + const string& command_text = "(Similarity \"human\" %V)") { + return {{"command_type", command_type}, {"command_text", command_text}, {"parameters", parameters}}; +} + class HangingQueryForwardProxy : public BusCommandProxy { public: void pack_command_line_args() override {} @@ -115,7 +121,7 @@ class HttpAPIServerFixture { httplib::Client make_client(int port) const { httplib::Client client(TEST_HOST, port); client.set_connection_timeout(2); - client.set_read_timeout(5); + client.set_read_timeout(15); return client; } @@ -631,6 +637,69 @@ TEST_F(CommandRouterHttpAPITest, set_param_rejects_unknown_key) { EXPECT_NE(payload["error"].get().find("Unknown parameter"), string::npos); } +TEST_F(CommandRouterHttpAPITest, execution_parameters_accepts_valid_scalar_values) { + auto create = + client().Post("/command-router/executions", + make_execution_body_with_parameters({{"populate_metta_mapping", true}, + {"use_metta_as_query_tokens", "true"}, + {"max_answers", 1}, + {"count_flag", 1}}) + .dump(), + "application/json"); + ASSERT_TRUE(create); + EXPECT_EQ(create->status, 202); + + auto get_res = client().Post( + "/command-router/executions", make_execution_body("get", "params").dump(), "application/json"); + ASSERT_TRUE(get_res); + ASSERT_EQ(get_res->status, 200); + + const string params = json::parse(get_res->body)["result"].get(); + EXPECT_NE(params.find("populate_metta_mapping: true"), string::npos); + EXPECT_NE(params.find("use_metta_as_query_tokens: true"), string::npos); + EXPECT_NE(params.find("max_answers: 1"), string::npos); + EXPECT_NE(params.find("count_flag: true"), string::npos); +} + +TEST_F(CommandRouterHttpAPITest, execution_parameters_rejects_invalid_values) { + auto unknown_key = client().Post("/command-router/executions", + make_execution_body_with_parameters({{"unknown_key", true}}).dump(), + "application/json"); + ASSERT_TRUE(unknown_key); + EXPECT_EQ(unknown_key->status, 400); + EXPECT_NE(json::parse(unknown_key->body)["error"].get().find("Unknown parameter"), + string::npos); + + auto wrong_type = + client().Post("/command-router/executions", + make_execution_body_with_parameters({{"max_answers", "not_a_number"}}).dump(), + "application/json"); + ASSERT_TRUE(wrong_type); + EXPECT_EQ(wrong_type->status, 400); + EXPECT_NE(json::parse(wrong_type->body)["error"].get().find("unsigned integer"), + string::npos); + + auto out_of_range = + client().Post("/command-router/executions", + make_execution_body_with_parameters({{"max_answers", 4294967296}}).dump(), + "application/json"); + ASSERT_TRUE(out_of_range); + EXPECT_EQ(out_of_range->status, 400); + EXPECT_NE(json::parse(out_of_range->body)["error"].get().find("unsigned integer"), + string::npos); + + auto not_object = client().Post("/command-router/executions", + json({{"command_type", "query"}, + {"command_text", "(Similarity \"human\" %V)"}, + {"parameters", json::array({1, 2, 3})}}) + .dump(), + "application/json"); + ASSERT_TRUE(not_object); + EXPECT_EQ(not_object->status, 400); + EXPECT_NE(json::parse(not_object->body)["error"].get().find("expected object"), + string::npos); +} + TEST_F(CommandRouterHttpAPITest, create_execution_rejects_invalid_requests) { auto bad_json = client().Post("/command-router/executions", "{bad", "application/json"); ASSERT_TRUE(bad_json);