diff --git a/README.md b/README.md index 3a9d9d5..f090fe0 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,10 @@ Visit the Dify Plugin marketplace, search for the "Webhook" plugin and click the Trigger a chatflow by sending a POST request to the chatflow endpoint: -- **URL**: `/chatflow/` +- **URL Without App**: `/chatflow/` +- **URL With App**: `/single-chatflow` - **Method**: `POST` -- **Headers**: +- **Headers**: - `Content-Type: application/json` - If using an API key: `X-API-Key: ` - **Body** (JSON): @@ -85,15 +86,16 @@ Trigger a chatflow by sending a POST request to the chatflow endpoint: } ``` -A successful response will include the chatflow output. +For endpoints configured with a specific Dify app, use the `/single-chatflow` route. A successful response will include the chatflow output. #### 🔄 Workflow Endpoint To initiate a workflow, send a POST request to the workflow endpoint: -- **URL**: `/workflow/` +- **URL Without App**: `/workflow/` +- **URL With App**: `/single-workflow` - **Method**: `POST` -- **Headers**: +- **Headers**: - `Content-Type: application/json` - If using an API key: `X-API-Key: ` - **Body** (JSON): @@ -103,7 +105,7 @@ To initiate a workflow, send a POST request to the workflow endpoint: } ``` -The response will contain results from the workflow execution. +For endpoints configured with a specific Dify app, use the `/single-workflow` route. The response will contain results from the workflow execution. ### 🧩 Customization with Middlewares diff --git a/endpoints/ __init__.py b/endpoints/ __init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/endpoints/chatflow.py b/endpoints/chatflow.py deleted file mode 100644 index 40c83f8..0000000 --- a/endpoints/chatflow.py +++ /dev/null @@ -1,88 +0,0 @@ -import json -from typing import Mapping -from werkzeug import Request, Response -from dify_plugin import Endpoint -from endpoints.helpers import apply_middleware, validate_api_key - - -class ChatflowEndpoint(Endpoint): - """ - The ChatflowEndpoint is used to trigger a Dify chatflow via an HTTP request. - This endpoint interfaces with the Dify chatflow API, allowing you to execute - chatflows by providing necessary parameters. The request body should be JSON - formatted with the following fields: - - - `app_id` (required): The ID of the chatflow you intend to trigger. This field - is mandatory, and the request will return an error if it is missing. - - - `query` (required): A string representing the query to be processed. - - - `inputs` (optional): An object containing the inputs needed for the chatflow. - If provided, it must be a dictionary (object) type. If omitted, an empty - object will be assumed as default. - - - `conversation_id` (optional): A string representing the conversation ID. - - When a request is made, this endpoint validates the presence of `app_id` and - ensures `inputs` is either a dictionary or omitted. It also validates `query` - and `conversation_id` as strings if provided. It then invokes the specified - chatflow using the `app_id`, `query`, `conversation_id`, and `inputs`, and provides - a blocking response from the chatflow execution. - - On successful invocation, the endpoint will return a JSON response containing - the chatflow's output with a 200 status code. In case of validation failure or - JSON parsing errors, it returns an error message with a 400 status code. - """ - def _invoke(self, r: Request, values: Mapping, settings: Mapping) -> Response: - """ - Invokes the endpoint with the given request. - """ - middleware_response = apply_middleware(r, settings) - if middleware_response: - return middleware_response - validation_response = validate_api_key(r, settings) - if validation_response: - return validation_response - - try: - request_data = r.get_json() - app_id = values["app_id"] - - if not app_id: - return Response(json.dumps({"error": "app_id is required"}), - status=400, content_type="application/json") - - explicit_inputs = settings.get('explicit_inputs', True) - - if explicit_inputs: - inputs = request_data.get("inputs", {}) - if not isinstance(inputs, dict): - return Response(json.dumps({"error": "inputs must be an object"}), - status=400, content_type="application/json") - else: - inputs = request_data - if not isinstance(inputs, dict): - return Response(json.dumps({"error": "inputs must be an object"}), - status=400, content_type="application/json") - query = request_data.get("query") if explicit_inputs else inputs.pop("query") - if not query or not isinstance(query, str): - return Response(json.dumps({"error": "query must be a string"}), - status=400, content_type="application/json") - - conversation_id = request_data.get("conversation_id") if explicit_inputs else inputs.pop("conversation_id") - if conversation_id is not None and not isinstance(conversation_id, str): - return Response(json.dumps({"error": "conversation_id must be a string"}), - status=400, content_type="application/json") - - response = self.session.app.chat.invoke( - app_id=app_id, - query=query, - conversation_id=conversation_id, - inputs=inputs, - response_mode="blocking" - ) - - return Response(json.dumps(response), status=200, content_type="application/json") - - except (json.JSONDecodeError, KeyError, TypeError) as e: - return Response(json.dumps({"error": str(e)}), status=500, content_type="application/json") \ No newline at end of file diff --git a/endpoints/chatflow.yaml b/endpoints/dynamic_chatflow.yaml similarity index 57% rename from endpoints/chatflow.yaml rename to endpoints/dynamic_chatflow.yaml index ef79dd6..7d7e251 100644 --- a/endpoints/chatflow.yaml +++ b/endpoints/dynamic_chatflow.yaml @@ -2,4 +2,4 @@ path: "/chatflow/" method: "POST" extra: python: - source: "endpoints/chatflow.py" + source: "endpoints/invoke_endpoint.py" diff --git a/endpoints/workflow.yaml b/endpoints/dynamic_workflow.yaml similarity index 57% rename from endpoints/workflow.yaml rename to endpoints/dynamic_workflow.yaml index eb25135..cdc59ef 100644 --- a/endpoints/workflow.yaml +++ b/endpoints/dynamic_workflow.yaml @@ -2,4 +2,4 @@ path: "/workflow/" method: "POST" extra: python: - source: "endpoints/workflow.py" + source: "endpoints/invoke_endpoint.py" diff --git a/endpoints/helpers.py b/endpoints/helpers.py index f0d9fd9..c9628b2 100644 --- a/endpoints/helpers.py +++ b/endpoints/helpers.py @@ -1,5 +1,5 @@ import json -from typing import Mapping, Optional +from typing import Literal, Mapping, Optional from werkzeug import Request, Response from middlewares.discord_middleware import DiscordMiddleware from middlewares.default_middleware import DefaultMiddleware @@ -61,4 +61,26 @@ def validate_api_key(r: Request, settings: Mapping) -> Optional[Response]: return Response(json.dumps({"error": "Invalid API key"}), status=403, content_type="application/json") + return None + +EndpointRoute = Literal["/workflow/", "/chatflow/", "/single-workflow", "/single-chatflow"] + +def determine_route(path: str) -> Optional[EndpointRoute]: + """ + Determines the endpoint route based on the request path. + + Args: + path: The request path + + Returns: + The endpoint route as a string, or None if the path doesn't match + """ + if path.startswith("/workflow"): + return "/workflow/" + elif path.startswith("/chatflow"): + return "/chatflow/" + elif path.startswith("/single-workflow"): + return "/single-workflow" + elif path.startswith("/single-chatflow"): + return "/single-chatflow" return None \ No newline at end of file diff --git a/endpoints/invoke_endpoint.py b/endpoints/invoke_endpoint.py new file mode 100644 index 0000000..e6b174c --- /dev/null +++ b/endpoints/invoke_endpoint.py @@ -0,0 +1,207 @@ +import json +import logging +from typing import Mapping, Dict, Any, Optional +from werkzeug import Request, Response +from dify_plugin import Endpoint +from endpoints.helpers import apply_middleware, validate_api_key, determine_route + +logger = logging.getLogger(__name__) + +class WebhookEndpoint(Endpoint): + """ + The UnifiedEndpoint handles both workflow and chatflow requests through a single interface. + + This endpoint routes requests to the appropriate Dify API based on the path: + - Paths starting with /workflow/ will invoke Dify workflows + - Paths starting with /chatflow/ will invoke Dify chatflows + + For chatflow requests, the following parameters are required: + - `app_id` (required): The ID of the chatflow to trigger + - `query` (required): A string representing the query to be processed + - `inputs` (optional): An object containing inputs needed for the chatflow + - `conversation_id` (optional): A string representing the conversation ID + + For workflow requests, the following parameters are required: + - `app_id` (required): The ID of the workflow to trigger + - `inputs` (optional): An object containing inputs needed for the workflow + + The endpoint behavior can be configured with: + - `explicit_inputs`: When true, inputs should be in req.body.inputs. When false, req.body is used. + - `raw_data_output`: When true, workflow responses will only return the data.outputs + """ + + def _invoke(self, r: Request, values: Mapping, settings: Mapping) -> Response: + """ + Invokes the endpoint with the given request for either chatflow or workflow. + """ + logger.info("Received request to unified endpoint") + + # Determine the endpoint mode + route = determine_route(r.path) + if not route: + logger.error("Invalid path: %s", r.path) + return Response(json.dumps({"error": "Invalid path. Use /workflow/ or /chatflow/"}), + status=404, content_type="application/json") + + logger.info("Request mode: %s", route) + + # Apply middleware + middleware_response = apply_middleware(r, settings) + if middleware_response: + logger.debug("Middleware response: %s", middleware_response) + return middleware_response + + # Validate API key + validation_response = validate_api_key(r, settings) + if validation_response: + logger.debug("API key validation failed: %s", validation_response) + return validation_response + + try: + request_body = getattr( + r, 'default_middleware_json', {}) or r.get_json() + + dynamic_app_id = values.get("app_id") + static_app_id = settings.get("static_app_id") + if isinstance(static_app_id, dict): + static_app_id = static_app_id.get('app_id') + + logger.debug("Parsed request body: %s", request_body) + logger.debug("Extracted dynamic_app_id: %s", dynamic_app_id) + logger.debug("Extracted static_app_id: %s", static_app_id) + + if not dynamic_app_id and not static_app_id: + logger.error("app_id is required but not provided.") + return Response(status=404, content_type="application/json") + + # Handle inputs based on explicit_inputs setting + explicit_inputs = settings.get('explicit_inputs', True) + + if explicit_inputs: + inputs = request_body.get("inputs", {}) + else: + inputs = request_body.copy() + + if not isinstance(inputs, dict): + logger.error( + "Invalid inputs type: expected object, got %s", type(inputs).__name__) + return Response(json.dumps({"error": "inputs must be an object"}), + status=400, content_type="application/json") + + # initialize empty response + response = None + + if route == "/chatflow/": + if static_app_id: + # Do not handle requests to /chatflow/ when a static app_id is defined + # Static app_id is explicitly used to only expose one single app + return Response(status=404, content_type="application/json") + + query = request_body.get( + "query", None) if explicit_inputs else inputs.pop("query", None) + if not query or not isinstance(query, str): + logger.error("query is required and must be a string") + return Response(json.dumps({"error": "query must be a string"}), + status=400, content_type="application/json") + + conversation_id = request_body.get( + "conversation_id") if explicit_inputs else inputs.pop("conversation_id", None) + if conversation_id is not None and not isinstance(conversation_id, str): + logger.error( + "conversation_id must be a string if provided") + return Response(json.dumps({"error": "conversation_id must be a string"}), + status=400, content_type="application/json") + + # Invoke chatflow + response = self._invoke_chatflow( + dynamic_app_id, query, conversation_id, inputs) + elif route == "/single-chatflow": + query = request_body.get( + "query") if explicit_inputs else inputs.pop("query", None) + if not query or not isinstance(query, str): + logger.error("query is required and must be a string") + return Response(json.dumps({"error": "query must be a string"}), + status=400, content_type="application/json") + + conversation_id = request_body.get( + "conversation_id") if explicit_inputs else inputs.pop("conversation_id", None) + if conversation_id is not None and not isinstance(conversation_id, str): + logger.error( + "conversation_id must be a string if provided") + return Response(json.dumps({"error": "conversation_id must be a string"}), + status=400, content_type="application/json") + + # Invoke chatflow + response = self._invoke_chatflow( + static_app_id, query, conversation_id, inputs) + + elif route == "/workflow/": + if static_app_id: + # Do not handle requests to /chatflow/ when a static app_id is defined + # Static app_id is explicitly used to only expose one single app + return Response(status=404, content_type="application/json") + # Invoking workflow + response = self._invoke_workflow( + dynamic_app_id, inputs, settings.get('raw_data_output', False)) + + elif route == "/single-workflow": + # Invoking workflow + response = self._invoke_workflow( + static_app_id, inputs, settings.get('raw_data_output', False)) + + if not response: + return Response(json.dumps({"error": "Failed to get response"}), status=500, content_type="application/json") + else: + # Return response + logger.debug("%s response: %s", route, response) + return Response(json.dumps(response), status=200, content_type="application/json") + + except (json.JSONDecodeError, KeyError, TypeError) as e: + logger.error("Error during request processing: %s", str(e)) + return Response(json.dumps({"error": str(e)}), status=500, content_type="application/json") + + def _invoke_chatflow(self, app_id: str, query: str, conversation_id: Optional[str], inputs: Dict[str, Any]) -> Dict[str, Any]: + """ + Invokes a Dify chatflow with the given parameters. + + Args: + app_id: The ID of the chatflow to invoke + query: The user query to process + conversation_id: Optional conversation ID for continuing a conversation + inputs: Additional inputs for the chatflow + + Returns: + The chatflow response + """ + logger.info("Invoking chatflow with app_id: %s", app_id) + dify_response = self.session.app.chat.invoke( + app_id=app_id, + query=query, + conversation_id=conversation_id, + inputs=inputs, + response_mode="blocking" + ) + return dify_response + + def _invoke_workflow(self, app_id: str, inputs: Dict[str, Any], raw_data_output: bool) -> Dict[str, Any]: + """ + Invokes a Dify workflow with the given parameters. + + Args: + app_id: The ID of the workflow to invoke + inputs: Inputs for the workflow + raw_data_output: If True, returns only the outputs field of the response + + Returns: + The workflow response, either full or just the outputs depending on raw_data_output + """ + logger.info( + "Invoking workflow with app_id: %s and inputs: %s", app_id, inputs) + dify_response = self.session.app.workflow.invoke( + app_id=app_id, + inputs=inputs, + response_mode="blocking" + ) + + # Process workflow response if raw_data_output is enabled + return dify_response["data"]["outputs"] if raw_data_output else dify_response diff --git a/endpoints/static_chatflow.yaml b/endpoints/static_chatflow.yaml new file mode 100644 index 0000000..7cf60c8 --- /dev/null +++ b/endpoints/static_chatflow.yaml @@ -0,0 +1,5 @@ +path: "/single-chatflow" +method: "POST" +extra: + python: + source: "endpoints/invoke_endpoint.py" diff --git a/endpoints/static_workflow.yaml b/endpoints/static_workflow.yaml new file mode 100644 index 0000000..1d2dc4e --- /dev/null +++ b/endpoints/static_workflow.yaml @@ -0,0 +1,5 @@ +path: "/single-workflow" +method: "POST" +extra: + python: + source: "endpoints/invoke_endpoint.py" diff --git a/endpoints/workflow.py b/endpoints/workflow.py deleted file mode 100644 index 250f4b8..0000000 --- a/endpoints/workflow.py +++ /dev/null @@ -1,95 +0,0 @@ -import json -import logging -from typing import Mapping -from werkzeug import Request, Response -from dify_plugin import Endpoint -from endpoints.helpers import apply_middleware, validate_api_key - -logger = logging.getLogger(__name__) - -class WorkflowEndpoint(Endpoint): - """ - The WorkflowEndpoint is used to trigger a Dify workflow via an HTTP request. - - This endpoint interfaces with the Dify workflow API, allowing you to execute - workflows by providing necessary parameters. The request body should be JSON - formatted with the following fields: - - - `app_id` (required): The ID of the workflow you intend to trigger. This field - is mandatory, and the request will return an error if it is missing. - - - `inputs` (optional): An object containing the inputs needed for the workflow. - If provided, it must be a dictionary (object) type. If omitted, an empty - object will be assumed as default. - - When a request is made, this endpoint validates the presence of `app_id` and - ensures `inputs` is either a dictionary or omitted. It then invokes the - specified workflow using the `app_id` and `inputs`, and provides a blocking - response from the workflow execution. - - On successful invocation, the endpoint will return a JSON response containing - the workflow's output with a 200 status code. In case of validation failure or - JSON parsing errors, it returns an error message with a 400 status code. - """ - - def _invoke(self, r: Request, values: Mapping, settings: Mapping) -> Response: - """ - Invokes the endpoint with the given request. - """ - logger.info("Received request to invoke workflow.") - - middleware_response = apply_middleware(r, settings) - if middleware_response: - logger.debug("Middleware response: %s", middleware_response) - return middleware_response - - logger.debug("No middleware response") - - validation_response = validate_api_key(r, settings) - if validation_response: - logger.debug("API key validation failed: %s", validation_response) - return validation_response - - try: - request_data = getattr(r, 'default_middleware_json', None) or r.get_json() - app_id = values["app_id"] - logger.debug("Parsed request data: %s", request_data) - logger.debug("Extracted app_id: %s", app_id) - - if not app_id: - logger.error("app_id is required but not provided.") - return Response(json.dumps({"error": "app_id is required"}), - status=400, content_type="application/json") - - explicit_inputs = settings.get('explicit_inputs', True) - - if explicit_inputs: - inputs = request_data.get("inputs", {}) - if not isinstance(inputs, dict): - logger.error( - "Invalid inputs type: expected object, got %s", type(inputs).__name__) - return Response(json.dumps({"error": "inputs must be an object"}), - status=400, content_type="application/json") - else: - inputs = request_data - if not isinstance(inputs, dict): - logger.error( - "Invalid inputs type: expected object, got %s", type(inputs).__name__) - return Response(json.dumps({"error": "inputs must be an object"}), - status=400, content_type="application/json") - - logger.info( - "Invoking workflow with app_id: %s and inputs: %s", app_id, inputs) - dify_response = self.session.app.workflow.invoke( - app_id=app_id, inputs=inputs, response_mode="blocking" - ) - - response = dify_response["data"]["outputs"] if settings.get( - 'raw_data_output', False) else dify_response - - logger.debug("Workflow response: %s", response) - return Response(json.dumps(response), status=200, content_type="application/json") - - except (json.JSONDecodeError, KeyError, TypeError) as e: - logger.error("Error during request processing: %s", str(e)) - return Response(json.dumps({"error": str(e)}), status=500, content_type="application/json") diff --git a/group/webhook.yaml b/group/webhook.yaml index 859313d..e713422 100644 --- a/group/webhook.yaml +++ b/group/webhook.yaml @@ -1,4 +1,16 @@ settings: + - name: static_app_id + type: app-selector + required: false + label: + en_US: App (Disables the routes) + zh_Hans: 应用程序 (禁用路由) + pt_BR: Aplicativo (Desativa as rotas de ) + placeholder: + en_US: Select a specific app to expose, instead of exposing all workspace apps + zh_Hans: 选择要公开的特定应用,而不是公开所有工作区应用 + pt_BR: Selecione um aplicativo específico para expor, em vez de expor todos os aplicativos do espaço de trabalho + - name: api_key type: secret-input required: false @@ -38,17 +50,6 @@ settings: en_US: Please select the API key location zh_Hans: 请选择 API 密钥位置 pt_BR: Por favor, selecione a localização da chave de API - - name: signature_verification_key - type: secret-input - required: false - label: - en_US: Signature Verification Public Key - zh_Hans: 签名验证公钥 - pt_BR: Chave Pública de Verificação de Assinatura - placeholder: - en_US: Please input your public key for signature verification - zh_Hans: 请输入您的签名验证公钥 - pt_BR: Por favor, insira sua chave pública para verificação de assinatura - name: middleware type: select @@ -72,7 +73,18 @@ settings: placeholder: en_US: Custom middlewares can add logic like signature validation zh_Hans: 自定义中间件可以添加诸如签名验证的逻辑 - pt_BR: Middlewares personalizados podem adicionar lógica como validação de assinatura + pt_BR: Middlewares personalizados podem adicionar lógica como validação de assinatura + - name: signature_verification_key + type: secret-input + required: false + label: + en_US: Signature Verification Public Key + zh_Hans: 签名验证公钥 + pt_BR: Chave Pública de Verificação de Assinatura + placeholder: + en_US: Please input your public key for signature verification + zh_Hans: 请输入您的签名验证公钥 + pt_BR: Por favor, insira sua chave pública para verificação de assinatura - name: explicit_inputs type: boolean @@ -86,35 +98,28 @@ settings: en_US: Use req.body.inputs instead of req.body as the inputs object zh_Hans: 使用 req.body.inputs 代替 req.body 作为输入对象 pt_BR: Usar req.body.inputs em vez de req.body como objeto de entradas - placeholder: - en_US: Please enter your API key - zh_Hans: 请输入您的 API 密钥 - pt_BR: Por favor, insira sua chave de API + + - name: json_string_input type: boolean required: false default: false label: - en_US: Set this to true when you want to receive the req.body as a JSON string. This is recommended when using large request payloads. - zh_Hans: 将此设置为true, 当您希望将req.body作为JSON字符串接收时。建议在使用大型请求负载时使用此选项。 - pt_BR: Defina isso como verdadeiro quando você quiser receber o req.body como uma string JSON. Isso é recomendado ao usar cargas úteis de requisição grandes. - placeholder: - en_US: Please enter your API key - zh_Hans: 请输入您的API密钥 - pt_BR: Por favor, insira sua chave de API + en_US: Transform req.body to req.body.json_string as JSON string. + zh_Hans: 将req.body转换为req.body.json_string作为JSON字符串。 + pt_BR: Transforme req.body em req.body.json_string como string JSON. + - name: raw_data_output type: boolean required: false default: false label: - en_US: Set this to true when you want to receive the response.body.data as is, without Dify metadata. This only applies to workflows. - zh_Hans: 当您希望接收原始的 response.body.data(无 Dify 元数据)时,将此项设置为 true。这仅适用于工作流。 - pt_BR: Defina isso como verdadeiro quando você quiser receber o response.body.data sem os metadados do Dify. Isso se aplica apenas a fluxos de trabalho. - placeholder: - en_US: Please enter your API key - zh_Hans: 请输入您的API密钥 - pt_BR: Por favor, insira sua chave de API + en_US: Send res.body.data instead of res.body.data as workflow response. + zh_Hans: 当您希望接收 res.body.data 作为工作流响应时,将此项设置为 true。 + pt_BR: Envie res.body.data como resposta de fluxo de trabalho em vez de res.body.data. endpoints: - - endpoints/workflow.yaml - - endpoints/chatflow.yaml \ No newline at end of file + - endpoints/dynamic_workflow.yaml + - endpoints/dynamic_chatflow.yaml + - endpoints/static_chatflow.yaml + - endpoints/static_workflow.yaml \ No newline at end of file diff --git a/manifest.yaml b/manifest.yaml index ab1cab0..dedafba 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -1,4 +1,4 @@ -version: 0.4.2 +version: 0.5.0 type: plugin author: perzeuss name: webhook @@ -22,7 +22,7 @@ plugins: endpoints: - group/webhook.yaml meta: - version: 0.4.2 + version: 0.5.0 arch: - amd64 - arm64 diff --git a/middlewares/__init__.py b/middlewares/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/endpoints/test_chatflow.py b/tests/endpoints/test_chatflow.py deleted file mode 100644 index 3a9d45f..0000000 --- a/tests/endpoints/test_chatflow.py +++ /dev/null @@ -1,331 +0,0 @@ -# pylint: disable=W0212 - -import json -import unittest -from unittest.mock import Mock, patch -from werkzeug import Request, Response -from dify_plugin.core.runtime import Session -from endpoints.chatflow import ChatflowEndpoint - -class TestChatflowEndpoint(unittest.TestCase): - def setUp(self): - # Create a mock session - self.mock_session = Mock(spec=Session) - self.mock_session.app = Mock() - self.mock_session.app.chat = Mock() - - # Create the endpoint with the mock session - self.endpoint = ChatflowEndpoint(session=self.mock_session) - - # Create a mock request - self.mock_request = Mock(spec=Request) - self.mock_request.get_json = Mock(return_value={}) - - # Default chatflow response - self.chatflow_response = { - "data": {"result": "Chatflow response"} - } - self.mock_session.app.chat.invoke.return_value = self.chatflow_response - - # Default settings - self.default_settings = { - "explicit_inputs": True, - "api_key_required": False - } - - @patch('endpoints.chatflow.apply_middleware') - @patch('endpoints.chatflow.validate_api_key') - def test_successful_invocation(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that a chatflow is successfully invoked when valid inputs are provided. - Checks that the chatflow is called with the correct parameters and a successful response is returned. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - self.mock_request.get_json.return_value = { - "app_id": "test-app-id", - "query": "What is the weather?", - "inputs": {"param1": "value1"} - } - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert chatflow was invoked with correct parameters - self.mock_session.app.chat.invoke.assert_called_once_with( - app_id="test-app-id", - query="What is the weather?", - conversation_id=None, - inputs={"param1": "value1"}, - response_mode="blocking" - ) - - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(json.loads(response.data), self.chatflow_response) - - @patch('endpoints.chatflow.apply_middleware') - @patch('endpoints.chatflow.validate_api_key') - def test_missing_app_id(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that an error is returned when the 'app_id' is missing from the request parameters. - Verifies that the requirement for 'app_id' is enforced. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - self.mock_request.get_json.return_value = { - "query": "What is the weather?" - } - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": ""}, # Empty app_id - self.default_settings - ) - - # Assert error response - self.assertEqual(response.status_code, 400) - self.assertEqual( - json.loads(response.data), - {"error": "app_id is required"} - ) - - # Assert chatflow was not invoked - self.mock_session.app.chat.invoke.assert_not_called() - - @patch('endpoints.chatflow.apply_middleware') - @patch('endpoints.chatflow.validate_api_key') - def test_missing_query(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that an error is returned when the 'query' is missing from the request. - Ensures that the 'query' parameter is mandatory for successful chatflow invocation. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - self.mock_request.get_json.return_value = { - "app_id": "test-app-id", - "inputs": {} - } - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert error response - self.assertEqual(response.status_code, 400) - self.assertEqual( - json.loads(response.data), - {"error": "query must be a string"} - ) - - # Assert chatflow was not invoked - self.mock_session.app.chat.invoke.assert_not_called() - - @patch('endpoints.chatflow.apply_middleware') - @patch('endpoints.chatflow.validate_api_key') - def test_inputs_not_dictionary(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that an error is returned when 'inputs' is not a dictionary object as expected. - Ensures that any incorrect input format is rejected. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - self.mock_request.get_json.return_value = { - "app_id": "test-app-id", - "query": "What is the weather?", - "inputs": "not a dictionary" # Invalid inputs - } - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert error response - self.assertEqual(response.status_code, 400) - self.assertEqual( - json.loads(response.data), - {"error": "inputs must be an object"} - ) - - # Assert chatflow was not invoked - self.mock_session.app.chat.invoke.assert_not_called() - - @patch('endpoints.chatflow.apply_middleware') - @patch('endpoints.chatflow.validate_api_key') - def test_successful_invocation_no_inputs(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that a chatflow is successfully invoked with empty inputs when no inputs are provided in the request. - Verifies that the chatflow is called with an empty inputs dictionary. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - self.mock_request.get_json.return_value = { - "app_id": "test-app-id", - "query": "What is the weather?" - } - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert chatflow was invoked with empty inputs - self.mock_session.app.chat.invoke.assert_called_once_with( - app_id="test-app-id", - query="What is the weather?", - conversation_id=None, - inputs={}, - response_mode="blocking" - ) - - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(json.loads(response.data), self.chatflow_response) - - @patch('endpoints.chatflow.apply_middleware') - @patch('endpoints.chatflow.validate_api_key') - def test_json_parsing_fails(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that an error response is returned when JSON parsing of the request fails. - This ensures the endpoint can detect and handle malformed request payloads. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - self.mock_request.get_json.side_effect = json.JSONDecodeError("Invalid JSON", "doc", 0) - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert error response - self.assertEqual(response.status_code, 500) - self.assertIn("Invalid JSON", json.loads(response.data)["error"]) - - @patch('endpoints.chatflow.apply_middleware') - @patch('endpoints.chatflow.validate_api_key') - def test_invalid_conversation_id(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that an error is returned when 'conversation_id' is not a string. - Ensures that the conversation_id parameter is correctly validated before invocation. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - self.mock_request.get_json.return_value = { - "app_id": "test-app-id", - "query": "What is the weather?", - "inputs": {}, - "conversation_id": 100 # Invalid conversation_id - } - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert error response - self.assertEqual(response.status_code, 400) - self.assertEqual( - json.loads(response.data), - {"error": "conversation_id must be a string"} - ) - - @patch('endpoints.chatflow.apply_middleware') - @patch('endpoints.chatflow.validate_api_key') - def test_api_key_validation_fails(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that a request is blocked due to failing API key validation. - This ensures that the API key requirement is enforced correctly. - """ - mock_apply_middleware.return_value = None - - # Return a response from API key validation, indicating validation failed - validation_response = Response( - json.dumps({"error": "Invalid API key"}), - status=401, - content_type="application/json" - ) - mock_validate_api_key.return_value = validation_response - - self.mock_request.get_json.return_value = { - "app_id": "test-app-id", - "query": "What is the weather?" - } - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert validation response is returned - self.assertEqual(response.status_code, 401) - self.assertEqual( - json.loads(response.data), - {"error": "Invalid API key"} - ) - - # Assert chatflow was not invoked - self.mock_session.app.chat.invoke.assert_not_called() - - @patch('endpoints.chatflow.apply_middleware') - @patch('endpoints.chatflow.validate_api_key') - def correct_input_with_explicit_inputs_false(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that the entire request body is used as inputs when 'explicit_inputs' is set to False. - Ensures the chatflow is correctly invoked with the non-wrapped request data. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - # Set explicit_inputs to False in the settings - settings_with_no_explicit_inputs = { - # The entire req.body should be passed as inputs dict to the chatflow - "explicit_inputs": False, - "api_key_required": False - } - - self.mock_request.get_json.return_value = { - "query": "What is the weather?", - "conversation_id": "123", - "some_param": "foo" - } - - self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - settings_with_no_explicit_inputs - ) - - expected_inputs = { - "some_param": "foo" - } - - # Assert chatflow was invoked with correct parameters including inputs - self.mock_session.app.chat.invoke.assert_called_with( - app_id="test-app-id", - query="What is the weather?", - conversation_id="123", - inputs=expected_inputs, - response_mode="blocking" - ) - - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/tests/endpoints/test_invoke_webhook.py b/tests/endpoints/test_invoke_webhook.py new file mode 100644 index 0000000..bc00a45 --- /dev/null +++ b/tests/endpoints/test_invoke_webhook.py @@ -0,0 +1,758 @@ +# pylint: disable=W0212 + +import json +import unittest +from unittest.mock import Mock, patch +from werkzeug import Request, Response +from dify_plugin.core.runtime import Session +from endpoints.invoke_endpoint import WebhookEndpoint + +class TestWebhookEndpoint(unittest.TestCase): + def setUp(self): + # Create a mock session + self.mock_session = Mock(spec=Session) + self.mock_session.app = Mock() + self.mock_session.app.workflow = Mock() + self.mock_session.app.chat = Mock() + + # Create the endpoint with the mock session + self.endpoint = WebhookEndpoint(session=self.mock_session) + + # Create a mock request + self.mock_request = Mock(spec=Request) + self.mock_request.get_json = Mock(return_value={}) + self.mock_request.headers = {} + self.mock_request.default_middleware_json = None + + # Set default path to empty string + self.mock_request.path = "" + + # Default workflow response + self.workflow_response = { + "data": {"outputs": {"result": "Test workflow output"}} + } + self.mock_session.app.workflow.invoke.return_value = self.workflow_response + + # Default chatflow response + self.chatflow_response = {"data": {"result": "Chatflow response"}} + self.mock_session.app.chat.invoke.return_value = self.chatflow_response + + # Default settings + self.default_settings = { + "explicit_inputs": True, + "raw_data_output": False, + "api_key_required": False, + "static_app_id": "static-app-id" # Use static_app_id instead of app_id + } + + # Reset the mock invocations after each test + def tearDown(self): + self.mock_session.app.workflow.invoke.reset_mock() + self.mock_session.app.chat.invoke.reset_mock() + + # REQUEST PREPROCESSING TESTS + + @patch('endpoints.invoke_endpoint.apply_middleware') + def test_middleware_blocks_request(self, mock_apply_middleware): + """Tests apply_middleware function when it blocks a request. + Ensures that requests are properly blocked when middleware returns a response.""" + # Return a response from middleware, indicating request is blocked + middleware_response = Response( + json.dumps({"error": "Blocked by middleware"}), + status=403, + content_type="application/json" + ) + mock_apply_middleware.return_value = middleware_response + + self.mock_request.path = "/single-workflow" + + response = self.endpoint._invoke( + self.mock_request, {}, self.default_settings) + + # Assert middleware response is returned + self.assertEqual(response.status_code, 403) + self.assertEqual( + json.loads(response.data), + {"error": "Blocked by middleware"} + ) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_api_key_validation_fails(self, mock_validate_api_key, mock_apply_middleware): + """Tests API key validation when validation fails. + Ensures requests with invalid API keys are properly rejected.""" + mock_apply_middleware.return_value = None + + # Return a response from API key validation, indicating validation failed + validation_response = Response( + json.dumps({"error": "Invalid API key"}), + status=401, + content_type="application/json" + ) + mock_validate_api_key.return_value = validation_response + + self.mock_request.path = "/single-chatflow" + self.default_settings["api_key_required"] = True + + response = self.endpoint._invoke( + self.mock_request, {}, self.default_settings) + + # Assert validation response is returned + self.assertEqual(response.status_code, 401) + self.assertEqual( + json.loads(response.data), + {"error": "Invalid API key"} + ) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_json_parsing_fails(self, mock_validate_api_key, mock_apply_middleware): + """Tests JSON parsing error handling. + Ensures proper error response when request JSON is invalid.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + # Mock both request.get_json and request.default_middleware_json + self.mock_request.get_json.side_effect = json.JSONDecodeError( + "Invalid JSON", "doc", 0) + self.mock_request.default_middleware_json = None + + self.mock_request.path = "/single-workflow" + + response = self.endpoint._invoke( + self.mock_request, {}, self.default_settings) + + # Assert error response + self.assertEqual(response.status_code, 500) + self.assertIn("Invalid JSON", json.loads(response.data)["error"]) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_default_middleware_json_used(self, mock_validate_api_key, mock_apply_middleware): + """Tests usage of default_middleware_json. + Ensures middleware-provided JSON is used instead of request body.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + # Set middleware_json which should be used instead of get_json + self.mock_request.default_middleware_json = { + "inputs": {"from_middleware": "middleware value"} + } + + # Ensure request.get_json won't be called + self.mock_request.get_json.side_effect = Exception("Should not be called") + + self.mock_request.path = "/single-workflow" + + response = self.endpoint._invoke( + self.mock_request, {}, self.default_settings) + + # Assert workflow was invoked with middleware json + self.mock_session.app.workflow.invoke.assert_called_once_with( + app_id="static-app-id", + inputs={"from_middleware": "middleware value"}, + response_mode="blocking" + ) + + # Assert response + self.assertEqual(response.status_code, 200) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_middleware_json_used_for_all_routes(self, mock_validate_api_key, mock_apply_middleware): + """Tests middleware JSON usage across all routes. + Ensures middleware-provided JSON is consistently used for all endpoint routes.""" + routes_to_test = [ + ("/single-workflow", {}, self.default_settings), + ("/single-chatflow", {}, self.default_settings), + ("/workflow/test-app-id", {"app_id": "test-app-id"}, dict(self.default_settings, **{"static_app_id": None})), + ("/chatflow/test-app-id", {"app_id": "test-app-id"}, dict(self.default_settings, **{"static_app_id": None})) + ] + + for path, values, settings in routes_to_test: + # Reset mocks + self.mock_session.app.workflow.invoke.reset_mock() + self.mock_session.app.chat.invoke.reset_mock() + mock_apply_middleware.reset_mock() + mock_validate_api_key.reset_mock() + + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.path = path + + # Set middleware JSON + if path.startswith("/workflow") or path == "/single-workflow": + middleware_json = {"inputs": {"from_middleware": "middleware value"}} + else: # chatflow routes + middleware_json = {"query": "Middleware query", "inputs": {}} + + self.mock_request.default_middleware_json = middleware_json + + # Make get_json fail to ensure it's not used + self.mock_request.get_json.side_effect = Exception("Should not be called") + + response = self.endpoint._invoke(self.mock_request, values, settings) + + # Assert successful response + self.assertEqual(response.status_code, 200) + + # Assert correct invocation based on route + if path.startswith("/workflow") or path == "/single-workflow": + app_id = "test-app-id" if path.startswith("/workflow") else "static-app-id" + self.mock_session.app.workflow.invoke.assert_called_once() + call_kwargs = self.mock_session.app.workflow.invoke.call_args[1] + self.assertEqual(call_kwargs["app_id"], app_id) + self.assertEqual(call_kwargs["inputs"], {"from_middleware": "middleware value"}) + else: # chatflow routes + app_id = "test-app-id" if path.startswith("/chatflow") else "static-app-id" + self.mock_session.app.chat.invoke.assert_called_once() + call_kwargs = self.mock_session.app.chat.invoke.call_args[1] + self.assertEqual(call_kwargs["app_id"], app_id) + self.assertEqual(call_kwargs["query"], "Middleware query") + + # SINGLE WORKFLOW TESTS + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_successful_invocation_single_workflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests successful invocation of /single-workflow endpoint. + Ensures the endpoint correctly processes requests with static app_id.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = {"inputs": {"param1": "value1"}} + self.mock_request.path = "/single-workflow" + + response = self.endpoint._invoke( + self.mock_request, {}, self.default_settings) + + # Assert workflow was invoked with correct parameters + self.mock_session.app.workflow.invoke.assert_called_once_with( + app_id="static-app-id", + inputs={"param1": "value1"}, + response_mode="blocking" + ) + + # Assert response + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.data), self.workflow_response) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_missing_static_app_id_workflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests /single-workflow endpoint with missing static app_id. + Ensures proper error handling when configuration is incomplete.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = {"inputs": {}} + self.mock_request.path = "/single-workflow" + + # Remove static_app_id from settings + settings_without_app_id = dict(self.default_settings) + settings_without_app_id.pop("static_app_id") + + response = self.endpoint._invoke( + self.mock_request, {}, settings_without_app_id) + + # Assert error response + self.assertEqual(response.status_code, 404) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_inputs_not_dictionary_single_workflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests /single-workflow with invalid inputs format. + Ensures proper validation of inputs parameter type.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = {"inputs": "not a dictionary"} + self.mock_request.path = "/single-workflow" + + response = self.endpoint._invoke( + self.mock_request, {}, self.default_settings) + + # Assert error response + self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.data), {"error": "inputs must be an object"}) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_raw_data_output_single_workflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests /single-workflow with raw_data_output=True. + Ensures only the outputs object is returned when raw output is enabled.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = {"inputs": {"param1": "value1"}} + self.mock_request.path = "/single-workflow" + + settings_with_raw_output = dict(self.default_settings) + settings_with_raw_output["raw_data_output"] = True + + response = self.endpoint._invoke( + self.mock_request, {}, settings_with_raw_output) + + # Assert only outputs are returned (raw data output) + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.data), self.workflow_response["data"]["outputs"]) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_explicit_inputs_false_single_workflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests /single-workflow with explicit_inputs=False. + Ensures the entire request body is used as inputs when explicit_inputs is disabled.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = {"param1": "value1", "param2": "value2"} + self.mock_request.path = "/single-workflow" + + settings_no_explicit_inputs = dict(self.default_settings) + settings_no_explicit_inputs["explicit_inputs"] = False + + response = self.endpoint._invoke( + self.mock_request, {}, settings_no_explicit_inputs) + + # Assert entire request body was used as inputs + self.mock_session.app.workflow.invoke.assert_called_once_with( + app_id="static-app-id", + inputs={"param1": "value1", "param2": "value2"}, + response_mode="blocking" + ) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_workflow_invocation_exception_single_workflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests exception handling in /single-workflow endpoint. + Ensures exceptions from workflow invocation are properly propagated.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = {"inputs": {}} + self.mock_request.path = "/single-workflow" + + # Make workflow.invoke raise an exception + self.mock_session.app.workflow.invoke.side_effect = Exception("Workflow error") + + with self.assertRaises(Exception) as context: + self.endpoint._invoke(self.mock_request, {}, self.default_settings) + + # Assert the exception message + self.assertEqual(str(context.exception), "Workflow error") + + # SINGLE CHATFLOW TESTS + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_successful_invocation_single_chatflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests successful invocation of /single-chatflow endpoint. + Ensures the endpoint correctly processes chat requests with static app_id.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = { + "query": "What is the weather?", + "inputs": {} + } + self.mock_request.path = "/single-chatflow" + + response = self.endpoint._invoke( + self.mock_request, {}, self.default_settings) + + # Assert chatflow was invoked with correct parameters + self.mock_session.app.chat.invoke.assert_called_once_with( + app_id="static-app-id", + query="What is the weather?", + conversation_id=None, + inputs={}, + response_mode="blocking" + ) + + # Assert response + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.data), self.chatflow_response) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_missing_static_app_id_chatflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests /single-chatflow endpoint with missing static app_id. + Ensures proper error handling when configuration is incomplete.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = { + "query": "What is the weather?", + "inputs": {} + } + self.mock_request.path = "/single-chatflow" + + # Remove static_app_id from settings + settings_without_app_id = dict(self.default_settings) + settings_without_app_id.pop("static_app_id") + + response = self.endpoint._invoke( + self.mock_request, {}, settings_without_app_id) + + # Assert error response + self.assertEqual(response.status_code, 404) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_missing_query_single_chatflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests /single-chatflow with missing query parameter. + Ensures proper validation of required query parameter.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = {"inputs": {}} + self.mock_request.path = "/single-chatflow" + + response = self.endpoint._invoke( + self.mock_request, {}, self.default_settings) + + # Assert error response + self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.data), {"error": "query must be a string"}) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_non_string_query_single_chatflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests /single-chatflow with non-string query parameter. + Ensures proper validation of query parameter type.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = { + "query": 123, # Not a string + "inputs": {} + } + self.mock_request.path = "/single-chatflow" + + response = self.endpoint._invoke( + self.mock_request, {}, self.default_settings) + + # Assert error response + self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.data), {"error": "query must be a string"}) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_explicit_inputs_false_single_chatflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests /single-chatflow with explicit_inputs=False. + Ensures all additional parameters are used as inputs with explicit_inputs disabled.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = { + "query": "What is the weather?", + "conversation_id": "123", + "param1": "value1" + } + self.mock_request.path = "/single-chatflow" + + settings_no_explicit_inputs = dict(self.default_settings) + settings_no_explicit_inputs["explicit_inputs"] = False + + response = self.endpoint._invoke( + self.mock_request, {}, settings_no_explicit_inputs) + + # Assert chatflow was invoked with correct parameters + self.mock_session.app.chat.invoke.assert_called_once_with( + app_id="static-app-id", + query="What is the weather?", + conversation_id="123", + inputs={"param1": "value1"}, + response_mode="blocking" + ) + + # DYNAMIC WORKFLOW TESTS + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_successful_invocation_workflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests successful invocation of /workflow/ endpoint. + Ensures the endpoint correctly processes requests with dynamic app_id.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = {"inputs": {"param1": "value1"}} + self.mock_request.path = "/workflow/test-app-id" + + # Remove static_app_id to allow dynamic app_id routes + settings_without_static_app_id = dict(self.default_settings) + settings_without_static_app_id.pop("static_app_id") + + response = self.endpoint._invoke( + self.mock_request, {"app_id": "test-app-id"}, settings_without_static_app_id) + + # Assert workflow was invoked with correct parameters + self.mock_session.app.workflow.invoke.assert_called_once_with( + app_id="test-app-id", + inputs={"param1": "value1"}, + response_mode="blocking" + ) + + # Assert response + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.data), self.workflow_response) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_inputs_not_dictionary_workflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests /workflow/ with invalid inputs format. + Ensures proper validation of inputs parameter type.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = {"inputs": "not a dictionary"} + self.mock_request.path = "/workflow/test-app-id" + + # Remove static_app_id to allow dynamic app_id routes + settings_without_static_app_id = dict(self.default_settings) + settings_without_static_app_id.pop("static_app_id") + + response = self.endpoint._invoke( + self.mock_request, {"app_id": "test-app-id"}, settings_without_static_app_id) + + # Assert error response + self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.data), {"error": "inputs must be an object"}) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_missing_app_id_workflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests /workflow/ with missing app_id parameter. + Ensures proper error handling when app_id is not provided.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.path = "/workflow/" + + # Remove static_app_id to allow dynamic app_id routes + settings_without_static_app_id = dict(self.default_settings) + settings_without_static_app_id.pop("static_app_id") + + response = self.endpoint._invoke( + self.mock_request, {"app_id": ""}, settings_without_static_app_id) + + # Assert error response + self.assertEqual(response.status_code, 404) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_raw_data_output_workflow_dynamic(self, mock_validate_api_key, mock_apply_middleware): + """Tests /workflow/ with raw_data_output=True. + Ensures only the outputs object is returned when raw output is enabled.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = {"inputs": {"param1": "value1"}} + self.mock_request.path = "/workflow/test-app-id" + + # Remove static_app_id to allow dynamic app_id routes + settings_without_static_app_id = dict(self.default_settings) + settings_without_static_app_id.pop("static_app_id") + settings_without_static_app_id["raw_data_output"] = True + + response = self.endpoint._invoke( + self.mock_request, {"app_id": "test-app-id"}, settings_without_static_app_id) + + # Assert only outputs are returned (raw data output) + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.data), self.workflow_response["data"]["outputs"]) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_explicit_inputs_false_workflow_dynamic(self, mock_validate_api_key, mock_apply_middleware): + """Tests /workflow/ with explicit_inputs=False. + Ensures the entire request body is used as inputs when explicit_inputs is disabled.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = {"param1": "value1", "param2": "value2"} + self.mock_request.path = "/workflow/test-app-id" + + # Remove static_app_id to allow dynamic app_id routes + settings_without_static_app_id = dict(self.default_settings) + settings_without_static_app_id.pop("static_app_id") + settings_without_static_app_id["explicit_inputs"] = False + + response = self.endpoint._invoke( + self.mock_request, {"app_id": "test-app-id"}, settings_without_static_app_id) + + # Assert entire request body was used as inputs + self.mock_session.app.workflow.invoke.assert_called_once_with( + app_id="test-app-id", + inputs={"param1": "value1", "param2": "value2"}, + response_mode="blocking" + ) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_workflow_invocation_exception_workflow_dynamic(self, mock_validate_api_key, mock_apply_middleware): + """Tests exception handling in /workflow/ endpoint. + Ensures exceptions from workflow invocation are properly propagated.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = {"inputs": {}} + self.mock_request.path = "/workflow/test-app-id" + + # Remove static_app_id to allow dynamic app_id routes + settings_without_static_app_id = dict(self.default_settings) + settings_without_static_app_id.pop("static_app_id") + + # Make workflow.invoke raise an exception + self.mock_session.app.workflow.invoke.side_effect = Exception("Workflow error") + + with self.assertRaises(Exception) as context: + self.endpoint._invoke(self.mock_request, {"app_id": "test-app-id"}, settings_without_static_app_id) + + # Assert the exception message + self.assertEqual(str(context.exception), "Workflow error") + + # DYNAMIC CHATFLOW TESTS + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_successful_invocation_chatflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests successful invocation of /chatflow/ endpoint. + Ensures the endpoint correctly processes chat requests with dynamic app_id.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = { + "query": "What is the weather?", + "inputs": {} + } + self.mock_request.path = "/chatflow/test-app-id" + + # Remove static_app_id to allow dynamic app_id routes + settings_without_static_app_id = dict(self.default_settings) + settings_without_static_app_id.pop("static_app_id") + + response = self.endpoint._invoke( + self.mock_request, {"app_id": "test-app-id"}, settings_without_static_app_id) + + # Assert chatflow was invoked with correct parameters + self.mock_session.app.chat.invoke.assert_called_once_with( + app_id="test-app-id", + query="What is the weather?", + conversation_id=None, + inputs={}, + response_mode="blocking" + ) + + # Assert response + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.data), self.chatflow_response) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_missing_query_chatflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests /chatflow/ with missing query parameter. + Ensures proper validation of required query parameter.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = {"inputs": {}} + self.mock_request.path = "/chatflow/test-app-id" + + # Remove static_app_id to allow dynamic app_id routes + settings_without_static_app_id = dict(self.default_settings) + settings_without_static_app_id.pop("static_app_id") + + response = self.endpoint._invoke( + self.mock_request, {"app_id": "test-app-id"}, settings_without_static_app_id) + # Assert error response + self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.data), {"error": "query must be a string"}) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_non_string_query_chatflow_dynamic(self, mock_validate_api_key, mock_apply_middleware): + """Tests /chatflow/ with non-string query parameter. + Ensures proper validation of query parameter type.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = { + "query": 123, # Not a string + "inputs": {} + } + self.mock_request.path = "/chatflow/test-app-id" + + # Remove static_app_id to allow dynamic app_id routes + settings_without_static_app_id = dict(self.default_settings) + settings_without_static_app_id.pop("static_app_id") + + response = self.endpoint._invoke( + self.mock_request, {"app_id": "test-app-id"}, settings_without_static_app_id) + + # Assert error response + self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.data), {"error": "query must be a string"}) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_invalid_conversation_id_chatflow(self, mock_validate_api_key, mock_apply_middleware): + """Tests /chatflow/ with invalid conversation_id parameter. + Ensures proper validation of conversation_id parameter type.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = { + "query": "What is the weather?", + "inputs": {}, + "conversation_id": 123 # Invalid conversation_id + } + self.mock_request.path = "/chatflow/test-app-id" + + # Remove static_app_id to allow dynamic app_id routes + settings_without_static_app_id = dict(self.default_settings) + settings_without_static_app_id.pop("static_app_id") + + response = self.endpoint._invoke( + self.mock_request, {"app_id": "test-app-id"}, settings_without_static_app_id) + + # Assert error response + self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.data), {"error": "conversation_id must be a string"}) + + @patch('endpoints.invoke_endpoint.apply_middleware') + @patch('endpoints.invoke_endpoint.validate_api_key') + def test_explicit_inputs_false_chatflow_dynamic(self, mock_validate_api_key, mock_apply_middleware): + """Tests /chatflow/ with explicit_inputs=False. + Ensures all additional parameters are used as inputs with explicit_inputs disabled.""" + mock_apply_middleware.return_value = None + mock_validate_api_key.return_value = None + + self.mock_request.get_json.return_value = { + "query": "What is the weather?", + "conversation_id": "123", + "param1": "value1" + } + self.mock_request.path = "/chatflow/test-app-id" + + # Remove static_app_id to allow dynamic app_id routes + settings_without_static_app_id = dict(self.default_settings) + settings_without_static_app_id.pop("static_app_id") + settings_without_static_app_id["explicit_inputs"] = False + + response = self.endpoint._invoke( + self.mock_request, {"app_id": "test-app-id"}, settings_without_static_app_id) + + # Assert chatflow was invoked with correct parameters + self.mock_session.app.chat.invoke.assert_called_once_with( + app_id="test-app-id", + query="What is the weather?", + conversation_id="123", + inputs={"param1": "value1"}, + response_mode="blocking" + ) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/endpoints/test_workflow.py b/tests/endpoints/test_workflow.py deleted file mode 100644 index 9bf38ae..0000000 --- a/tests/endpoints/test_workflow.py +++ /dev/null @@ -1,387 +0,0 @@ -# pylint: disable=W0212 - -import json -import unittest -from unittest.mock import Mock, patch -from werkzeug import Request, Response -from dify_plugin.core.runtime import Session -from endpoints.workflow import WorkflowEndpoint - - -class TestWorkflowEndpoint(unittest.TestCase): - def setUp(self): - # Create a mock session - self.mock_session = Mock(spec=Session) - self.mock_session.app = Mock() - self.mock_session.app.workflow = Mock() - - # Create the endpoint with the mock session - self.endpoint = WorkflowEndpoint(session=self.mock_session) - - # Create a mock request - self.mock_request = Mock(spec=Request) - self.mock_request.get_json = Mock(return_value={}) - self.mock_request.headers = {} - self.mock_request.default_middleware_json = None - - # Default workflow response - self.workflow_response = { - "data": { - "outputs": {"result": "Test workflow output"} - } - } - self.mock_session.app.workflow.invoke.return_value = self.workflow_response - - # Default settings - self.default_settings = { - "explicit_inputs": True, - "raw_data_output": False, - "api_key_required": False - } - - @patch('endpoints.workflow.apply_middleware') - @patch('endpoints.workflow.validate_api_key') - def test_successful_invocation(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that a workflow is successfully invoked when valid inputs are provided. - Checks that the workflow is called with the correct parameters and a successful response is returned. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - self.mock_request.get_json.return_value = { - "inputs": {"param1": "value1", "param2": "value2"} - } - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert workflow was invoked with correct parameters - self.mock_session.app.workflow.invoke.assert_called_once_with( - app_id="test-app-id", - inputs={"param1": "value1", "param2": "value2"}, - response_mode="blocking" - ) - - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(json.loads(response.data), self.workflow_response) - - @patch('endpoints.workflow.apply_middleware') - @patch('endpoints.workflow.validate_api_key') - def test_successful_invocation_no_inputs(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that a workflow is successfully invoked with empty inputs when no inputs are provided in the request. - Verifies that the workflow is called with an empty inputs dictionary. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - self.mock_request.get_json.return_value = {} - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert workflow was invoked with empty inputs - self.mock_session.app.workflow.invoke.assert_called_once_with( - app_id="test-app-id", - inputs={}, - response_mode="blocking" - ) - - # Assert response - self.assertEqual(response.status_code, 200) - - @patch('endpoints.workflow.apply_middleware') - @patch('endpoints.workflow.validate_api_key') - def test_explicit_inputs_false(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that the entire request body is used as inputs when 'explicit_inputs' is set to False. - Ensures the workflow is correctly invoked with the non-wrapped request data. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - self.mock_request.get_json.return_value = { - "param1": "value1", - "param2": "value2" - } - - settings = dict(self.default_settings) - settings["explicit_inputs"] = False - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - settings - ) - - # Assert entire request body was used as inputs - self.mock_session.app.workflow.invoke.assert_called_once_with( - app_id="test-app-id", - inputs={"param1": "value1", "param2": "value2"}, - response_mode="blocking" - ) - - # Assert response - self.assertEqual(response.status_code, 200) - - @patch('endpoints.workflow.apply_middleware') - @patch('endpoints.workflow.validate_api_key') - def test_raw_data_output(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that only the 'outputs' part of the workflow response is returned when 'raw_data_output' is enabled. - This ensures concise data output when this option is selected. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - settings = dict(self.default_settings) - settings["raw_data_output"] = True - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - settings - ) - - # Assert only the outputs are returned - self.assertEqual( - json.loads(response.data), - self.workflow_response["data"]["outputs"] - ) - - @patch('endpoints.workflow.apply_middleware') - @patch('endpoints.workflow.validate_api_key') - def test_missing_app_id(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that an error is returned when the 'app_id' is missing from the request parameters. - This verifies that the requirement for 'app_id' is enforced. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": ""}, # Empty app_id - self.default_settings - ) - - # Assert error response - self.assertEqual(response.status_code, 400) - self.assertEqual( - json.loads(response.data), - {"error": "app_id is required"} - ) - - # Assert workflow was not invoked - self.mock_session.app.workflow.invoke.assert_not_called() - - @patch('endpoints.workflow.apply_middleware') - @patch('endpoints.workflow.validate_api_key') - def test_inputs_not_dictionary(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that an error is returned when 'inputs' is not a dictionary object as expected. - Ensures that any incorrect input format is rejected. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - self.mock_request.get_json.return_value = { - "inputs": "not a dictionary" - } - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert error response - self.assertEqual(response.status_code, 400) - self.assertEqual( - json.loads(response.data), - {"error": "inputs must be an object"} - ) - - # Assert workflow was not invoked - self.mock_session.app.workflow.invoke.assert_not_called() - - @patch('endpoints.workflow.apply_middleware') - def test_middleware_blocks_request(self, mock_apply_middleware): - """ - Tests that a request is blocked when middleware provides a blocking response. - Verifies that middleware can preempt workflow invocation. - """ - # Return a response from middleware, indicating request is blocked - middleware_response = Response( - json.dumps({"error": "Blocked by middleware"}), - status=403, - content_type="application/json" - ) - mock_apply_middleware.return_value = middleware_response - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert middleware response is returned - self.assertEqual(response.status_code, 403) - self.assertEqual( - json.loads(response.data), - {"error": "Blocked by middleware"} - ) - - # Assert workflow was not invoked - self.mock_session.app.workflow.invoke.assert_not_called() - - @patch('endpoints.workflow.apply_middleware') - @patch('endpoints.workflow.validate_api_key') - def test_api_key_validation_fails(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that a request is blocked due to failing API key validation. - This ensures that the API key requirement is enforced correctly. - """ - mock_apply_middleware.return_value = None - - # Return a response from API key validation, indicating validation failed - validation_response = Response( - json.dumps({"error": "Invalid API key"}), - status=401, - content_type="application/json" - ) - mock_validate_api_key.return_value = validation_response - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert validation response is returned - self.assertEqual(response.status_code, 401) - self.assertEqual( - json.loads(response.data), - {"error": "Invalid API key"} - ) - - # Assert workflow was not invoked - self.mock_session.app.workflow.invoke.assert_not_called() - - @patch('endpoints.workflow.apply_middleware') - @patch('endpoints.workflow.validate_api_key') - def test_json_parsing_fails(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that an error response is returned when the JSON parsing of the request fails. - This ensures the endpoint can detect and handle malformed request payloads. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - self.mock_request.get_json.side_effect = json.JSONDecodeError( - "Invalid JSON", "doc", 0) - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - self.assertEqual(response.status_code, 500) - self.assertIn("Invalid JSON", json.loads(response.data)["error"]) - - @patch('endpoints.workflow.apply_middleware') - @patch('endpoints.workflow.validate_api_key') - def test_workflow_invocation_exception(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that an exception is propagated correctly when an error occurs during workflow invocation. - Ensures that operational failures are surfaced and logged appropriately. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - # Make workflow.invoke raise an exception - self.mock_session.app.workflow.invoke.side_effect = Exception( - "Workflow error") - - with self.assertRaises(Exception) as context: - self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert the exception message - self.assertEqual(str(context.exception), "Workflow error") - - @patch('endpoints.workflow.apply_middleware') - @patch('endpoints.workflow.validate_api_key') - def test_non_dict_input_with_explicit_inputs_false(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that an error is returned when the request body, used as inputs due to 'explicit_inputs' being False, is not a dictionary. - This ensures consistent input validation regardless of configuration. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - self.mock_request.get_json.return_value = "not a dictionary" - - settings = dict(self.default_settings) - settings["explicit_inputs"] = False - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - settings - ) - - # Assert error response - self.assertEqual(response.status_code, 400) - self.assertEqual( - json.loads(response.data), - {"error": "inputs must be an object"} - ) - - # Assert workflow was not invoked - self.mock_session.app.workflow.invoke.assert_not_called() - - @patch('endpoints.workflow.apply_middleware') - @patch('endpoints.workflow.validate_api_key') - def test_middleware_json_used(self, mock_validate_api_key, mock_apply_middleware): - """ - Tests that the 'default_middleware_json' is used if present, overriding the usual request JSON parsing. - This ensures middleware can alter or provide data for further handling directly. - """ - mock_apply_middleware.return_value = None - mock_validate_api_key.return_value = None - - # Set middleware_json which should be used instead of get_json - self.mock_request.default_middleware_json = { - "inputs": {"from_middleware": "middleware value"} - } - - response = self.endpoint._invoke( - self.mock_request, - {"app_id": "test-app-id"}, - self.default_settings - ) - - # Assert workflow was invoked with middleware json - self.mock_session.app.workflow.invoke.assert_called_once_with( - app_id="test-app-id", - inputs={"from_middleware": "middleware value"}, - response_mode="blocking" - ) - - # get_json should not be called - self.mock_request.get_json.assert_not_called() - - -if __name__ == '__main__': - unittest.main()