|
|
@@ -3,6 +3,7 @@
|
|
|
#include "common/time_utils.hpp"
|
|
|
#include "proto/runner.grpc.pb.h"
|
|
|
#include <grpcpp/grpcpp.h>
|
|
|
+#include <optional>
|
|
|
|
|
|
namespace smartbotic::webserver::api {
|
|
|
|
|
|
@@ -11,11 +12,13 @@ using namespace common;
|
|
|
WebhookController::WebhookController(storage::StorageClient& storage,
|
|
|
runners::RunnerRegistry& registry,
|
|
|
runners::LoadBalancer& load_balancer,
|
|
|
- WebSocketServer& ws_server)
|
|
|
+ WebSocketServer& ws_server,
|
|
|
+ nodes::NodeStore& node_store)
|
|
|
: storage_(storage)
|
|
|
, registry_(registry)
|
|
|
, load_balancer_(load_balancer)
|
|
|
- , ws_server_(ws_server) {}
|
|
|
+ , ws_server_(ws_server)
|
|
|
+ , node_store_(node_store) {}
|
|
|
|
|
|
void WebhookController::registerRoutes(httplib::Server& server) {
|
|
|
// Match any HTTP method for webhooks
|
|
|
@@ -51,6 +54,61 @@ void WebhookController::handleWebhook(const httplib::Request& req, httplib::Resp
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
+ // Find matching HTTP trigger node for this method
|
|
|
+ auto trigger_node = findTriggerNode(workflow, req.method);
|
|
|
+
|
|
|
+ // If a trigger node exists, validate the request
|
|
|
+ if (trigger_node.has_value()) {
|
|
|
+ auto& node = trigger_node.value();
|
|
|
+ auto config = node.value("config", nlohmann::json::object());
|
|
|
+
|
|
|
+ // Validate path (if configured)
|
|
|
+ std::string config_path = config.value("path", "");
|
|
|
+ if (!config_path.empty() && path != config_path) {
|
|
|
+ sendError(res, "Path not found", 404);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate authentication (if requireAuth is true)
|
|
|
+ if (config.value("requireAuth", false)) {
|
|
|
+ if (!validateApiKey(req, config)) {
|
|
|
+ sendError(res, "Unauthorized", 401);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate content-type (for POST/PUT)
|
|
|
+ if (req.method == "POST" || req.method == "PUT") {
|
|
|
+ std::string expected_ct = config.value("contentType", "application/json");
|
|
|
+ auto ct = req.get_header_value("Content-Type");
|
|
|
+ if (ct.find(expected_ct) == std::string::npos) {
|
|
|
+ sendError(res, "Unsupported content type. Expected: " + expected_ct, 415);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate body schema (if validateBody is true)
|
|
|
+ if (config.value("validateBody", false)) {
|
|
|
+ auto schema = config.value("bodySchema", nlohmann::json::object());
|
|
|
+ nlohmann::json body_json;
|
|
|
+
|
|
|
+ if (!req.body.empty()) {
|
|
|
+ try {
|
|
|
+ body_json = nlohmann::json::parse(req.body);
|
|
|
+ } catch (const std::exception& e) {
|
|
|
+ sendError(res, "Invalid JSON body: " + std::string(e.what()), 400);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ std::string validation_error;
|
|
|
+ if (!validateBodySchema(body_json, schema, validation_error)) {
|
|
|
+ sendError(res, "Body validation failed: " + validation_error, 400);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
// Select a runner
|
|
|
auto runner = load_balancer_.selectRunner();
|
|
|
if (!runner) {
|
|
|
@@ -78,6 +136,9 @@ void WebhookController::handleWebhook(const httplib::Request& req, httplib::Resp
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // Add client IP
|
|
|
+ trigger_data["clientIp"] = req.remote_addr;
|
|
|
+
|
|
|
// Execute workflow via gRPC
|
|
|
auto channel = grpc::CreateChannel(runner->address, grpc::InsecureChannelCredentials());
|
|
|
auto stub = proto::RunnerService::NewStub(channel);
|
|
|
@@ -131,4 +192,179 @@ void WebhookController::sendError(httplib::Response& res, const std::string& mes
|
|
|
res.set_content(nlohmann::json{{"error", message}}.dump(), "application/json");
|
|
|
}
|
|
|
|
|
|
+std::optional<nlohmann::json> WebhookController::findTriggerNode(
|
|
|
+ const nlohmann::json& workflow,
|
|
|
+ const std::string& method) {
|
|
|
+
|
|
|
+ // Map HTTP method to trigger node type
|
|
|
+ std::string trigger_type;
|
|
|
+ if (method == "GET") {
|
|
|
+ trigger_type = "get-trigger";
|
|
|
+ } else if (method == "POST") {
|
|
|
+ trigger_type = "post-trigger";
|
|
|
+ } else if (method == "PUT") {
|
|
|
+ trigger_type = "put-trigger";
|
|
|
+ } else {
|
|
|
+ return std::nullopt; // No HTTP trigger for this method
|
|
|
+ }
|
|
|
+
|
|
|
+ // Search workflow nodes for matching trigger
|
|
|
+ auto nodes = workflow.value("nodes", nlohmann::json::array());
|
|
|
+ for (const auto& node : nodes) {
|
|
|
+ std::string node_type = node.value("type", "");
|
|
|
+ if (node_type == trigger_type) {
|
|
|
+ return std::make_optional(nlohmann::json(node));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return std::nullopt;
|
|
|
+}
|
|
|
+
|
|
|
+bool WebhookController::validateApiKey(
|
|
|
+ const httplib::Request& req,
|
|
|
+ const nlohmann::json& config) {
|
|
|
+
|
|
|
+ std::string header_name = config.value("apiKeyHeader", "X-API-Key");
|
|
|
+ std::string expected_key = config.value("apiKey", "");
|
|
|
+
|
|
|
+ if (expected_key.empty()) {
|
|
|
+ LOG_WARN("API key validation enabled but no key configured");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ auto it = req.headers.find(header_name);
|
|
|
+ if (it == req.headers.end()) {
|
|
|
+ LOG_DEBUG("Missing API key header: {}", header_name);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (it->second != expected_key) {
|
|
|
+ LOG_DEBUG("Invalid API key provided");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+bool WebhookController::validateBodySchema(
|
|
|
+ const nlohmann::json& body,
|
|
|
+ const nlohmann::json& schema,
|
|
|
+ std::string& error) {
|
|
|
+
|
|
|
+ // Validate type
|
|
|
+ if (schema.contains("type")) {
|
|
|
+ std::string expected_type = schema["type"];
|
|
|
+
|
|
|
+ if (expected_type == "object" && !body.is_object()) {
|
|
|
+ error = "Expected object, got " + std::string(body.type_name());
|
|
|
+ return false;
|
|
|
+ } else if (expected_type == "array" && !body.is_array()) {
|
|
|
+ error = "Expected array, got " + std::string(body.type_name());
|
|
|
+ return false;
|
|
|
+ } else if (expected_type == "string" && !body.is_string()) {
|
|
|
+ error = "Expected string, got " + std::string(body.type_name());
|
|
|
+ return false;
|
|
|
+ } else if (expected_type == "number" && !body.is_number()) {
|
|
|
+ error = "Expected number, got " + std::string(body.type_name());
|
|
|
+ return false;
|
|
|
+ } else if (expected_type == "integer" && !body.is_number_integer()) {
|
|
|
+ error = "Expected integer, got " + std::string(body.type_name());
|
|
|
+ return false;
|
|
|
+ } else if (expected_type == "boolean" && !body.is_boolean()) {
|
|
|
+ error = "Expected boolean, got " + std::string(body.type_name());
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate required fields
|
|
|
+ if (schema.contains("required") && schema["required"].is_array()) {
|
|
|
+ for (const auto& field : schema["required"]) {
|
|
|
+ std::string field_name = field.get<std::string>();
|
|
|
+ if (!body.contains(field_name)) {
|
|
|
+ error = "Missing required field: " + field_name;
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate properties (recursive)
|
|
|
+ if (schema.contains("properties") && body.is_object()) {
|
|
|
+ for (auto& [key, prop_schema] : schema["properties"].items()) {
|
|
|
+ if (body.contains(key)) {
|
|
|
+ if (!validateBodySchema(body[key], prop_schema, error)) {
|
|
|
+ error = key + ": " + error;
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate array items
|
|
|
+ if (schema.contains("items") && body.is_array()) {
|
|
|
+ const auto& items_schema = schema["items"];
|
|
|
+ size_t idx = 0;
|
|
|
+ for (const auto& item : body) {
|
|
|
+ if (!validateBodySchema(item, items_schema, error)) {
|
|
|
+ error = "[" + std::to_string(idx) + "]: " + error;
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ idx++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate minimum/maximum for numbers
|
|
|
+ if (body.is_number()) {
|
|
|
+ if (schema.contains("minimum")) {
|
|
|
+ double min_val = schema["minimum"].get<double>();
|
|
|
+ if (body.get<double>() < min_val) {
|
|
|
+ error = "Value below minimum: " + std::to_string(min_val);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (schema.contains("maximum")) {
|
|
|
+ double max_val = schema["maximum"].get<double>();
|
|
|
+ if (body.get<double>() > max_val) {
|
|
|
+ error = "Value above maximum: " + std::to_string(max_val);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate minLength/maxLength for strings
|
|
|
+ if (body.is_string()) {
|
|
|
+ std::string str_val = body.get<std::string>();
|
|
|
+ if (schema.contains("minLength")) {
|
|
|
+ size_t min_len = schema["minLength"].get<size_t>();
|
|
|
+ if (str_val.length() < min_len) {
|
|
|
+ error = "String too short, minimum length: " + std::to_string(min_len);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (schema.contains("maxLength")) {
|
|
|
+ size_t max_len = schema["maxLength"].get<size_t>();
|
|
|
+ if (str_val.length() > max_len) {
|
|
|
+ error = "String too long, maximum length: " + std::to_string(max_len);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate enum
|
|
|
+ if (schema.contains("enum") && schema["enum"].is_array()) {
|
|
|
+ bool found = false;
|
|
|
+ for (const auto& allowed : schema["enum"]) {
|
|
|
+ if (body == allowed) {
|
|
|
+ found = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!found) {
|
|
|
+ error = "Value not in allowed enum values";
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
} // namespace smartbotic::webserver::api
|