Pārlūkot izejas kodu

feat: add HTTP trigger nodes (GET, POST, PUT) with request validation

Add three new trigger nodes that extend the webhook system:
- get-trigger: HTTP GET requests with optional path filtering and API key auth
- post-trigger: HTTP POST with JSON body schema validation
- put-trigger: HTTP PUT with JSON body schema validation

WebhookController now validates requests against trigger node config including
method matching, path filtering, content-type, API key auth, and JSON Schema
body validation.
fszontagh 6 mēneši atpakaļ
vecāks
revīzija
b378f5ec71

+ 94 - 0
nodes/triggers/get-trigger.js

@@ -0,0 +1,94 @@
+/**
+ * @node get-trigger
+ * @name GET Trigger
+ * @category triggers
+ * @version 1.0.0
+ * @description Triggers workflow on HTTP GET request to webhook endpoint
+ * @trigger
+ * @icon globe
+ */
+
+const configSchema = {
+  type: 'object',
+  properties: {
+    path: {
+      type: 'string',
+      title: 'Path Filter',
+      description: 'Optional path suffix to match (e.g., /status). Leave empty to match any path.',
+      default: ''
+    },
+    requireAuth: {
+      type: 'boolean',
+      title: 'Require Authentication',
+      description: 'Require API key in request header',
+      default: false
+    },
+    apiKeyHeader: {
+      type: 'string',
+      title: 'API Key Header',
+      description: 'Header name containing the API key',
+      default: 'X-API-Key',
+      'x-if': 'requireAuth'
+    },
+    apiKey: {
+      type: 'string',
+      title: 'API Key',
+      description: 'Expected API key value',
+      format: 'password',
+      'x-if': 'requireAuth'
+    }
+  }
+};
+
+const inputSchema = {
+  type: 'object',
+  properties: {}
+};
+
+const outputSchema = {
+  type: 'object',
+  properties: {
+    method: {
+      type: 'string',
+      description: 'HTTP method (GET)'
+    },
+    path: {
+      type: 'string',
+      description: 'Request path after /webhook/{workflow_id}'
+    },
+    query: {
+      type: 'object',
+      description: 'Query parameters as key-value pairs'
+    },
+    headers: {
+      type: 'object',
+      description: 'Request headers as key-value pairs'
+    },
+    timestamp: {
+      type: 'number',
+      description: 'Unix timestamp when request was received'
+    },
+    clientIp: {
+      type: 'string',
+      description: 'Client IP address'
+    }
+  }
+};
+
+async function execute(config, input, context) {
+  smartbotic.log.info('GET trigger activated');
+
+  // The trigger data comes from the webhook controller
+  const triggerData = context.triggerData || {};
+
+  return {
+    method: triggerData.method || 'GET',
+    path: triggerData.path || '',
+    query: triggerData.query || {},
+    headers: triggerData.headers || {},
+    timestamp: Date.now(),
+    clientIp: triggerData.clientIp || ''
+  };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 124 - 0
nodes/triggers/post-trigger.js

@@ -0,0 +1,124 @@
+/**
+ * @node post-trigger
+ * @name POST Trigger
+ * @category triggers
+ * @version 1.0.0
+ * @description Triggers workflow on HTTP POST request with JSON body validation
+ * @trigger
+ * @icon upload
+ */
+
+const configSchema = {
+  type: 'object',
+  properties: {
+    path: {
+      type: 'string',
+      title: 'Path Filter',
+      description: 'Optional path suffix to match (e.g., /orders). Leave empty to match any path.',
+      default: ''
+    },
+    contentType: {
+      type: 'string',
+      title: 'Content Type',
+      description: 'Required content type for the request',
+      enum: ['application/json'],
+      default: 'application/json'
+    },
+    validateBody: {
+      type: 'boolean',
+      title: 'Validate Body',
+      description: 'Enable JSON Schema validation for request body',
+      default: false
+    },
+    bodySchema: {
+      type: 'object',
+      title: 'Body Schema',
+      description: 'JSON Schema to validate request body against',
+      format: 'json-schema',
+      default: {
+        type: 'object',
+        properties: {},
+        required: []
+      },
+      'x-if': 'validateBody'
+    },
+    requireAuth: {
+      type: 'boolean',
+      title: 'Require Authentication',
+      description: 'Require API key in request header',
+      default: false
+    },
+    apiKeyHeader: {
+      type: 'string',
+      title: 'API Key Header',
+      description: 'Header name containing the API key',
+      default: 'X-API-Key',
+      'x-if': 'requireAuth'
+    },
+    apiKey: {
+      type: 'string',
+      title: 'API Key',
+      description: 'Expected API key value',
+      format: 'password',
+      'x-if': 'requireAuth'
+    }
+  }
+};
+
+const inputSchema = {
+  type: 'object',
+  properties: {}
+};
+
+const outputSchema = {
+  type: 'object',
+  properties: {
+    method: {
+      type: 'string',
+      description: 'HTTP method (POST)'
+    },
+    path: {
+      type: 'string',
+      description: 'Request path after /webhook/{workflow_id}'
+    },
+    query: {
+      type: 'object',
+      description: 'Query parameters as key-value pairs'
+    },
+    headers: {
+      type: 'object',
+      description: 'Request headers as key-value pairs'
+    },
+    body: {
+      type: 'object',
+      description: 'Parsed JSON request body'
+    },
+    timestamp: {
+      type: 'number',
+      description: 'Unix timestamp when request was received'
+    },
+    clientIp: {
+      type: 'string',
+      description: 'Client IP address'
+    }
+  }
+};
+
+async function execute(config, input, context) {
+  smartbotic.log.info('POST trigger activated');
+
+  // The trigger data comes from the webhook controller
+  const triggerData = context.triggerData || {};
+
+  return {
+    method: triggerData.method || 'POST',
+    path: triggerData.path || '',
+    query: triggerData.query || {},
+    headers: triggerData.headers || {},
+    body: triggerData.body || {},
+    timestamp: Date.now(),
+    clientIp: triggerData.clientIp || ''
+  };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 124 - 0
nodes/triggers/put-trigger.js

@@ -0,0 +1,124 @@
+/**
+ * @node put-trigger
+ * @name PUT Trigger
+ * @category triggers
+ * @version 1.0.0
+ * @description Triggers workflow on HTTP PUT request with JSON body validation
+ * @trigger
+ * @icon upload
+ */
+
+const configSchema = {
+  type: 'object',
+  properties: {
+    path: {
+      type: 'string',
+      title: 'Path Filter',
+      description: 'Optional path suffix to match (e.g., /orders/{id}). Leave empty to match any path.',
+      default: ''
+    },
+    contentType: {
+      type: 'string',
+      title: 'Content Type',
+      description: 'Required content type for the request',
+      enum: ['application/json'],
+      default: 'application/json'
+    },
+    validateBody: {
+      type: 'boolean',
+      title: 'Validate Body',
+      description: 'Enable JSON Schema validation for request body',
+      default: false
+    },
+    bodySchema: {
+      type: 'object',
+      title: 'Body Schema',
+      description: 'JSON Schema to validate request body against',
+      format: 'json-schema',
+      default: {
+        type: 'object',
+        properties: {},
+        required: []
+      },
+      'x-if': 'validateBody'
+    },
+    requireAuth: {
+      type: 'boolean',
+      title: 'Require Authentication',
+      description: 'Require API key in request header',
+      default: false
+    },
+    apiKeyHeader: {
+      type: 'string',
+      title: 'API Key Header',
+      description: 'Header name containing the API key',
+      default: 'X-API-Key',
+      'x-if': 'requireAuth'
+    },
+    apiKey: {
+      type: 'string',
+      title: 'API Key',
+      description: 'Expected API key value',
+      format: 'password',
+      'x-if': 'requireAuth'
+    }
+  }
+};
+
+const inputSchema = {
+  type: 'object',
+  properties: {}
+};
+
+const outputSchema = {
+  type: 'object',
+  properties: {
+    method: {
+      type: 'string',
+      description: 'HTTP method (PUT)'
+    },
+    path: {
+      type: 'string',
+      description: 'Request path after /webhook/{workflow_id}'
+    },
+    query: {
+      type: 'object',
+      description: 'Query parameters as key-value pairs'
+    },
+    headers: {
+      type: 'object',
+      description: 'Request headers as key-value pairs'
+    },
+    body: {
+      type: 'object',
+      description: 'Parsed JSON request body'
+    },
+    timestamp: {
+      type: 'number',
+      description: 'Unix timestamp when request was received'
+    },
+    clientIp: {
+      type: 'string',
+      description: 'Client IP address'
+    }
+  }
+};
+
+async function execute(config, input, context) {
+  smartbotic.log.info('PUT trigger activated');
+
+  // The trigger data comes from the webhook controller
+  const triggerData = context.triggerData || {};
+
+  return {
+    method: triggerData.method || 'PUT',
+    path: triggerData.path || '',
+    query: triggerData.query || {},
+    headers: triggerData.headers || {},
+    body: triggerData.body || {},
+    timestamp: Date.now(),
+    clientIp: triggerData.clientIp || ''
+  };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 238 - 2
src/webserver/api/webhook_controller.cpp

@@ -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

+ 9 - 1
src/webserver/api/webhook_controller.hpp

@@ -5,6 +5,7 @@
 #include "storage/storage_client.hpp"
 #include "../runners/load_balancer.hpp"
 #include "../websocket_server.hpp"
+#include "../nodes/node_store.hpp"
 
 namespace smartbotic::webserver::api {
 
@@ -13,13 +14,19 @@ public:
     WebhookController(storage::StorageClient& storage,
                      runners::RunnerRegistry& registry,
                      runners::LoadBalancer& load_balancer,
-                     WebSocketServer& ws_server);
+                     WebSocketServer& ws_server,
+                     nodes::NodeStore& node_store);
 
     void registerRoutes(httplib::Server& server);
 
 private:
     void handleWebhook(const httplib::Request& req, httplib::Response& res);
 
+    // HTTP trigger validation methods
+    std::optional<nlohmann::json> findTriggerNode(const nlohmann::json& workflow, const std::string& method);
+    bool validateApiKey(const httplib::Request& req, const nlohmann::json& config);
+    bool validateBodySchema(const nlohmann::json& body, const nlohmann::json& schema, std::string& error);
+
     void sendJson(httplib::Response& res, const nlohmann::json& data, int status = 200);
     void sendError(httplib::Response& res, const std::string& message, int status);
 
@@ -27,6 +34,7 @@ private:
     runners::RunnerRegistry& registry_;
     runners::LoadBalancer& load_balancer_;
     WebSocketServer& ws_server_;
+    nodes::NodeStore& node_store_;
 };
 
 } // namespace smartbotic::webserver::api

+ 1 - 1
src/webserver/webserver_service.cpp

@@ -218,7 +218,7 @@ void WebServerService::setupRoutes() {
     runner_ctrl_->registerRoutes(server);
 
     webhook_ctrl_ = std::make_unique<api::WebhookController>(
-        *storage_, *runner_registry_, *load_balancer_, *ws_server_);
+        *storage_, *runner_registry_, *load_balancer_, *ws_server_, *node_store_);
     webhook_ctrl_->registerRoutes(server);
 
     database_ctrl_ = std::make_unique<api::DatabaseController>(*storage_, *auth_middleware_);