Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions src/agents/command_router/http_api/CommandRouterHttpAPI.cc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include "BusCommandRouterProcessor.h"
#include "BusCommandRouterProxy.h"
#include "BusCommandRouterProxyStreamPoller.h"
#include "SystemParametersSingleton.h"

#define LOG_LEVEL INFO_LEVEL
#include "Logger.h"
Expand Down Expand Up @@ -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<pair<string, string>> set_args;
for (const auto& [key, value] : body["parameters"].items()) {
string validation_error;
const optional<string> 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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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);

Expand Down Expand Up @@ -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<string> 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<string> {
error_message = std::move(message);
return nullopt;
};

optional<string> formatted_value;

if (holds_alternative<bool>(param_it->second)) {
if (value.is_boolean()) {
formatted_value = value.get<bool>() ? "true" : "false";
} else if (value.is_string()) {
const string& text = value.get<string>();
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<long long>();
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)");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else if (holds_alternative<unsigned int>(param_it->second)) {
const string uint_error = "Parameter '" + key + "' expects unsigned integer";
const auto fits_uint = [](unsigned long long number) {
return static_cast<unsigned int>(number) == number;
};
if (value.is_number_unsigned()) {
const unsigned long long number = value.get<unsigned long long>();
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<long long>();
if (number < 0 || !fits_uint(static_cast<unsigned long long>(number))) {
return fail(uint_error);
}
formatted_value = std::to_string(number);
} else if (value.is_string()) {
const string& text = value.get<string>();
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (const exception&) {
return fail(uint_error);
}
} else {
return fail(uint_error);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else if (holds_alternative<long>(param_it->second)) {
if (value.is_number_integer()) {
formatted_value = std::to_string(value.get<long long>());
} else if (value.is_string()) {
try {
size_t consumed = 0;
const long parsed = stol(value.get<string>(), &consumed);
if (consumed != value.get<string>().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<double>(param_it->second)) {
if (value.is_number()) {
formatted_value = value.dump();
} else if (value.is_string()) {
try {
size_t consumed = 0;
stod(value.get<string>(), &consumed);
if (consumed != value.get<string>().size()) {
return fail("Parameter '" + key + "' expects number");
}
formatted_value = value.get<string>();
} catch (const exception&) {
return fail("Parameter '" + key + "' expects number");
}
} else {
return fail("Parameter '" + key + "' expects number");
}
} else if (holds_alternative<string>(param_it->second)) {
if (!value.is_string()) {
return fail("Parameter '" + key + "' expects string");
}
const string& text = value.get<string>();
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();
}
10 changes: 10 additions & 0 deletions src/agents/command_router/http_api/CommandRouterHttpAPI.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
#include <unordered_set>
Expand Down Expand Up @@ -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 <key> <value>`.
* @return The set-command arg on success; nullopt with error_message on failure.
*/
optional<string> build_set_param_arg(Properties& known_params,
const string& key,
const json& value,
string& error_message) const;
};

} // namespace command_router
71 changes: 70 additions & 1 deletion src/tests/cpp/command_router_http_api_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -631,6 +637,69 @@ TEST_F(CommandRouterHttpAPITest, set_param_rejects_unknown_key) {
EXPECT_NE(payload["error"].get<string>().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<string>();
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<string>().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<string>().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<string>().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<string>().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);
Expand Down
Loading