diff --git a/api/controllers/console/workspace/endpoint.py b/api/controllers/console/workspace/endpoint.py index 001327047e6..df8989e3fa1 100644 --- a/api/controllers/console/workspace/endpoint.py +++ b/api/controllers/console/workspace/endpoint.py @@ -9,12 +9,11 @@ marks only the legacy paths as deprecated. from datetime import datetime from enum import StrEnum from http import HTTPStatus -from typing import Any +from typing import Any, Literal from flask_restx import Resource from pydantic import BaseModel, Field -from controllers.common.fields import SuccessResponse from controllers.common.rbac import RBACCheck, Workspace from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns @@ -40,6 +39,10 @@ from libs.login import login_required from services.plugin.endpoint_service import EndpointService +class EndpointMutationResponse(ResponseModel): + success: Literal[True] = Field(description="Always true on success. Failures are returned as HTTP errors.") + + class EndpointCreatePayload(BaseModel): plugin_unique_identifier: str settings: dict[str, Any] @@ -156,7 +159,7 @@ register_schema_models( ) register_response_schema_models( console_ns, - SuccessResponse, + EndpointMutationResponse, EndpointProviderConfigOptionResponse, EndpointProviderConfigResponse, EndpointDeclarationResponse, @@ -166,7 +169,7 @@ register_response_schema_models( ) -def _create_endpoint(tenant_id: str, user_id: str, req_data: EndpointCreatePayload) -> bool: +def _create_endpoint(tenant_id: str, user_id: str, req_data: EndpointCreatePayload) -> Literal[True]: """Create a plugin endpoint for the injected workspace and user.""" try: return EndpointService.create_endpoint( @@ -180,7 +183,7 @@ def _create_endpoint(tenant_id: str, user_id: str, req_data: EndpointCreatePaylo raise ValueError(e.description) from e -def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str, req_data: EndpointUpdatePayload) -> bool: +def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str, req_data: EndpointUpdatePayload) -> Literal[True]: """Update a plugin endpoint identified by the canonical path parameter.""" return EndpointService.update_endpoint( tenant_id=tenant_id, @@ -191,7 +194,7 @@ def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str, req_data: E ) -def _legacy_update_endpoint(tenant_id: str, user_id: str, req_data: LegacyEndpointUpdatePayload) -> bool: +def _legacy_update_endpoint(tenant_id: str, user_id: str, req_data: LegacyEndpointUpdatePayload) -> Literal[True]: return EndpointService.update_endpoint( tenant_id=tenant_id, user_id=user_id, @@ -201,7 +204,7 @@ def _legacy_update_endpoint(tenant_id: str, user_id: str, req_data: LegacyEndpoi ) -def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> bool: +def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]: """Delete a plugin endpoint identified by the canonical path parameter.""" return EndpointService.delete_endpoint( tenant_id=tenant_id, @@ -210,11 +213,11 @@ def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> bool: ) -def _delete_endpoint_from_payload(tenant_id: str, user_id: str, req_data: EndpointIdPayload) -> bool: +def _delete_endpoint_from_payload(tenant_id: str, user_id: str, req_data: EndpointIdPayload) -> Literal[True]: return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=req_data.endpoint_id) -def _set_endpoint_enabled(tenant_id: str, user_id: str, req_data: EndpointIdPayload, *, enabled: bool) -> bool: +def _set_endpoint_enabled(tenant_id: str, user_id: str, req_data: EndpointIdPayload, *, enabled: bool) -> Literal[True]: action = EndpointService.enable_endpoint if enabled else EndpointService.disable_endpoint return action(tenant_id=tenant_id, user_id=user_id, endpoint_id=req_data.endpoint_id) @@ -229,7 +232,7 @@ class EndpointCollectionApi(Resource): @console_ns.response( HTTPStatus.OK, "Endpoint created successfully", - console_ns.models[SuccessResponse.__name__], + console_ns.models[EndpointMutationResponse.__name__], ) @console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required") @setup_required @@ -241,7 +244,7 @@ class EndpointCollectionApi(Resource): @with_current_tenant_id @model_validate(EndpointCreatePayload) def post(self, req_data: EndpointCreatePayload, tenant_id: str, user_id: str): - return SuccessResponse( + return EndpointMutationResponse( success=_create_endpoint(tenant_id=tenant_id, user_id=user_id, req_data=req_data) ).model_dump(mode="json") @@ -261,7 +264,7 @@ class DeprecatedEndpointCreateApi(Resource): @console_ns.response( HTTPStatus.OK, "Endpoint created successfully", - console_ns.models[SuccessResponse.__name__], + console_ns.models[EndpointMutationResponse.__name__], ) @console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required") @setup_required @@ -273,7 +276,7 @@ class DeprecatedEndpointCreateApi(Resource): @with_current_tenant_id @model_validate(EndpointCreatePayload) def post(self, req_data: EndpointCreatePayload, tenant_id: str, user_id: str): - return SuccessResponse( + return EndpointMutationResponse( success=_create_endpoint(tenant_id=tenant_id, user_id=user_id, req_data=req_data) ).model_dump(mode="json") @@ -349,7 +352,7 @@ class EndpointItemApi(Resource): @console_ns.response( HTTPStatus.OK, "Endpoint deleted successfully", - console_ns.models[SuccessResponse.__name__], + console_ns.models[EndpointMutationResponse.__name__], ) @console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required") @setup_required @@ -360,7 +363,7 @@ class EndpointItemApi(Resource): @with_current_user_id @with_current_tenant_id def delete(self, tenant_id: str, user_id: str, id: str): - return SuccessResponse( + return EndpointMutationResponse( success=_delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id) ).model_dump(mode="json") @@ -371,7 +374,7 @@ class EndpointItemApi(Resource): @console_ns.response( HTTPStatus.OK, "Endpoint updated successfully", - console_ns.models[SuccessResponse.__name__], + console_ns.models[EndpointMutationResponse.__name__], ) @console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required") @setup_required @@ -383,7 +386,7 @@ class EndpointItemApi(Resource): @with_current_tenant_id @model_validate(EndpointUpdatePayload) def patch(self, req_data: EndpointUpdatePayload, tenant_id: str, user_id: str, id: str): - return SuccessResponse( + return EndpointMutationResponse( success=_update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id, req_data=req_data) ).model_dump(mode="json") @@ -404,7 +407,7 @@ class DeprecatedEndpointDeleteApi(Resource): @console_ns.response( HTTPStatus.OK, "Endpoint deleted successfully", - console_ns.models[SuccessResponse.__name__], + console_ns.models[EndpointMutationResponse.__name__], ) @console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required") @setup_required @@ -416,7 +419,7 @@ class DeprecatedEndpointDeleteApi(Resource): @with_current_tenant_id @model_validate(EndpointIdPayload) def post(self, req_data: EndpointIdPayload, tenant_id: str, user_id: str): - return SuccessResponse( + return EndpointMutationResponse( success=_delete_endpoint_from_payload(tenant_id=tenant_id, user_id=user_id, req_data=req_data) ).model_dump(mode="json") @@ -437,7 +440,7 @@ class DeprecatedEndpointUpdateApi(Resource): @console_ns.response( HTTPStatus.OK, "Endpoint updated successfully", - console_ns.models[SuccessResponse.__name__], + console_ns.models[EndpointMutationResponse.__name__], ) @console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required") @setup_required @@ -449,7 +452,7 @@ class DeprecatedEndpointUpdateApi(Resource): @with_current_tenant_id @model_validate(LegacyEndpointUpdatePayload) def post(self, req_data: LegacyEndpointUpdatePayload, tenant_id: str, user_id: str): - return SuccessResponse( + return EndpointMutationResponse( success=_legacy_update_endpoint(tenant_id=tenant_id, user_id=user_id, req_data=req_data) ).model_dump(mode="json") @@ -462,7 +465,7 @@ class EndpointEnableApi(Resource): @console_ns.response( HTTPStatus.OK, "Endpoint enabled successfully", - console_ns.models[SuccessResponse.__name__], + console_ns.models[EndpointMutationResponse.__name__], ) @console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required") @setup_required @@ -474,7 +477,7 @@ class EndpointEnableApi(Resource): @with_current_tenant_id @model_validate(EndpointIdPayload) def post(self, req_data: EndpointIdPayload, tenant_id: str, user_id: str): - return SuccessResponse( + return EndpointMutationResponse( success=_set_endpoint_enabled(tenant_id=tenant_id, user_id=user_id, req_data=req_data, enabled=True) ).model_dump(mode="json") @@ -487,7 +490,7 @@ class EndpointDisableApi(Resource): @console_ns.response( HTTPStatus.OK, "Endpoint disabled successfully", - console_ns.models[SuccessResponse.__name__], + console_ns.models[EndpointMutationResponse.__name__], ) @console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required") @setup_required @@ -499,6 +502,6 @@ class EndpointDisableApi(Resource): @with_current_tenant_id @model_validate(EndpointIdPayload) def post(self, req_data: EndpointIdPayload, tenant_id: str, user_id: str): - return SuccessResponse( + return EndpointMutationResponse( success=_set_endpoint_enabled(tenant_id=tenant_id, user_id=user_id, req_data=req_data, enabled=False) ).model_dump(mode="json") diff --git a/api/core/plugin/impl/endpoint.py b/api/core/plugin/impl/endpoint.py index 015afd9a0f6..575fe6ad466 100644 --- a/api/core/plugin/impl/endpoint.py +++ b/api/core/plugin/impl/endpoint.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Literal from core.plugin.entities.endpoint import EndpointEntityWithInstance from core.plugin.impl.base import BasePluginClient @@ -13,13 +13,13 @@ class PluginEndpointClient(BasePluginClient): plugin_unique_identifier: str, name: str, settings: dict[str, Any], - ) -> bool: + ) -> Literal[True]: """ Create an endpoint for the given plugin. Errors will be raised if any error occurs. """ - return self._request_with_plugin_daemon_response( + success = self._request_with_plugin_daemon_response( "POST", f"plugin/{tenant_id}/endpoint/setup", bool, @@ -33,6 +33,9 @@ class PluginEndpointClient(BasePluginClient): "name": name, }, ) + if not success: + raise PluginDaemonInternalServerError("Plugin daemon failed to create the endpoint") + return success def list_endpoints(self, tenant_id: str, user_id: str, page: int, page_size: int): """ @@ -64,11 +67,11 @@ class PluginEndpointClient(BasePluginClient): def update_endpoint( self, tenant_id: str, user_id: str, endpoint_id: str, name: str, settings: dict[str, Any] - ) -> bool: + ) -> Literal[True]: """ Update the settings of the given endpoint. """ - return self._request_with_plugin_daemon_response( + success = self._request_with_plugin_daemon_response( "POST", f"plugin/{tenant_id}/endpoint/update", bool, @@ -82,8 +85,11 @@ class PluginEndpointClient(BasePluginClient): "Content-Type": "application/json", }, ) + if not success: + raise PluginDaemonInternalServerError("Plugin daemon failed to update the endpoint") + return success - def delete_endpoint(self, tenant_id: str, user_id: str, endpoint_id: str): + def delete_endpoint(self, tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]: """ Delete the given endpoint. @@ -91,7 +97,7 @@ class PluginEndpointClient(BasePluginClient): it will return True instead of raising an error. """ try: - return self._request_with_plugin_daemon_response( + success = self._request_with_plugin_daemon_response( "POST", f"plugin/{tenant_id}/endpoint/remove", bool, @@ -107,12 +113,15 @@ class PluginEndpointClient(BasePluginClient): if "record not found" in str(e.description).lower(): return True raise + if not success: + raise PluginDaemonInternalServerError("Plugin daemon failed to delete the endpoint") + return success - def enable_endpoint(self, tenant_id: str, user_id: str, endpoint_id: str): + def enable_endpoint(self, tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]: """ Enable the given endpoint. """ - return self._request_with_plugin_daemon_response( + success = self._request_with_plugin_daemon_response( "POST", f"plugin/{tenant_id}/endpoint/enable", bool, @@ -123,12 +132,15 @@ class PluginEndpointClient(BasePluginClient): "Content-Type": "application/json", }, ) + if not success: + raise PluginDaemonInternalServerError("Plugin daemon failed to enable the endpoint") + return success - def disable_endpoint(self, tenant_id: str, user_id: str, endpoint_id: str): + def disable_endpoint(self, tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]: """ Disable the given endpoint. """ - return self._request_with_plugin_daemon_response( + success = self._request_with_plugin_daemon_response( "POST", f"plugin/{tenant_id}/endpoint/disable", bool, @@ -139,3 +151,6 @@ class PluginEndpointClient(BasePluginClient): "Content-Type": "application/json", }, ) + if not success: + raise PluginDaemonInternalServerError("Plugin daemon failed to disable the endpoint") + return success diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 756fdecedfd..f3c94c3b05c 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -10067,7 +10067,7 @@ Create a new plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint created successfully | **application/json**: [SuccessResponse](#successresponse)
| +| 200 | Endpoint created successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)
| | 403 | Admin privileges required | | ### ~~[POST] /workspaces/current/endpoints/create~~ @@ -10086,7 +10086,7 @@ Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/cur | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint created successfully | **application/json**: [SuccessResponse](#successresponse)
| +| 200 | Endpoint created successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)
| | 403 | Admin privileges required | | ### ~~[POST] /workspaces/current/endpoints/delete~~ @@ -10105,7 +10105,7 @@ Deprecated legacy alias for deleting a plugin endpoint. Use DELETE /workspaces/c | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint deleted successfully | **application/json**: [SuccessResponse](#successresponse)
| +| 200 | Endpoint deleted successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)
| | 403 | Admin privileges required | | ### [POST] /workspaces/current/endpoints/disable @@ -10121,7 +10121,7 @@ Disable a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint disabled successfully | **application/json**: [SuccessResponse](#successresponse)
| +| 200 | Endpoint disabled successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)
| | 403 | Admin privileges required | | ### [POST] /workspaces/current/endpoints/enable @@ -10137,7 +10137,7 @@ Enable a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint enabled successfully | **application/json**: [SuccessResponse](#successresponse)
| +| 200 | Endpoint enabled successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)
| | 403 | Admin privileges required | | ### [GET] /workspaces/current/endpoints/list @@ -10189,7 +10189,7 @@ Deprecated legacy alias for updating a plugin endpoint. Use PATCH /workspaces/cu | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint updated successfully | **application/json**: [SuccessResponse](#successresponse)
| +| 200 | Endpoint updated successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)
| | 403 | Admin privileges required | | ### [DELETE] /workspaces/current/endpoints/{id} @@ -10205,7 +10205,7 @@ Delete a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint deleted successfully | **application/json**: [SuccessResponse](#successresponse)
| +| 200 | Endpoint deleted successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)
| | 403 | Admin privileges required | | ### [PATCH] /workspaces/current/endpoints/{id} @@ -10227,7 +10227,7 @@ Update a plugin endpoint | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint updated successfully | **application/json**: [SuccessResponse](#successresponse)
| +| 200 | Endpoint updated successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)
| | 403 | Admin privileges required | | ### [GET] /workspaces/current/members @@ -18178,6 +18178,12 @@ declaration of an endpoint | ---- | ---- | ----------- | -------- | | endpoints | [ [EndpointListItemResponse](#endpointlistitemresponse) ] | Endpoint information | Yes | +#### EndpointMutationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| success | boolean | Always true on success. Failures are returned as HTTP errors. | Yes | + #### EndpointProviderConfigI18nResponse | Name | Type | Description | Required | diff --git a/api/services/plugin/endpoint_service.py b/api/services/plugin/endpoint_service.py index 1727cd7abd6..9a1eb0b2f6f 100644 --- a/api/services/plugin/endpoint_service.py +++ b/api/services/plugin/endpoint_service.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Literal from core.plugin.impl.endpoint import PluginEndpointClient @@ -7,7 +7,7 @@ class EndpointService: @classmethod def create_endpoint( cls, tenant_id: str, user_id: str, plugin_unique_identifier: str, name: str, settings: dict[str, Any] - ): + ) -> Literal[True]: return PluginEndpointClient().create_endpoint( tenant_id=tenant_id, user_id=user_id, @@ -36,7 +36,9 @@ class EndpointService: ) @classmethod - def update_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str, name: str, settings: dict[str, Any]): + def update_endpoint( + cls, tenant_id: str, user_id: str, endpoint_id: str, name: str, settings: dict[str, Any] + ) -> Literal[True]: return PluginEndpointClient().update_endpoint( tenant_id=tenant_id, user_id=user_id, @@ -46,7 +48,7 @@ class EndpointService: ) @classmethod - def delete_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str): + def delete_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]: return PluginEndpointClient().delete_endpoint( tenant_id=tenant_id, user_id=user_id, @@ -54,7 +56,7 @@ class EndpointService: ) @classmethod - def enable_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str): + def enable_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]: return PluginEndpointClient().enable_endpoint( tenant_id=tenant_id, user_id=user_id, @@ -62,7 +64,7 @@ class EndpointService: ) @classmethod - def disable_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str): + def disable_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]: return PluginEndpointClient().disable_endpoint( tenant_id=tenant_id, user_id=user_id, diff --git a/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py b/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py index eb328aaf8e8..be72754d231 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py @@ -3,8 +3,10 @@ from datetime import UTC, datetime from types import FunctionType from unittest.mock import patch +import httpx import pytest from flask import Flask +from flask_restx import Resource from controllers.common.rbac import Workspace from controllers.console import console_ns @@ -22,13 +24,15 @@ from controllers.console.workspace.endpoint import ( EndpointListForPluginQuery, EndpointListForSinglePluginApi, EndpointListQuery, + EndpointMutationResponse, EndpointUpdatePayload, LegacyEndpointUpdatePayload, ) from controllers.console.wraps import RBACPermission from core.entities.provider_entities import ProviderConfig, ProviderConfigType from core.plugin.entities.endpoint import EndpointEntityWithInstance, EndpointProviderDeclaration -from core.plugin.impl.exc import PluginPermissionDeniedError +from core.plugin.impl.exc import PluginDaemonInternalServerError, PluginPermissionDeniedError +from libs.external_api import ExternalApi from tests.unit_tests.controllers.rbac_introspection import rbac_checks @@ -73,6 +77,36 @@ def test_endpoint_lists_require_management_permission(method: FunctionType) -> N class TestEndpointCollectionApi: + @pytest.mark.parametrize("success", [True, False]) + def test_daemon_result_uses_http_success_or_error(self, success: bool) -> None: + app = Flask(__name__) + api = ExternalApi(app) + method = inspect.unwrap(EndpointCollectionApi.post) + payload = EndpointCreatePayload(plugin_unique_identifier="plugin-1", name="endpoint", settings={}) + + @api.route("/workspaces/current/endpoints") + class EndpointMutationApi(Resource): + def post(self) -> dict[str, object]: + return method(EndpointCollectionApi(), payload, "t1", "u1") + + with patch( + "core.plugin.impl.base.BasePluginClient._request", + return_value=httpx.Response( + 200, + request=httpx.Request("POST", "https://daemon.test/endpoint/setup"), + json={"code": 0, "message": "success", "data": success}, + ), + ): + response = app.test_client().post("/workspaces/current/endpoints") + + if success: + assert response.status_code == 200 + assert response.get_json() == {"success": True} + else: + assert response.status_code == 500 + assert response.get_json()["status"] == 500 + assert "success" not in response.get_json() + def test_create_success(self, app: Flask): api = EndpointCollectionApi() method = inspect.unwrap(api.post) @@ -253,11 +287,13 @@ class TestEndpointItemApi: with ( app.test_request_context("/", method="DELETE"), - patch("controllers.console.workspace.endpoint.EndpointService.delete_endpoint", return_value=False), + patch( + "controllers.console.workspace.endpoint.EndpointService.delete_endpoint", + side_effect=PluginDaemonInternalServerError("failed"), + ), ): - result = method(api, "t1", "u1", "e1") - - assert result["success"] is False + with pytest.raises(PluginDaemonInternalServerError): + method(api, "t1", "u1", "e1") def test_update_success(self, app: Flask): api = EndpointItemApi() @@ -311,11 +347,13 @@ class TestEndpointItemApi: with ( app.test_request_context("/", method="PATCH", json=payload), - patch("controllers.console.workspace.endpoint.EndpointService.update_endpoint", return_value=False), + patch( + "controllers.console.workspace.endpoint.EndpointService.update_endpoint", + side_effect=PluginDaemonInternalServerError("failed"), + ), ): - result = method(api, req_data, "t1", "u1", "e1") - - assert result["success"] is False + with pytest.raises(PluginDaemonInternalServerError): + method(api, req_data, "t1", "u1", "e1") class TestDeprecatedEndpointDeleteApi: @@ -353,11 +391,13 @@ class TestDeprecatedEndpointDeleteApi: with ( app.test_request_context("/", json=payload), - patch("controllers.console.workspace.endpoint.EndpointService.delete_endpoint", return_value=False), + patch( + "controllers.console.workspace.endpoint.EndpointService.delete_endpoint", + side_effect=PluginDaemonInternalServerError("failed"), + ), ): - result = method(api, req_data, "t1", "u1") - - assert result["success"] is False + with pytest.raises(PluginDaemonInternalServerError): + method(api, req_data, "t1", "u1") class TestDeprecatedEndpointUpdateApi: @@ -405,14 +445,22 @@ class TestDeprecatedEndpointUpdateApi: with ( app.test_request_context("/", json=payload), - patch("controllers.console.workspace.endpoint.EndpointService.update_endpoint", return_value=False), + patch( + "controllers.console.workspace.endpoint.EndpointService.update_endpoint", + side_effect=PluginDaemonInternalServerError("failed"), + ), ): - result = method(api, req_data, "t1", "u1") - - assert result["success"] is False + with pytest.raises(PluginDaemonInternalServerError): + method(api, req_data, "t1", "u1") class TestEndpointRouteMetadata: + def test_mutation_response_only_documents_success(self) -> None: + success_schema = EndpointMutationResponse.model_json_schema()["properties"]["success"] + + assert success_schema["const"] is True + assert success_schema["type"] == "boolean" + def test_legacy_write_routes_are_marked_deprecated(self): assert DeprecatedEndpointCreateApi.post.__apidoc__["deprecated"] is True assert DeprecatedEndpointDeleteApi.post.__apidoc__["deprecated"] is True @@ -477,11 +525,13 @@ class TestEndpointEnableApi: with ( app.test_request_context("/", json=payload), - patch("controllers.console.workspace.endpoint.EndpointService.enable_endpoint", return_value=False), + patch( + "controllers.console.workspace.endpoint.EndpointService.enable_endpoint", + side_effect=PluginDaemonInternalServerError("failed"), + ), ): - result = method(api, req_data, "t1", "u1") - - assert result["success"] is False + with pytest.raises(PluginDaemonInternalServerError): + method(api, req_data, "t1", "u1") class TestEndpointDisableApi: diff --git a/api/tests/unit_tests/core/plugin/impl/test_endpoint_client_impl.py b/api/tests/unit_tests/core/plugin/impl/test_endpoint_client_impl.py index 7c9f46be093..e8d487c336d 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_endpoint_client_impl.py +++ b/api/tests/unit_tests/core/plugin/impl/test_endpoint_client_impl.py @@ -1,3 +1,6 @@ +from collections.abc import Callable + +import httpx import pytest from pytest_mock import MockerFixture @@ -6,13 +9,46 @@ from core.plugin.impl.exc import PluginDaemonInternalServerError class TestPluginEndpointClientImpl: + @pytest.mark.parametrize( + "mutate", + [ + pytest.param( + lambda client: client.create_endpoint("tenant-1", "user-1", "org/plugin:1", "endpoint-a", {}), + id="create", + ), + pytest.param( + lambda client: client.update_endpoint("tenant-1", "user-1", "endpoint-1", "renamed", {}), + id="update", + ), + pytest.param(lambda client: client.delete_endpoint("tenant-1", "user-1", "endpoint-1"), id="delete"), + pytest.param(lambda client: client.enable_endpoint("tenant-1", "user-1", "endpoint-1"), id="enable"), + pytest.param(lambda client: client.disable_endpoint("tenant-1", "user-1", "endpoint-1"), id="disable"), + ], + ) + def test_mutation_rejects_false_daemon_result( + self, mocker: MockerFixture, mutate: Callable[[PluginEndpointClient], bool] + ) -> None: + client = PluginEndpointClient() + mocker.patch.object( + client, + "_request", + return_value=httpx.Response( + 200, + request=httpx.Request("POST", "https://daemon.test/endpoint"), + json={"code": 0, "message": "success", "data": False}, + ), + ) + + with pytest.raises(PluginDaemonInternalServerError, match="Plugin daemon failed to"): + mutate(client) + def test_create_endpoint(self, mocker: MockerFixture): client = PluginEndpointClient() request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=True) result = client.create_endpoint("tenant-1", "user-1", "org/plugin:1", "endpoint-a", {"k": "v"}) - assert result is True + assert result assert request_mock.call_count == 1 args = request_mock.call_args.args kwargs = request_mock.call_args.kwargs @@ -49,15 +85,15 @@ class TestPluginEndpointClientImpl: result = client.update_endpoint("tenant-1", "user-1", "endpoint-1", "renamed", {"x": 1}) - assert result is True + assert result assert request_mock.call_args.args[:3] == ("POST", "plugin/tenant-1/endpoint/update", bool) def test_enable_and_disable_endpoint(self, mocker: MockerFixture): client = PluginEndpointClient() request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=True) - assert client.enable_endpoint("tenant-1", "user-1", "endpoint-1") is True - assert client.disable_endpoint("tenant-1", "user-1", "endpoint-1") is True + assert client.enable_endpoint("tenant-1", "user-1", "endpoint-1") + assert client.disable_endpoint("tenant-1", "user-1", "endpoint-1") calls = request_mock.call_args_list assert calls[0].args[1] == "plugin/tenant-1/endpoint/enable" @@ -68,7 +104,7 @@ class TestPluginEndpointClientImpl: request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response") request_mock.side_effect = PluginDaemonInternalServerError("record not found") - assert client.delete_endpoint("tenant-1", "user-1", "endpoint-1") is True + assert client.delete_endpoint("tenant-1", "user-1", "endpoint-1") request_mock.side_effect = PluginDaemonInternalServerError("permission denied") with pytest.raises(PluginDaemonInternalServerError) as exc_info: diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index af6b52cf87d..b711df6f0a8 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -2582,16 +2582,6 @@ "count": 3 } }, - "web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx": { - "typescript/no-explicit-any": { - "count": 2 - } - }, - "web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx": { - "typescript/no-explicit-any": { - "count": 7 - } - }, "web/app/components/plugins/plugin-detail-panel/model-list.tsx": { "typescript/no-explicit-any": { "count": 1 @@ -2689,7 +2679,7 @@ }, "web/app/components/plugins/types.ts": { "typescript/no-explicit-any": { - "count": 25 + "count": 24 } }, "web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx": { @@ -5038,17 +5028,6 @@ "count": 1 } }, - "web/service/use-endpoints.ts": { - "@tanstack/query/prefer-query-options": { - "count": 2 - }, - "no-restricted-imports": { - "count": 1 - }, - "typescript/no-explicit-any": { - "count": 7 - } - }, "web/service/use-explore.ts": { "@tanstack/query/prefer-query-options": { "count": 2 diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index b6f81460353..450ace9e1fa 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -129,8 +129,8 @@ export type EndpointCreatePayload = { } } -export type SuccessResponse = { - success: boolean +export type EndpointMutationResponse = { + success: true } export type EndpointIdPayload = { @@ -391,6 +391,10 @@ export type ParserExcludePlugin = { plugin_id: string } +export type SuccessResponse = { + success: boolean +} + export type PluginAutoUpgradeFetchResponse = { auto_upgrade: PluginAutoUpgradeSettingsResponseModel category: TenantPluginAutoUpgradeCategory @@ -3147,7 +3151,7 @@ export type PostWorkspacesCurrentEndpointsErrors = { } export type PostWorkspacesCurrentEndpointsResponses = { - 200: SuccessResponse + 200: EndpointMutationResponse } export type PostWorkspacesCurrentEndpointsResponse = @@ -3165,7 +3169,7 @@ export type PostWorkspacesCurrentEndpointsCreateErrors = { } export type PostWorkspacesCurrentEndpointsCreateResponses = { - 200: SuccessResponse + 200: EndpointMutationResponse } export type PostWorkspacesCurrentEndpointsCreateResponse = @@ -3183,7 +3187,7 @@ export type PostWorkspacesCurrentEndpointsDeleteErrors = { } export type PostWorkspacesCurrentEndpointsDeleteResponses = { - 200: SuccessResponse + 200: EndpointMutationResponse } export type PostWorkspacesCurrentEndpointsDeleteResponse = @@ -3201,7 +3205,7 @@ export type PostWorkspacesCurrentEndpointsDisableErrors = { } export type PostWorkspacesCurrentEndpointsDisableResponses = { - 200: SuccessResponse + 200: EndpointMutationResponse } export type PostWorkspacesCurrentEndpointsDisableResponse = @@ -3219,7 +3223,7 @@ export type PostWorkspacesCurrentEndpointsEnableErrors = { } export type PostWorkspacesCurrentEndpointsEnableResponses = { - 200: SuccessResponse + 200: EndpointMutationResponse } export type PostWorkspacesCurrentEndpointsEnableResponse = @@ -3272,7 +3276,7 @@ export type PostWorkspacesCurrentEndpointsUpdateErrors = { } export type PostWorkspacesCurrentEndpointsUpdateResponses = { - 200: SuccessResponse + 200: EndpointMutationResponse } export type PostWorkspacesCurrentEndpointsUpdateResponse = @@ -3292,7 +3296,7 @@ export type DeleteWorkspacesCurrentEndpointsByIdErrors = { } export type DeleteWorkspacesCurrentEndpointsByIdResponses = { - 200: SuccessResponse + 200: EndpointMutationResponse } export type DeleteWorkspacesCurrentEndpointsByIdResponse = @@ -3312,7 +3316,7 @@ export type PatchWorkspacesCurrentEndpointsByIdErrors = { } export type PatchWorkspacesCurrentEndpointsByIdResponses = { - 200: SuccessResponse + 200: EndpointMutationResponse } export type PatchWorkspacesCurrentEndpointsByIdResponse = diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 0c7739a14b8..4b3f8d6b1e2 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -63,10 +63,10 @@ export const zEndpointCreatePayload = z.object({ }) /** - * SuccessResponse + * EndpointMutationResponse */ -export const zSuccessResponse = z.object({ - success: z.boolean(), +export const zEndpointMutationResponse = z.object({ + success: z.literal(true), }) /** @@ -278,6 +278,13 @@ export const zPluginAutoUpgradeChangeResponse = z.object({ success: z.boolean(), }) +/** + * SuccessResponse + */ +export const zSuccessResponse = z.object({ + success: z.boolean(), +}) + /** * PluginDebuggingKeyResponse */ @@ -4118,35 +4125,35 @@ export const zPostWorkspacesCurrentEndpointsBody = zEndpointCreatePayload /** * Endpoint created successfully */ -export const zPostWorkspacesCurrentEndpointsResponse = zSuccessResponse +export const zPostWorkspacesCurrentEndpointsResponse = zEndpointMutationResponse export const zPostWorkspacesCurrentEndpointsCreateBody = zEndpointCreatePayload /** * Endpoint created successfully */ -export const zPostWorkspacesCurrentEndpointsCreateResponse = zSuccessResponse +export const zPostWorkspacesCurrentEndpointsCreateResponse = zEndpointMutationResponse export const zPostWorkspacesCurrentEndpointsDeleteBody = zEndpointIdPayload /** * Endpoint deleted successfully */ -export const zPostWorkspacesCurrentEndpointsDeleteResponse = zSuccessResponse +export const zPostWorkspacesCurrentEndpointsDeleteResponse = zEndpointMutationResponse export const zPostWorkspacesCurrentEndpointsDisableBody = zEndpointIdPayload /** * Endpoint disabled successfully */ -export const zPostWorkspacesCurrentEndpointsDisableResponse = zSuccessResponse +export const zPostWorkspacesCurrentEndpointsDisableResponse = zEndpointMutationResponse export const zPostWorkspacesCurrentEndpointsEnableBody = zEndpointIdPayload /** * Endpoint enabled successfully */ -export const zPostWorkspacesCurrentEndpointsEnableResponse = zSuccessResponse +export const zPostWorkspacesCurrentEndpointsEnableResponse = zEndpointMutationResponse export const zGetWorkspacesCurrentEndpointsListQuery = z.object({ page: z.int().gte(1), @@ -4174,7 +4181,7 @@ export const zPostWorkspacesCurrentEndpointsUpdateBody = zLegacyEndpointUpdatePa /** * Endpoint updated successfully */ -export const zPostWorkspacesCurrentEndpointsUpdateResponse = zSuccessResponse +export const zPostWorkspacesCurrentEndpointsUpdateResponse = zEndpointMutationResponse export const zDeleteWorkspacesCurrentEndpointsByIdPath = z.object({ id: z.string(), @@ -4183,7 +4190,7 @@ export const zDeleteWorkspacesCurrentEndpointsByIdPath = z.object({ /** * Endpoint deleted successfully */ -export const zDeleteWorkspacesCurrentEndpointsByIdResponse = zSuccessResponse +export const zDeleteWorkspacesCurrentEndpointsByIdResponse = zEndpointMutationResponse export const zPatchWorkspacesCurrentEndpointsByIdBody = zEndpointUpdatePayload @@ -4194,7 +4201,7 @@ export const zPatchWorkspacesCurrentEndpointsByIdPath = z.object({ /** * Endpoint updated successfully */ -export const zPatchWorkspacesCurrentEndpointsByIdResponse = zSuccessResponse +export const zPatchWorkspacesCurrentEndpointsByIdResponse = zEndpointMutationResponse /** * Success diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx deleted file mode 100644 index 17b6b5fe3e9..00000000000 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-card.spec.tsx +++ /dev/null @@ -1,503 +0,0 @@ -import type { EndpointListItem, PluginDetail } from '../../types' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import EndpointCard from '../endpoint-card' - -const mockHandleChange = vi.fn() -const mockEnableEndpoint = vi.fn() -const mockDisableEndpoint = vi.fn() -const mockDeleteEndpoint = vi.fn() -const mockUpdateEndpoint = vi.fn() -const mockToastNotify = vi.fn() - -const getEditButton = () => screen.getByRole('button', { name: 'common.operation.edit' }) -const getDeleteButton = () => screen.getByRole('button', { name: 'common.operation.delete' }) -const getCopyButton = () => screen.getByRole('button', { name: 'common.operation.copy' }) - -vi.mock('@/app/notifications', () => ({ - toast: Object.assign( - (message: string, options?: { type?: string }) => - mockToastNotify({ type: options?.type, message }), - { - success: (message: string) => mockToastNotify({ type: 'success', message }), - error: (message: string) => mockToastNotify({ type: 'error', message }), - warning: (message: string) => mockToastNotify({ type: 'warning', message }), - info: (message: string) => mockToastNotify({ type: 'info', message }), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }, - ), -})) - -// Flags to control whether operations should fail -const failureFlags = { - enable: false, - disable: false, - delete: false, - update: false, -} - -vi.mock('@/service/use-endpoints', () => ({ - useEnableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void; onError: () => void }) => ({ - mutate: (id: string) => { - mockEnableEndpoint(id) - if (failureFlags.enable) onError() - else onSuccess() - }, - }), - useDisableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void; onError: () => void }) => ({ - mutate: (id: string) => { - mockDisableEndpoint(id) - if (failureFlags.disable) onError() - else onSuccess() - }, - }), - useDeleteEndpoint: ({ onSuccess, onError }: { onSuccess: () => void; onError: () => void }) => ({ - mutate: (id: string) => { - mockDeleteEndpoint(id) - if (failureFlags.delete) onError() - else onSuccess() - }, - }), - useUpdateEndpoint: ({ onSuccess, onError }: { onSuccess: () => void; onError: () => void }) => ({ - mutate: (data: unknown) => { - mockUpdateEndpoint(data) - if (failureFlags.update) onError() - else onSuccess() - }, - }), -})) - -vi.mock('@langgenius/dify-ui/status-dot', () => ({ - StatusDot: ({ status }: { status: string }) => ( - - ), -})) - -vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ - toolCredentialToFormSchemas: (schemas: unknown[]) => schemas, - addDefaultValue: (value: unknown) => value, -})) - -vi.mock('../endpoint-modal', () => ({ - default: ({ onCancel, onSaved }: { onCancel: () => void; onSaved: (state: unknown) => void }) => ( -
- - -
- ), -})) - -const mockEndpointData: EndpointListItem = { - id: 'ep-1', - name: 'Test Endpoint', - url: 'https://api.example.com', - enabled: true, - created_at: '2024-01-01', - updated_at: '2024-01-02', - settings: {}, - tenant_id: 'tenant-1', - plugin_id: 'plugin-1', - expired_at: '', - hook_id: 'hook-1', - declaration: { - settings: [], - endpoints: [ - { path: '/api/test', method: 'GET' }, - { path: '/api/hidden', method: 'POST', hidden: true }, - ], - }, -} - -const mockPluginDetail: PluginDetail = { - id: 'test-id', - created_at: '2024-01-01', - updated_at: '2024-01-02', - name: 'Test Plugin', - plugin_id: 'test-plugin', - plugin_unique_identifier: 'test-uid', - declaration: {} as PluginDetail['declaration'], - installation_id: 'install-1', - tenant_id: 'tenant-1', - endpoints_setups: 0, - endpoints_active: 0, - version: '1.0.0', - latest_version: '1.0.0', - latest_unique_identifier: 'test-uid', - source: 'marketplace' as PluginDetail['source'], - meta: undefined, - status: 'active', - deprecated_reason: '', - alternative_plugin_id: '', -} - -describe('EndpointCard', () => { - beforeEach(() => { - vi.clearAllMocks() - // Reset failure flags - failureFlags.enable = false - failureFlags.disable = false - failureFlags.delete = false - failureFlags.update = false - // Polyfill document.execCommand for copy-to-clipboard in the test DOM runtime - if (typeof document.execCommand !== 'function') { - document.execCommand = vi.fn().mockReturnValue(true) - } - }) - - afterEach(() => { - vi.useRealTimers() - }) - - const waitForAlertDialogToClose = async () => { - await waitFor(() => { - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() - }) - } - - describe('Rendering', () => { - it('should render endpoint name', () => { - render( - , - ) - - expect(screen.getByText('Test Endpoint'))!.toBeInTheDocument() - }) - - it('should render visible endpoints only', () => { - render( - , - ) - - expect(screen.getByText('GET'))!.toBeInTheDocument() - expect(screen.getByText('https://api.example.com/api/test'))!.toBeInTheDocument() - expect(screen.queryByText('POST')).not.toBeInTheDocument() - }) - - it('should show active status when enabled', () => { - render( - , - ) - - expect(screen.getByText('plugin.detailPanel.serviceOk'))!.toBeInTheDocument() - expect(screen.getByTestId('indicator'))!.toHaveAttribute('data-status', 'success') - }) - - it('should show disabled status when not enabled', () => { - const disabledData = { ...mockEndpointData, enabled: false } - render( - , - ) - - expect(screen.getByText('plugin.detailPanel.disabled'))!.toBeInTheDocument() - expect(screen.getByTestId('indicator'))!.toHaveAttribute('data-status', 'disabled') - }) - }) - - describe('User Interactions', () => { - it('should reach endpoint actions through the tab order', async () => { - const user = userEvent.setup() - render( - , - ) - - await user.tab() - expect(getEditButton()).toHaveFocus() - - await user.tab() - expect(getDeleteButton()).toHaveFocus() - - await user.keyboard('{Enter}') - expect(screen.getByText('plugin.detailPanel.endpointDeleteTip')).toBeInTheDocument() - }) - - it('should show disable confirm when switching off', () => { - render( - , - ) - - fireEvent.click(screen.getByRole('switch')) - - expect(screen.getByText('plugin.detailPanel.endpointDisableTip'))!.toBeInTheDocument() - }) - - it('should call disableEndpoint when confirm disable', () => { - render( - , - ) - - fireEvent.click(screen.getByRole('switch')) - // Click confirm button in the Confirm dialog - fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - - expect(mockDisableEndpoint).toHaveBeenCalledWith('ep-1') - }) - - it('should show delete confirm when delete clicked', () => { - render( - , - ) - - fireEvent.click(getDeleteButton()) - - expect(screen.getByText('plugin.detailPanel.endpointDeleteTip'))!.toBeInTheDocument() - }) - - it('should call deleteEndpoint when confirm delete', () => { - render( - , - ) - - fireEvent.click(getDeleteButton()) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - - expect(mockDeleteEndpoint).toHaveBeenCalledWith('ep-1') - }) - - it('should show edit modal when edit clicked', () => { - render( - , - ) - - fireEvent.click(getEditButton()) - - expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument() - }) - - it('should call updateEndpoint when save in modal', () => { - render( - , - ) - - fireEvent.click(getEditButton()) - fireEvent.click(screen.getByTestId('modal-save')) - - expect(mockUpdateEndpoint).toHaveBeenCalled() - }) - }) - - describe('Copy Functionality', () => { - it('should reset copy state after timeout', async () => { - vi.useFakeTimers() - render( - , - ) - - fireEvent.click(getCopyButton()) - - act(() => { - vi.advanceTimersByTime(2000) - }) - - expect(screen.getByText('Test Endpoint'))!.toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle empty endpoints', () => { - const dataWithNoEndpoints = { - ...mockEndpointData, - declaration: { settings: [], endpoints: [] }, - } - render( - , - ) - - expect(screen.getByText('Test Endpoint'))!.toBeInTheDocument() - }) - - it('should call handleChange after enable', () => { - const disabledData = { ...mockEndpointData, enabled: false } - render( - , - ) - - fireEvent.click(screen.getByRole('switch')) - - expect(mockHandleChange).toHaveBeenCalled() - }) - - it('should hide disable confirm and revert state when cancel clicked', async () => { - render( - , - ) - - fireEvent.click(screen.getByRole('switch')) - expect(screen.getByText('plugin.detailPanel.endpointDisableTip'))!.toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - await waitForAlertDialogToClose() - - expect(screen.getByRole('switch'))!.toHaveAttribute('aria-checked', 'true') - }) - - it('should hide delete confirm when cancel clicked', async () => { - render( - , - ) - - fireEvent.click(getDeleteButton()) - expect(screen.getByText('plugin.detailPanel.endpointDeleteTip'))!.toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - await waitForAlertDialogToClose() - }) - - it('should hide edit modal when cancel clicked', () => { - render( - , - ) - - fireEvent.click(getEditButton()) - expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument() - - fireEvent.click(screen.getByTestId('modal-cancel')) - - expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument() - }) - }) - - describe('Error Handling', () => { - it('should show error toast when enable fails', () => { - failureFlags.enable = true - const disabledData = { ...mockEndpointData, enabled: false } - render( - , - ) - - fireEvent.click(screen.getByRole('switch')) - - expect(mockEnableEndpoint).toHaveBeenCalled() - }) - - it('should show error toast when disable fails', () => { - failureFlags.disable = true - render( - , - ) - - fireEvent.click(screen.getByRole('switch')) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - - expect(mockDisableEndpoint).toHaveBeenCalled() - }) - - it('should show error toast when delete fails', () => { - failureFlags.delete = true - render( - , - ) - - fireEvent.click(getDeleteButton()) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - - expect(mockDeleteEndpoint).toHaveBeenCalled() - }) - - it('should show error toast when update fails', () => { - render( - , - ) - - fireEvent.click(getEditButton()) - - expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument() - - failureFlags.update = true - fireEvent.click(screen.getByTestId('modal-save')) - - expect(mockUpdateEndpoint).toHaveBeenCalled() - expect(mockHandleChange).not.toHaveBeenCalled() - }) - }) -}) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-fixture.ts b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-fixture.ts new file mode 100644 index 00000000000..a67503d033a --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-fixture.ts @@ -0,0 +1,39 @@ +import { normalizeInstalledPluginDetail } from '@/service/use-plugins' + +export const createPluginDetail = () => + normalizeInstalledPluginDetail({ + id: 'test-id', + created_at: '2024-01-01', + updated_at: '2024-01-02', + plugin_id: 'test-plugin', + plugin_unique_identifier: 'test-uid', + tenant_id: 'tenant-1', + endpoints_setups: 1, + endpoints_active: 1, + version: '1.0.0', + source: 'marketplace', + runtime_type: 'local', + checksum: 'checksum', + meta: {}, + declaration: { + version: '1.0.0', + author: 'Dify', + name: 'Test Plugin', + category: 'extension', + created_at: '2024-01-01', + icon: 'plugin.svg', + label: { en_US: 'Test Plugin' }, + description: { en_US: 'Endpoint plugin' }, + resource: {}, + plugins: {}, + meta: {}, + endpoint: { + settings: [ + { name: 'enabled', type: 'boolean', default: false }, + { name: 'count', type: 'text-input', default: '0' }, + { name: 'token', type: 'secret-input', default: '' }, + ], + endpoints: [], + }, + }, + }) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx index 42cc3c00216..ee1bd7fc702 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx @@ -1,221 +1,233 @@ -import type { PluginDetail } from '@/app/components/plugins/types' -import { fireEvent, screen, waitFor } from '@testing-library/react' +import type { EndpointListItemResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import { act, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { renderWithConsoleQuery as render } from '@/test/console/query-data' import EndpointList from '../endpoint-list' +import { createPluginDetail } from './endpoint-fixture' -vi.mock('@langgenius/dify-ui/cn', () => ({ - cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), -})) +const { request, showError } = vi.hoisted(() => ({ request: vi.fn(), showError: vi.fn() })) +vi.mock('@/service/base', () => ({ request })) +vi.mock('@/app/notifications', () => ({ toast: { error: showError } })) +vi.mock('../../readme-panel/entrance', () => ({ ReadmeEntrance: () => null })) -const mockEndpoints = [ - { - id: 'ep-1', - name: 'Endpoint 1', - url: 'https://api.example.com', - declaration: { settings: [], endpoints: [] }, - }, -] +const detail = createPluginDetail() -let mockEndpointListData: { endpoints: typeof mockEndpoints } | undefined - -const mockInvalidateEndpointList = vi.fn() -const mockInvalidateInstalledPluginList = vi.fn() -const mockCreateEndpoint = vi.fn() - -vi.mock('@/service/use-endpoints', () => ({ - useEndpointList: () => ({ data: mockEndpointListData }), - useInvalidateEndpointList: () => mockInvalidateEndpointList, - useCreateEndpoint: ({ onSuccess }: { onSuccess: () => void }) => ({ - mutate: (data: unknown) => { - mockCreateEndpoint(data) - onSuccess() - }, - }), -})) - -vi.mock('@/service/use-plugins', () => ({ - useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, -})) - -vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ - toolCredentialToFormSchemas: (schemas: unknown[]) => schemas, -})) - -vi.mock('../endpoint-card', () => ({ - default: ({ data }: { data: { name: string } }) => ( -
{data.name}
- ), -})) - -vi.mock('../endpoint-modal', () => ({ - default: ({ onCancel, onSaved }: { onCancel: () => void; onSaved: (state: unknown) => void }) => ( -
- - -
- ), -})) - -const createPluginDetail = (): PluginDetail => ({ - id: 'test-id', +const createEndpoint = (): EndpointListItemResponse => ({ + id: 'ep-1', + name: 'Endpoint 1', + url: 'https://api.example.com', + enabled: true, created_at: '2024-01-01', updated_at: '2024-01-02', - name: 'Test Plugin', - plugin_id: 'test-plugin', - plugin_unique_identifier: 'test-uid', - declaration: { - endpoint: { settings: [], endpoints: [] }, - tool: undefined, - } as unknown as PluginDetail['declaration'], - installation_id: 'install-1', + settings: { enabled: false, count: '0', token: '', retry_count: 0 }, tenant_id: 'tenant-1', - endpoints_setups: 0, - endpoints_active: 0, - version: '1.0.0', - latest_version: '1.0.0', - latest_unique_identifier: 'test-uid', - source: 'marketplace' as PluginDetail['source'], - meta: undefined, - status: 'active', - deprecated_reason: '', - alternative_plugin_id: '', + plugin_id: 'test-plugin', + expired_at: '', + hook_id: 'hook-1', + declaration: { + settings: detail.declaration.endpoint?.settings ?? [], + endpoints: [ + { method: 'GET', path: '/public' }, + { method: 'POST', path: '/hidden', hidden: true }, + ], + }, }) -describe('EndpointList', () => { - const getAddButton = () => - screen.getByRole('button', { name: 'plugin.detailPanel.endpointModalTitle' }) +let endpoints: EndpointListItemResponse[] +let rejectMutation: boolean +let failAfterWrite: boolean +let mutationGate: Promise | undefined +const mutations: { path: string; method: string; body: unknown }[] = [] - beforeEach(() => { - vi.clearAllMocks() - mockEndpointListData = { endpoints: mockEndpoints } +const renderEndpoints = () => render() + +beforeEach(() => { + vi.clearAllMocks() + endpoints = [createEndpoint()] + rejectMutation = false + failAfterWrite = false + mutationGate = undefined + mutations.length = 0 + request.mockImplementation( + async (url: string, _init: RequestInit, options: { request: Request }) => { + const method = options.request.method + const path = new URL(url).pathname.replace(/^.*\/workspaces/, '/workspaces') + if (method === 'GET') return Response.json({ endpoints }) + const body: unknown = options.request.body ? await options.request.json() : undefined + mutations.push({ path, method, body }) + await mutationGate + if (rejectMutation && !failAfterWrite) + return Response.json({ message: 'Failed' }, { status: 500 }) + if (path.endsWith('/enable')) + endpoints = endpoints.map((item) => ({ ...item, enabled: true })) + else if (path.endsWith('/disable')) + endpoints = endpoints.map((item) => ({ ...item, enabled: false })) + else if (method === 'DELETE') endpoints = [] + else endpoints = [{ ...createEndpoint(), name: 'Saved endpoint' }] + if (rejectMutation) return Response.json({ message: 'Failed after write' }, { status: 500 }) + return Response.json({ success: true }) + }, + ) +}) + +describe('Endpoint management', () => { + it('loads the plugin endpoints and only displays public paths', async () => { + renderEndpoints() + expect(await screen.findByText('Endpoint 1')).toBeInTheDocument() + expect(screen.getByText('https://api.example.com/public')).toBeInTheDocument() + expect(screen.queryByText('https://api.example.com/hidden')).not.toBeInTheDocument() + const requestedURL = new URL(request.mock.calls[0]![0]) + expect(requestedURL.searchParams.get('plugin_id')).toBe('test-plugin') + expect(requestedURL.searchParams.get('page')).toBe('1') + expect(requestedURL.searchParams.get('page_size')).toBe('100') }) - describe('Rendering', () => { - it('should render endpoint list', () => { - render() - - expect(screen.getByText('plugin.detailPanel.endpoints'))!.toBeInTheDocument() - }) - - it('should render endpoint cards', () => { - render() - - expect(screen.getByTestId('endpoint-card'))!.toBeInTheDocument() - expect(screen.getByText('Endpoint 1'))!.toBeInTheDocument() - }) - - it('should return null when no data', () => { - mockEndpointListData = undefined - const { container } = render() - - expect(container)!.toBeEmptyDOMElement() - }) - - it('should show empty message when no endpoints', () => { - mockEndpointListData = { endpoints: [] } - render() - - expect(screen.getByText('plugin.detailPanel.endpointsEmpty'))!.toBeInTheDocument() - }) - - it('should render add button', () => { - render() - - expect(getAddButton()).toBeInTheDocument() - }) + it('creates through the canonical route and refreshes the list', async () => { + const user = userEvent.setup() + endpoints = [] + renderEndpoints() + await user.click( + await screen.findByRole('button', { name: 'plugin.detailPanel.endpointModalTitle' }), + ) + await user.clear(screen.getByPlaceholderText('Endpoint Name')) + await user.type(screen.getByPlaceholderText('Endpoint Name'), 'Saved endpoint') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(await screen.findByText('Saved endpoint')).toBeInTheDocument() + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(mutations).toEqual([ + { + path: '/workspaces/current/endpoints', + method: 'POST', + body: { + plugin_unique_identifier: 'test-uid', + name: 'Saved endpoint', + settings: { enabled: false, count: '0', token: '' }, + }, + }, + ]) }) - describe('User Interactions', () => { - it('should show modal when add button clicked', () => { - render() - - fireEvent.click(getAddButton()) - - expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument() - }) - - it('should hide modal when cancel clicked', () => { - render() - - fireEvent.click(getAddButton()) - expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument() - - fireEvent.click(screen.getByTestId('modal-cancel')) - expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument() - }) - - it('should call createEndpoint when save clicked', () => { - render() - - fireEvent.click(getAddButton()) - fireEvent.click(screen.getByTestId('modal-save')) - - expect(mockCreateEndpoint).toHaveBeenCalled() - }) + it('updates through the canonical item route without dropping falsy settings', async () => { + const user = userEvent.setup() + renderEndpoints() + await user.click(await screen.findByRole('button', { name: 'common.operation.edit' })) + await user.clear(screen.getByPlaceholderText('Endpoint Name')) + await user.type(screen.getByPlaceholderText('Endpoint Name'), 'Saved endpoint') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(await screen.findByText('Saved endpoint')).toBeInTheDocument() + expect(mutations).toEqual([ + { + path: '/workspaces/current/endpoints/ep-1', + method: 'PATCH', + body: { + name: 'Saved endpoint', + settings: { enabled: false, count: '0', token: '', retry_count: 0 }, + }, + }, + ]) }) - describe('Border Style', () => { - it('should render with border style based on tool existence', () => { - const detail = createPluginDetail() - detail.declaration.tool = {} as PluginDetail['declaration']['tool'] - render() - - expect(screen.getByText('plugin.detailPanel.endpoints'))!.toBeInTheDocument() - }) + it('keeps enabled state when disabling is cancelled, then follows refetched state after confirm', async () => { + const user = userEvent.setup() + renderEndpoints() + await user.click(await screen.findByRole('switch')) + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true') + expect(mutations).toHaveLength(0) + await user.click(screen.getByRole('switch')) + await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')) + await user.click(screen.getByRole('switch')) + await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')) + expect(mutations.map(({ path, body }) => ({ path, body }))).toEqual([ + { path: '/workspaces/current/endpoints/disable', body: { endpoint_id: 'ep-1' } }, + { path: '/workspaces/current/endpoints/enable', body: { endpoint_id: 'ep-1' } }, + ]) }) - describe('Multiple Endpoints', () => { - it('should render multiple endpoint cards', () => { - mockEndpointListData = { - endpoints: [ - { - id: 'ep-1', - name: 'Endpoint 1', - url: 'https://api1.example.com', - declaration: { settings: [], endpoints: [] }, - }, - { - id: 'ep-2', - name: 'Endpoint 2', - url: 'https://api2.example.com', - declaration: { settings: [], endpoints: [] }, - }, - ], - } - render() - - expect(screen.getAllByTestId('endpoint-card')).toHaveLength(2) - }) + it('deletes through the canonical route and refreshes the empty state', async () => { + const user = userEvent.setup() + renderEndpoints() + await user.click(await screen.findByRole('button', { name: 'common.operation.delete' })) + await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + expect(await screen.findByText('plugin.detailPanel.endpointsEmpty')).toBeInTheDocument() + expect(mutations).toEqual([ + { path: '/workspaces/current/endpoints/ep-1', method: 'DELETE', body: undefined }, + ]) }) - describe('Create Endpoint Flow', () => { - it('should invalidate endpoint list after successful create', () => { - render() + it('keeps edit values available when the mutation fails', async () => { + const user = userEvent.setup() + rejectMutation = true + renderEndpoints() + await user.click(await screen.findByRole('button', { name: 'common.operation.edit' })) + await user.clear(screen.getByPlaceholderText('Endpoint Name')) + await user.type(screen.getByPlaceholderText('Endpoint Name'), 'Saved endpoint') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + await waitFor(() => expect(showError).toHaveBeenCalled()) + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Endpoint 1')).toBeInTheDocument() + }) - fireEvent.click(getAddButton()) - fireEvent.click(screen.getByTestId('modal-save')) - - return waitFor(() => { - expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin') - expect(mockInvalidateInstalledPluginList).toHaveBeenCalled() + it.each(['edit', 'disable', 'delete'] as const)( + 'blocks duplicate %s while pending and retains the failed action for retry', + async (action) => { + const user = userEvent.setup() + let settle: () => void = () => {} + mutationGate = new Promise((resolve) => { + settle = resolve }) - }) - - it('should pass correct params to createEndpoint', () => { - render() - - fireEvent.click(getAddButton()) - fireEvent.click(screen.getByTestId('modal-save')) - - expect(mockCreateEndpoint).toHaveBeenCalledWith({ - pluginUniqueID: 'test-uid', - state: { name: 'New Endpoint' }, + rejectMutation = true + renderEndpoints() + if (action === 'disable') await user.click(await screen.findByRole('switch')) + else + await user.click(await screen.findByRole('button', { name: `common.operation.${action}` })) + const actionName = action === 'edit' ? 'common.operation.save' : 'common.operation.confirm' + const submit = screen.getByRole('button', { name: actionName }) + await user.click(submit) + await waitFor(() => expect(submit).toHaveAttribute('aria-disabled', 'true')) + expect(submit).toHaveFocus() + expect(submit).not.toBeDisabled() + await user.click(submit) + expect(mutations).toHaveLength(1) + await act(async () => { + settle() }) - }) + await waitFor(() => expect(showError).toHaveBeenCalled()) + expect(screen.getByRole('button', { name: actionName })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ) + if (action === 'edit') + expect(screen.getByPlaceholderText('Endpoint Name')).toHaveValue('Endpoint 1') + else expect(screen.getByRole('alertdialog')).toBeInTheDocument() + + rejectMutation = false + await user.click(screen.getByRole('button', { name: actionName })) + await waitFor(() => + expect( + screen.queryByRole(action === 'edit' ? 'dialog' : 'alertdialog'), + ).not.toBeInTheDocument(), + ) + expect(mutations).toHaveLength(2) + }, + ) + + it('refreshes a committed write after an error while preserving the editable form', async () => { + const user = userEvent.setup() + rejectMutation = true + failAfterWrite = true + renderEndpoints() + await user.click(await screen.findByRole('button', { name: 'common.operation.edit' })) + await user.clear(screen.getByPlaceholderText('Endpoint Name')) + await user.type(screen.getByPlaceholderText('Endpoint Name'), 'Submitted name') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + await waitFor(() => expect(showError).toHaveBeenCalled()) + expect(await screen.findByText('Saved endpoint')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Endpoint Name')).toHaveValue('Submitted name') + expect(mutations).toHaveLength(1) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx index 008ce84d138..5091e8d4851 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx @@ -1,413 +1,207 @@ -import type { FormSchema } from '../../../base/form/types' -import type { PluginDetail } from '../../types' -import { fireEvent, render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import EndpointModal from '../endpoint-modal' +import { createPluginDetail } from './endpoint-fixture' -const mockToastNotify = vi.fn() - -vi.mock('@/app/notifications', () => ({ - toast: Object.assign( - (message: string, options?: { type?: string }) => - mockToastNotify({ type: options?.type, message }), - { - success: (message: string) => mockToastNotify({ type: 'success', message }), - error: (message: string) => mockToastNotify({ type: 'error', message }), - warning: (message: string) => mockToastNotify({ type: 'warning', message }), - info: (message: string) => mockToastNotify({ type: 'info', message }), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }, - ), -})) - +const { showError } = vi.hoisted(() => ({ showError: vi.fn() })) +vi.mock('@/app/notifications', () => ({ toast: { error: showError } })) +vi.mock('../../readme-panel/entrance', () => ({ ReadmeEntrance: () => null })) vi.mock('@/hooks/use-i18n', () => ({ - useRenderI18nObject: () => (obj: Record | string) => - typeof obj === 'string' ? obj : obj?.en_US || '', + useRenderI18nObject: () => (label: { en_US: string }) => label.en_US, })) -vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({ - default: ({ - value, - onChange, - fieldMoreInfo, - }: { - value: Record - onChange: (v: Record) => void - fieldMoreInfo?: (item: { url?: string }) => React.ReactNode - }) => { - return ( -
- onChange({ ...value, name: e.target.value })} - /> - {/* Render fieldMoreInfo to test url link */} - {fieldMoreInfo && ( -
- {fieldMoreInfo({ url: 'https://example.com' })} - {fieldMoreInfo({})} -
- )} -
+const mockPluginDetail = createPluginDetail() + +describe('Endpoint settings form', () => { + beforeEach(() => vi.clearAllMocks()) + + it('preserves typed defaults and validates required false as a present value', async () => { + const user = userEvent.setup() + const onSaved = vi.fn() + render( + , ) - }, -})) - -vi.mock('../../readme-panel/entrance', () => ({ - ReadmeEntrance: () =>
, -})) - -const mockFormSchemas = [ - { name: 'name', label: { en_US: 'Name' }, type: 'text-input', required: true, default: '' }, - { - name: 'apiKey', - label: { en_US: 'API Key' }, - type: 'secret-input', - required: false, - default: '', - }, -] as unknown as FormSchema[] - -const mockPluginDetail: PluginDetail = { - id: 'test-id', - created_at: '2024-01-01', - updated_at: '2024-01-02', - name: 'Test Plugin', - plugin_id: 'test-plugin', - plugin_unique_identifier: 'test-uid', - declaration: {} as PluginDetail['declaration'], - installation_id: 'install-1', - tenant_id: 'tenant-1', - endpoints_setups: 0, - endpoints_active: 0, - version: '1.0.0', - latest_version: '1.0.0', - latest_unique_identifier: 'test-uid', - source: 'marketplace' as PluginDetail['source'], - meta: undefined, - status: 'active', - deprecated_reason: '', - alternative_plugin_id: '', -} - -describe('EndpointModal', () => { - const mockOnCancel = vi.fn() - const mockOnSaved = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() + await user.type(screen.getByPlaceholderText('Endpoint Name'), 'New endpoint') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(onSaved).toHaveBeenCalledWith({ + name: 'New endpoint', + settings: { enabled: false, limit: '0' }, + }) + expect(showError).not.toHaveBeenCalled() }) - describe('Rendering', () => { - it('should render drawer', () => { - render( - , - ) - - const dialog = screen.getByRole('dialog') - - expect(dialog)!.toBeInTheDocument() - expect(dialog).toHaveClass( - 'data-[swipe-direction=right]:top-2', - 'data-[swipe-direction=right]:bottom-2', - 'data-[swipe-direction=right]:h-[calc(100dvh-16px)]', - 'data-[swipe-direction=right]:w-100', - 'data-[swipe-direction=right]:max-w-[calc(100vw-1rem)]', - ) - }) - - it('should render title and description', () => { - render( - , - ) - - expect(screen.getByText('plugin.detailPanel.endpointModalTitle'))!.toBeInTheDocument() - expect(screen.getByText('plugin.detailPanel.endpointModalDesc'))!.toBeInTheDocument() - }) - - it('should render form with fieldMoreInfo url link', () => { - render( - , - ) - - expect(screen.getByTestId('field-more-info'))!.toBeInTheDocument() - expect(screen.getByText('tools.howToGet'))!.toBeInTheDocument() - }) - - it('should render readme entrance', () => { - render( - , - ) - - expect(screen.getByTestId('readme-entrance'))!.toBeInTheDocument() + it.each([ + ['true', true], + ['True', true], + ['1', true], + ['false', false], + [true, true], + [false, false], + [1, true], + [0, false], + ])('renders and submits boolean default %j as %j', async (value, expected) => { + const user = userEvent.setup() + const onSaved = vi.fn() + render( + , + ) + expect(screen.getByRole('radio', { name: expected ? 'True' : 'False' })).toHaveAttribute( + 'aria-checked', + 'true', + ) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(onSaved).toHaveBeenCalledWith({ + name: 'Typed endpoint', + settings: { enabled: expected }, }) }) - describe('User Interactions', () => { - it('should call onCancel when cancel clicked', () => { - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - - expect(mockOnCancel).toHaveBeenCalledTimes(1) - }) - - it('should call onCancel when close button clicked', () => { - render( - , - ) - - const allButtons = screen.getAllByRole('button') - fireEvent.click(allButtons[0]!) - - expect(mockOnCancel).toHaveBeenCalledTimes(1) - }) - - it('should update form value when input changes', () => { - render( - , - ) - - const input = screen.getByTestId('form-input') - fireEvent.change(input, { target: { value: 'Test Name' } }) - - expect(input)!.toHaveValue('Test Name') + it('interprets an empty optional boolean default as false', async () => { + const user = userEvent.setup() + const onSaved = vi.fn() + render( + , + ) + expect(screen.getByRole('radio', { name: 'False' })).toHaveAttribute('aria-checked', 'true') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(onSaved).toHaveBeenCalledWith({ + name: 'Optional endpoint', + settings: { enabled: false }, }) }) - describe('Default Values', () => { - it('should use defaultValues when provided', () => { - render( - , - ) + it('preserves a nullable optional boolean and rejects an empty required boolean', async () => { + const user = userEvent.setup() + const onSaved = vi.fn() + const { rerender } = render( + , + ) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(onSaved).toHaveBeenCalledWith({ name: 'Nullable endpoint', settings: { enabled: null } }) + onSaved.mockClear() + rerender( + , + ) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(onSaved).not.toHaveBeenCalled() + expect(showError).toHaveBeenCalled() + }) - expect(screen.getByTestId('form-input'))!.toHaveValue('Default Name') + it('preserves explicit clearing and does not mutate edit values', async () => { + const user = userEvent.setup() + const onSaved = vi.fn() + const defaults = { + name: 'Existing', + token: '', + enabled: false, + retry_count: 0, + nested: { limit: 3 }, + } + render( + , + ) + expect(screen.getByPlaceholderText('Token')).toHaveValue('') + await user.clear(screen.getByPlaceholderText('Endpoint Name')) + await user.type(screen.getByPlaceholderText('Endpoint Name'), 'Updated') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(onSaved).toHaveBeenCalledWith({ + name: 'Updated', + settings: { token: '', enabled: false, retry_count: 0, nested: { limit: 3 } }, }) - - it('should extract default values from schemas when no defaultValues', () => { - const schemasWithDefaults = [ - { - name: 'name', - label: 'Name', - type: 'text-input', - required: true, - default: 'Schema Default', - }, - ] as unknown as FormSchema[] - - render( - , - ) - - expect(screen.getByTestId('form-input'))!.toHaveValue('Schema Default') - }) - - it('should handle schemas without default values', () => { - const schemasNoDefault = [ - { name: 'name', label: 'Name', type: 'text-input', required: false }, - ] as unknown as FormSchema[] - - render( - , - ) - - expect(screen.getByTestId('form'))!.toBeInTheDocument() + expect(defaults).toEqual({ + name: 'Existing', + token: '', + enabled: false, + retry_count: 0, + nested: { limit: 3 }, }) }) - describe('Validation - handleSave', () => { - it('should show toast error when required field is empty', () => { - const schemasWithRequired = [ - { - name: 'name', - label: { en_US: 'Name Field' }, - type: 'text-input', - required: true, - default: '', - }, - ] as unknown as FormSchema[] - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: expect.stringContaining('errorMsg.fieldRequired'), - }) - expect(mockOnSaved).not.toHaveBeenCalled() - }) - - it('should show toast error with string label when required field is empty', () => { - const schemasWithStringLabel = [ - { name: 'name', label: 'String Label', type: 'text-input', required: true, default: '' }, - ] as unknown as FormSchema[] - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: expect.stringContaining('String Label'), - }) - }) - - it('should call onSaved when all required fields are filled', () => { - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockOnSaved).toHaveBeenCalledWith({ name: 'Valid Name' }) - }) - - it('should not validate non-required empty fields', () => { - const schemasOptional = [ - { name: 'optional', label: 'Optional', type: 'text-input', required: false, default: '' }, - ] as unknown as FormSchema[] - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockToastNotify).not.toHaveBeenCalled() - expect(mockOnSaved).toHaveBeenCalled() - }) + it('keeps the form open and identifies a missing required field', async () => { + const user = userEvent.setup() + const onSaved = vi.fn() + render( + , + ) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(onSaved).not.toHaveBeenCalled() + expect(showError).toHaveBeenCalled() + expect(screen.getByPlaceholderText('Endpoint Name')).toBeInTheDocument() }) - describe('Boolean Field Processing', () => { - it.each([ - { input: 'true', expected: true }, - { input: '1', expected: true }, - { input: 'True', expected: true }, - { input: 'false', expected: false }, - { input: 1, expected: true }, - { input: 0, expected: false }, - { input: true, expected: true }, - { input: false, expected: false }, - ])('should convert $input to $expected for boolean fields', ({ input, expected }) => { - const schemasWithBoolean = [ - { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, - ] as unknown as FormSchema[] - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockOnSaved).toHaveBeenCalledWith({ enabled: expected }) - }) - - it('should not process non-boolean fields', () => { - const schemasWithText = [ - { name: 'text', label: 'Text', type: 'text-input', required: false, default: '' }, - ] as unknown as FormSchema[] - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockOnSaved).toHaveBeenCalledWith({ text: 'hello' }) - }) + it('keeps pending save focusable and blocks click and implicit submissions', async () => { + const user = userEvent.setup() + const onSaved = vi.fn() + render( + , + ) + const save = screen.getByRole('button', { name: 'common.operation.save' }) + expect(save).toHaveAttribute('aria-disabled', 'true') + expect(save).not.toBeDisabled() + await user.click(save) + await user.click(screen.getByPlaceholderText('Endpoint Name')) + await user.keyboard('{Enter}') + expect(onSaved).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/utils.spec.ts b/web/app/components/plugins/plugin-detail-panel/__tests__/utils.spec.ts deleted file mode 100644 index decca5d828a..00000000000 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/utils.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { NAME_FIELD } from '../utils' - -describe('utils', () => { - describe('NAME_FIELD', () => { - it('should have correct type', () => { - expect(NAME_FIELD.type).toBe(FormTypeEnum.textInput) - }) - - it('should have correct name', () => { - expect(NAME_FIELD.name).toBe('name') - }) - - it('should have label translations', () => { - expect(NAME_FIELD.label).toBeDefined() - expect(NAME_FIELD.label.en_US).toBe('Endpoint Name') - expect(NAME_FIELD.label.zh_Hans).toBe('端点名称') - expect(NAME_FIELD.label.ja_JP).toBe('エンドポイント名') - expect(NAME_FIELD.label.pt_BR).toBe('Nome do ponto final') - }) - - it('should have placeholder translations', () => { - expect(NAME_FIELD.placeholder).toBeDefined() - expect(NAME_FIELD.placeholder.en_US).toBe('Endpoint Name') - expect(NAME_FIELD.placeholder.zh_Hans).toBe('端点名称') - expect(NAME_FIELD.placeholder.ja_JP).toBe('エンドポイント名') - expect(NAME_FIELD.placeholder.pt_BR).toBe('Nome do ponto final') - }) - - it('should be required', () => { - expect(NAME_FIELD.required).toBe(true) - }) - - it('should have empty default value', () => { - expect(NAME_FIELD.default).toBe('') - }) - - it('should have null help', () => { - expect(NAME_FIELD.help).toBeNull() - }) - - it('should have all required field properties', () => { - const requiredKeys = ['type', 'name', 'label', 'placeholder', 'required', 'default', 'help'] - requiredKeys.forEach((key) => { - expect(NAME_FIELD).toHaveProperty(key) - }) - }) - - it('should match expected structure', () => { - expect(NAME_FIELD).toEqual({ - type: FormTypeEnum.textInput, - name: 'name', - label: { - en_US: 'Endpoint Name', - zh_Hans: '端点名称', - ja_JP: 'エンドポイント名', - pt_BR: 'Nome do ponto final', - }, - placeholder: { - en_US: 'Endpoint Name', - zh_Hans: '端点名称', - ja_JP: 'エンドポイント名', - pt_BR: 'Nome do ponto final', - }, - required: true, - default: '', - help: null, - }) - }) - }) -}) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx index 20ce15f0ce2..675200f4128 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx @@ -1,5 +1,5 @@ -import type { ComponentProps } from 'react' -import type { EndpointListItem, PluginDetail } from '../types' +import type { EndpointListItemResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { PluginDetail } from '../types' import { AlertDialog, AlertDialogActions, @@ -12,112 +12,62 @@ import { IconButton } from '@langgenius/dify-ui/icon-button' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Switch } from '@langgenius/dify-ui/switch' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useMutation } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import copy from 'copy-to-clipboard' import * as React from 'react' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - addDefaultValue, - toolCredentialToFormSchemas, -} from '@/app/components/tools/utils/to-form-schema' import { toast } from '@/app/notifications' -import { - useDeleteEndpoint, - useDisableEndpoint, - useEnableEndpoint, - useUpdateEndpoint, -} from '@/service/use-endpoints' +import { consoleQuery } from '@/service/console' import EndpointModal from './endpoint-modal' -import { NAME_FIELD } from './utils' - -type EndpointModalFormSchemas = ComponentProps['formSchemas'] type Props = Readonly<{ pluginDetail: PluginDetail - data: EndpointListItem - handleChange: () => void + data: EndpointListItemResponse }> -const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => { +const EndpointCard = ({ pluginDetail, data }: Props) => { const { t } = useTranslation() - const [active, setActive] = useState(data.enabled) const endpointID = data.id - - // switch const [isShowDisableConfirm, { setTrue: showDisableConfirm, setFalse: hideDisableConfirm }] = useBoolean(false) - const { mutate: enableEndpoint } = useEnableEndpoint({ - onSuccess: async () => { - await handleChange() - }, - onError: () => { - toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - setActive(false) - }, - }) - const { mutate: disableEndpoint } = useDisableEndpoint({ - onSuccess: async () => { - await handleChange() - hideDisableConfirm() - }, - onError: () => { - toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - setActive(false) - }, - }) - const handleSwitch = (state: boolean) => { - if (state) { - setActive(true) - enableEndpoint(endpointID) - } else { - setActive(false) - showDisableConfirm() - } - } - - // delete const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = useBoolean(false) - const { mutate: deleteEndpoint } = useDeleteEndpoint({ - onSuccess: async () => { - await handleChange() - hideDeleteConfirm() - }, - onError: () => { - toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - }, - }) - - // update const [ isShowEndpointModal, { setTrue: showEndpointModalConfirm, setFalse: hideEndpointModalConfirm }, ] = useBoolean(false) - const formSchemas = useMemo(() => { - return toolCredentialToFormSchemas([NAME_FIELD, ...data.declaration.settings]) - }, [data.declaration.settings]) - const formValue = useMemo(() => { - const formValue = { - name: data.name, - ...data.settings, - } - return addDefaultValue(formValue, formSchemas) - }, [data.name, data.settings, formSchemas]) - const { mutate: updateEndpoint } = useUpdateEndpoint({ - onSuccess: async () => { - await handleChange() - hideEndpointModalConfirm() - }, - onError: () => { - toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - }, - }) - const handleUpdate = (state: Record) => - updateEndpoint({ - endpointID, - state, - }) + const showSaveError = () => { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } + const { mutate: enableEndpoint, isPending: isEnabling } = useMutation( + consoleQuery.workspaces.current.endpoints.enable.post.mutationOptions({ + onError: showSaveError, + }), + ) + const { mutate: disableEndpoint, isPending: isDisabling } = useMutation( + consoleQuery.workspaces.current.endpoints.disable.post.mutationOptions({ + onSuccess: hideDisableConfirm, + onError: showSaveError, + }), + ) + const { mutate: deleteEndpoint, isPending: isDeleting } = useMutation( + consoleQuery.workspaces.current.endpoints.byId.delete.mutationOptions({ + onSuccess: hideDeleteConfirm, + onError: showSaveError, + }), + ) + const { mutate: updateEndpoint, isPending: isUpdating } = useMutation( + consoleQuery.workspaces.current.endpoints.byId.patch.mutationOptions({ + onSuccess: hideEndpointModalConfirm, + onError: showSaveError, + }), + ) + const handleSwitch = (enabled: boolean) => { + if (enabled) enableEndpoint({ body: { endpoint_id: endpointID } }) + else showDisableConfirm() + } const [isCopied, setIsCopied] = useState(false) const handleCopy = (value: string) => { @@ -125,13 +75,6 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => { setIsCopied(true) } - const handleDisableConfirmOpenChange = (open: boolean) => { - if (open) return - - hideDisableConfirm() - setActive(true) - } - useEffect(() => { if (isCopied) { const timer = setTimeout(() => { @@ -169,10 +112,10 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => {
- {(data.declaration.endpoints ?? []) + {(data.declaration?.endpoints ?? []) .filter((endpoint) => !endpoint.hidden) - .map((endpoint, index) => ( -
+ .map((endpoint) => ( +
{endpoint.method}
@@ -207,21 +150,30 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => { ))}
- {active && ( + {data.enabled && (
{t(($) => $['detailPanel.serviceOk'], { ns: 'plugin' })}
)} - {!active && ( + {!data.enabled && (
{t(($) => $['detailPanel.disabled'], { ns: 'plugin' })}
)} - +
- + !open && hideDisableConfirm()} + >
@@ -235,7 +187,10 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => { {t(($) => $['operation.cancel'], { ns: 'common' })} - disableEndpoint(endpointID)}> + disableEndpoint({ body: { endpoint_id: endpointID } })} + > {t(($) => $['operation.confirm'], { ns: 'common' })} @@ -255,7 +210,10 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => { {t(($) => $['operation.cancel'], { ns: 'common' })} - deleteEndpoint(endpointID)}> + deleteEndpoint({ params: { id: endpointID } })} + > {t(($) => $['operation.confirm'], { ns: 'common' })} @@ -263,10 +221,11 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => { {isShowEndpointModal && ( updateEndpoint({ params: { id: endpointID }, body })} + isPending={isUpdating} pluginDetail={pluginDetail} /> )} diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx index 0bc82f5f948..4ca332e8ab9 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx @@ -1,32 +1,24 @@ +import type { EndpointProviderDeclarationResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { PluginDetail } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' import { IconButton } from '@langgenius/dify-ui/icon-button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useMutation, useQuery } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import * as React from 'react' -import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { toast } from '@/app/notifications' import { useDocLink } from '@/context/i18n' -import { - useCreateEndpoint, - useEndpointList, - useInvalidateEndpointList, -} from '@/service/use-endpoints' -import { useInvalidateInstalledPluginList } from '@/service/use-plugins' +import { consoleQuery } from '@/service/console' import EndpointCard from './endpoint-card' import EndpointModal from './endpoint-modal' -import { NAME_FIELD } from './utils' type Props = Readonly<{ detail: PluginDetail }> -type EndpointDeclaration = NonNullable - type EndpointListContentProps = Readonly<{ - declaration: EndpointDeclaration + declaration: EndpointProviderDeclarationResponse detail: PluginDetail }> @@ -35,33 +27,22 @@ const EndpointListContent = ({ declaration, detail }: EndpointListContentProps) const docLink = useDocLink() const pluginUniqueID = detail.plugin_unique_identifier const showTopBorder = detail.declaration.tool - const { data } = useEndpointList(detail.plugin_id) - const invalidateEndpointList = useInvalidateEndpointList() - const invalidateInstalledPluginList = useInvalidateInstalledPluginList() - + const { data } = useQuery( + consoleQuery.workspaces.current.endpoints.list.plugin.get.queryOptions({ + input: { query: { plugin_id: detail.plugin_id, page: 1, page_size: 100 } }, + }), + ) const [isShowEndpointModal, { setTrue: showEndpointModal, setFalse: hideEndpointModal }] = useBoolean(false) - - const formSchemas = useMemo(() => { - return toolCredentialToFormSchemas([NAME_FIELD, ...declaration.settings]) - }, [declaration.settings]) - - const { mutate: createEndpoint } = useCreateEndpoint({ - onSuccess: async () => { - await invalidateEndpointList(detail.plugin_id) - invalidateInstalledPluginList(detail.declaration.category) - hideEndpointModal() - }, - onError: () => { - toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) - }, - }) - - const handleCreate = (state: Record) => - createEndpoint({ - pluginUniqueID, - state, - }) + const showSaveError = () => { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + } + const { mutate: createEndpoint, isPending } = useMutation( + consoleQuery.workspaces.current.endpoints.post.mutationOptions({ + onSuccess: hideEndpointModal, + onError: showSaveError, + }), + ) if (!data) return null @@ -124,22 +105,17 @@ const EndpointListContent = ({ declaration, detail }: EndpointListContentProps) )}
{data.endpoints.map((item) => ( - { - invalidateEndpointList(detail.plugin_id) - invalidateInstalledPluginList(detail.declaration.category) - }} - pluginDetail={detail} - /> + ))}
{isShowEndpointModal && ( + createEndpoint({ body: { ...body, plugin_unique_identifier: pluginUniqueID } }) + } + isPending={isPending} pluginDetail={detail} /> )} diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx index f9e9e1f0aa5..415de6447a5 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx @@ -1,6 +1,14 @@ 'use client' -import type { FC } from 'react' -import type { FormSchema } from '../../base/form/types' +import type { + EndpointProviderConfigI18nResponse, + EndpointProviderConfigResponse, + EndpointUpdatePayload, + ProviderConfigType, +} from '@dify/contracts/api/console/workspaces/types.gen' +import type { + CredentialFormSchema, + TypeWithI18N, +} from '../../header/account-setting/model-provider-page/declarations' import type { PluginDetail } from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' @@ -13,72 +21,126 @@ import { DrawerViewport, } from '@langgenius/dify-ui/drawer' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { RiArrowRightUpLine, RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' import { toast } from '@/app/notifications' import { useRenderI18nObject } from '@/hooks/use-i18n' +import { FormTypeEnum } from '../../header/account-setting/model-provider-page/declarations' import { ReadmeEntrance } from '../readme-panel/entrance' +const NAME_FIELD = { + type: 'text-input', + name: 'name', + label: { + en_US: 'Endpoint Name', + zh_Hans: '端点名称', + ja_JP: 'エンドポイント名', + pt_BR: 'Nome do ponto final', + }, + placeholder: { + en_US: 'Endpoint Name', + zh_Hans: '端点名称', + ja_JP: 'エンドポイント名', + pt_BR: 'Nome do ponto final', + }, + required: true, + default: '', + help: null, +} satisfies EndpointProviderConfigResponse + type Props = Readonly<{ - formSchemas: FormSchema[] - defaultValues?: any + settings: EndpointProviderConfigResponse[] + defaultValues?: Record onCancel: () => void - onSaved: (value: Record) => void + onSaved: (value: EndpointUpdatePayload) => void + isPending?: boolean pluginDetail: PluginDetail }> -const extractDefaultValues = (schemas: any[]) => { - const result: Record = {} - for (const field of schemas) { - if (field.default !== undefined) result[field.name] = field.default - } - return result +const fieldTypes: Record = { + 'text-input': FormTypeEnum.textInput, + 'secret-input': FormTypeEnum.secretInput, + select: FormTypeEnum.select, + boolean: FormTypeEnum.checkbox, + 'app-selector': FormTypeEnum.appSelector, + 'model-selector': FormTypeEnum.modelSelector, + 'array[tools]': FormTypeEnum.multiToolSelector, } -const EndpointModal: FC = ({ - formSchemas, - defaultValues = {}, +const toFormLabel = (label: EndpointProviderConfigI18nResponse): TypeWithI18N => ({ + en_US: label.en_US, + zh_Hans: label.zh_Hans ?? label.en_US, + pt_BR: label.pt_BR ?? label.en_US, + ja_JP: label.ja_JP ?? label.en_US, +}) + +const toFormSchema = (field: EndpointProviderConfigResponse): CredentialFormSchema => ({ + name: field.name, + variable: field.name, + type: fieldTypes[field.type], + label: toFormLabel(field.label ?? { en_US: field.name }), + required: field.required ?? false, + show_on: [], + tooltip: field.help ? toFormLabel(field.help) : undefined, + placeholder: field.placeholder ? toFormLabel(field.placeholder) : undefined, + scope: field.scope ?? undefined, + url: field.url ?? undefined, + options: (field.options ?? []).map((option) => ({ + value: option.value, + label: toFormLabel(option.label), + show_on: [], + })), +}) + +const EndpointModal = ({ + settings, + defaultValues, onCancel, onSaved, + isPending, pluginDetail, -}) => { +}: Props) => { const getValueFromI18nObject = useRenderI18nObject() const { t } = useTranslation() - const initialValues = - Object.keys(defaultValues).length > 0 ? defaultValues : extractDefaultValues(formSchemas) - const [tempCredential, setTempCredential] = React.useState(initialValues) + const fields = [NAME_FIELD, ...settings] + const formSchemas = fields.map(toFormSchema) + const [tempCredential, setTempCredential] = React.useState>(() => { + const values: Record = { + ...Object.fromEntries( + fields + .filter((field) => field.default !== undefined) + .map((field) => [field.name, field.default]), + ), + ...defaultValues, + } + for (const field of fields) { + const value = values[field.name] + if (field.type !== 'boolean' || (field.required && value === '')) continue + if (typeof value === 'string') + values[field.name] = value === 'true' || value === '1' || value === 'True' + else if (typeof value === 'number') values[field.name] = value === 1 + } + return values + }) const handleSave = () => { - for (const field of formSchemas) { - if (field.required && !tempCredential[field.name]) { + if (isPending) return + for (const field of fields) { + const value = tempCredential[field.name] + if (field.required && (value === undefined || value === null || value === '')) { toast.error( t(($) => $['errorMsg.fieldRequired'], { ns: 'common', - field: - typeof field.label === 'string' - ? field.label - : getValueFromI18nObject(field.label as Record), + field: field.label ? getValueFromI18nObject(toFormLabel(field.label)) : field.name, }), ) return } } - - // Fix: Process boolean fields to ensure they are sent as proper boolean values - const processedCredential = { ...tempCredential } - formSchemas.forEach((field: any) => { - if (field.type === 'boolean' && processedCredential[field.name] !== undefined) { - const value = processedCredential[field.name] - if (typeof value === 'string') - processedCredential[field.name] = value === 'true' || value === '1' || value === 'True' - else if (typeof value === 'number') processedCredential[field.name] = value === 1 - else if (typeof value === 'boolean') processedCredential[field.name] = value - } - }) - - onSaved(processedCredential) + const { name, ...values } = tempCredential + if (typeof name !== 'string' || name.length === 0) return + onSaved({ name, settings: values }) } return ( @@ -108,7 +170,7 @@ const EndpointModal: FC = ({ aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onCancel} > -
@@ -116,14 +178,20 @@ const EndpointModal: FC = ({
-
+
{ + event.preventDefault() + handleSave() + }} + >
{ setTempCredential(v) }} - formSchemas={formSchemas as any} + formSchemas={formSchemas} isEditMode={true} showOnVariableMap={{}} validating={false} @@ -137,7 +205,7 @@ const EndpointModal: FC = ({ className="inline-flex items-center body-xs-regular text-text-accent-secondary" > {t(($) => $.howToGet, { ns: 'tools' })} - + ) : null } @@ -148,12 +216,12 @@ const EndpointModal: FC = ({ -
- + diff --git a/web/app/components/plugins/plugin-detail-panel/endpoints.md b/web/app/components/plugins/plugin-detail-panel/endpoints.md new file mode 100644 index 00000000000..754b1f7d632 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/endpoints.md @@ -0,0 +1,73 @@ +# Plugin endpoints + +The endpoint list, card, and modal own the plugin endpoint management surface. +They consume generated `consoleQuery.workspaces.current.endpoints` options +directly. There is no endpoint service hook, handwritten cache key, or frontend +copy of an API response type. + +## API and types + +- [Endpoint controllers] + own request/response models and tenant, user, and permission enforcement. +- [Plugin endpoint client] + owns the daemon boundary. Successful writes return `true`; daemon failures + raise errors. An unexpected `false` acknowledgement is an error, not a second + successful HTTP response. Deleting an already removed endpoint remains + idempotent. +- Generated Console contracts own list items, provider configuration, and write + payloads. Create uses `POST /endpoints`, edit uses `PATCH /endpoints/{id}`, and + delete uses `DELETE /endpoints/{id}`. Enable and disable use their generated + action endpoints. Success keeps the existing `200 { success: true }` shape. +- The modal owns conversion from provider configuration to the existing form + schema and from form values to the generated settings payload. It preserves + `false`, `0`, and empty strings, and separates the name without mutating the + submitted object. Boolean fields interpret the declaration's string or numeric + defaults as boolean form values (`"true"`, `"True"`, `"1"`, and `1` mean true). + Missing and null values remain missing; a required empty value still fails validation. + An optional empty boolean default means false, as before; + other field types retain their original values. Optional declarations and nullable localized labels are + handled at this display boundary. + +When an API shape changes, update its Pydantic owner and regenerate TypeScript, +Zod, and OpenAPI Markdown. Do not widen generated unions, assert a different DTO, +or add a frontend compatibility type to hide a contract mismatch. + +## Queries and mutations + +| Decision | Owner and behavior | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| List identity | Generated input includes plugin ID, page `1`, and page size `100`; pagination behavior is unchanged. | +| Freshness and retries | Inherit the existing QueryClient defaults, including five-minute freshness. No endpoint-specific override is needed. | +| Rendering | The list queries when its endpoint surface is mounted. The card reads `enabled` from query data; pending changes disable the switch. | +| Shared invalidation | All five settled writes invalidate the endpoint list family and installed plugin list/category queries containing endpoint counts. | +| Invalidation scope | Update/delete/enable/disable only identify an endpoint, so their shared policy cannot safely infer its plugin. All endpoint list inputs are marked stale. Installed plugin IDs and unrelated tools remain untouched. | +| Failure | Rejected mutations refresh server state, retain editable form state, and do not close the form or retry the write. | +| Local callbacks | The surface owns toast, confirmation, and closing behavior; shared cache work stays in `query-policies.ts` `onSettled`. | + +Pending submit and confirmation actions use the Button loading contract and block +repeat submissions, including implicit form submission. + +Endpoint writes are not atomic across the daemon's database and follow-up work. +Create can install a record before later encryption fails; update/delete can +commit before credential-cache cleanup fails. A rejected request is therefore +not proof that server state is unchanged. All five mutation policies invalidate +on settlement, including errors, with the generated keys written inline in each +configuration. Local `onError` feedback remains separate; refreshing is a read, +not an automatic retry or a rollback of the write. Mutations retain TanStack +Query's default of no automatic retries. + +The endpoint list family uses `endpoints.list.key()`: `list.get.key()` would not +match `list.plugin.get`. Query data remains scoped by the application's existing +workspace-switch boundary; this module does not introduce a separate cache. + +## Verification + +Backend tests protect the success-only write contract, daemon failure handling, +delete idempotence, and generated schema. Frontend tests exercise generated +transport and real QueryClient invalidation alongside local callbacks, including +failure after a committed write and unchanged scalar settings. Surface tests protect form submission, +server-owned switch state, and failure recovery. Coverage percentage is not an +acceptance criterion. + +[Endpoint controllers]: ../../../../../api/controllers/console/workspace/endpoint.py +[Plugin endpoint client]: ../../../../../api/core/plugin/impl/endpoint.py diff --git a/web/app/components/plugins/plugin-detail-panel/utils.ts b/web/app/components/plugins/plugin-detail-panel/utils.ts deleted file mode 100644 index fd51142a38b..00000000000 --- a/web/app/components/plugins/plugin-detail-panel/utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' - -export const NAME_FIELD = { - type: FormTypeEnum.textInput, - name: 'name', - label: { - en_US: 'Endpoint Name', - zh_Hans: '端点名称', - ja_JP: 'エンドポイント名', - pt_BR: 'Nome do ponto final', - }, - placeholder: { - en_US: 'Endpoint Name', - zh_Hans: '端点名称', - ja_JP: 'エンドポイント名', - pt_BR: 'Nome do ponto final', - }, - required: true, - default: '', - help: null, -} diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts index 87edd55b329..5a117d98335 100644 --- a/web/app/components/plugins/types.ts +++ b/web/app/components/plugins/types.ts @@ -1,3 +1,4 @@ +import type { EndpointProviderDeclarationResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { FormTypeEnum } from '../base/form/types' import type { CredentialFormSchemaBase } from '../header/account-setting/model-provider-page/declarations' import type { AutoUpdateConfig } from './reference-setting-modal/auto-update-setting/types' @@ -38,32 +39,6 @@ type PluginToolDeclaration = { credentials_schema: ToolCredential[] // TODO } -type PluginEndpointDeclaration = { - settings: ToolCredential[] - endpoints?: EndpointItem[] | null -} - -type EndpointItem = { - path: string - method: string - hidden?: boolean -} - -export type EndpointListItem = { - id: string - created_at: string - updated_at: string - settings: Record - tenant_id: string - plugin_id: string - expired_at: string - declaration: PluginEndpointDeclaration - name: string - enabled: boolean - url: string - hook_id: string -} - type PluginDeclarationMeta = { version: string minimum_dify_version?: string @@ -84,7 +59,7 @@ export type PluginDeclaration = { resource: any // useless in frontend plugins: any // useless in frontend verified: boolean - endpoint?: PluginEndpointDeclaration | null + endpoint?: EndpointProviderDeclarationResponse | null tool?: PluginToolDeclaration datasource?: PluginToolDeclaration model: any @@ -349,14 +324,6 @@ export type GitHubUrlInfo = { repo?: string } -// endpoint -export type EndpointsResponse = { - endpoints: EndpointListItem[] - has_more: boolean - limit: number - total: number - page: number -} export const InstallStep = { uploading: 'uploading', uploadFailed: 'uploadFailed', diff --git a/web/service/__tests__/use-plugins.spec.tsx b/web/service/__tests__/use-plugins.spec.tsx index 0a8a2a8edce..57b504a7991 100644 --- a/web/service/__tests__/use-plugins.spec.tsx +++ b/web/service/__tests__/use-plugins.spec.tsx @@ -312,6 +312,33 @@ describe('normalizeInstalledPluginDetail', () => { expect(categoryDetail.plugin_id).toBe('langgenius/category-plugin') }) + it('preserves endpoint setting types at the dynamic plugin manifest boundary', () => { + const plugin = createPluginInstallation() + plugin.declaration.endpoint = { + settings: [ + { name: 'enabled', type: 'boolean', default: false, required: true, label: null }, + { + name: 'count', + type: 'text-input', + default: '0', + placeholder: { en_US: 'Count', zh_Hans: null }, + }, + ], + endpoints: null, + } + const detail = normalizeInstalledPluginDetail(plugin) + + expect(detail.declaration.endpoint?.settings?.[0]).toMatchObject({ + default: false, + label: null, + }) + expect(detail.declaration.endpoint?.settings?.[1]).toMatchObject({ + default: '0', + placeholder: { zh_Hans: null }, + }) + expect(detail.declaration.endpoint?.endpoints).toBeNull() + }) + it('should preserve generated plugin declaration capabilities', () => { const detail = normalizeInstalledPluginDetail(createPluginInstallation()) diff --git a/web/service/console/README.md b/web/service/console/README.md index 363cb02a1ad..7eee3b0bb37 100644 --- a/web/service/console/README.md +++ b/web/service/console/README.md @@ -17,6 +17,25 @@ transport for the Python Console API. - Routes/layouts own prefetching and hydration. Use the existing [QueryClient factory]. +## Policy ownership + +Shared operation defaults belong in `query-policies.ts`. Call sites own inputs, +execution conditions, projections, and interaction feedback. A local option +overrides the corresponding default; callbacks are not composed automatically. +When a mutation uses shared `onSettled` invalidation, keep local toast, close, and +navigation behavior in `onSuccess` / `onError`. Do not override `onSettled` without +preserving that cache contract. The returned invalidation promise keeps the +mutation pending until active consumers have refreshed. + +List affected generated keys inline in the owning operation's policy. Whether +invalidation runs after an error depends on the write contract: a rejected +request does not imply the server rolled back. Record that decision per module. +Do not add an invalidation helper to hide the cache dependencies. + +Use generated options and types directly. A feature-owned options factory is +appropriate only when it owns a shared request policy or a composed query; +forwarding hooks and handwritten API DTOs create a second owner. + [Browser transport]: ./browser.ts [Instrumentation]: ../../instrumentation.ts [QueryClient factory]: ../../app/get-query-client.ts diff --git a/web/service/console/endpoint-policies.spec.ts b/web/service/console/endpoint-policies.spec.ts new file mode 100644 index 00000000000..8277eb5460f --- /dev/null +++ b/web/service/console/endpoint-policies.spec.ts @@ -0,0 +1,171 @@ +import type { QueryKey } from '@tanstack/react-query' +import { MutationObserver, QueryClient } from '@tanstack/react-query' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { consoleQuery } from './index' + +const { request } = vi.hoisted(() => ({ + request: + vi.fn<(url: string, init: RequestInit, options: { request: Request }) => Promise>(), +})) + +vi.mock('../base', () => ({ request })) + +const endpoints = consoleQuery.workspaces.current.endpoints +const plugins = consoleQuery.workspaces.current.plugin + +const mutations = [ + { + name: 'create', + method: 'POST', + path: '/endpoints', + run: (client: QueryClient, onSuccess: () => void) => + new MutationObserver(client, endpoints.post.mutationOptions({ onSuccess })).mutate({ + body: { name: 'Endpoint', plugin_unique_identifier: 'plugin@1.0', settings: {} }, + }), + }, + { + name: 'update', + method: 'PATCH', + path: '/endpoints/endpoint-1', + run: (client: QueryClient, onSuccess: () => void) => + new MutationObserver(client, endpoints.byId.patch.mutationOptions({ onSuccess })).mutate({ + params: { id: 'endpoint-1' }, + body: { name: 'Renamed', settings: { enabled: false, limit: 0, token: '' } }, + }), + }, + { + name: 'delete', + method: 'DELETE', + path: '/endpoints/endpoint-1', + run: (client: QueryClient, onSuccess: () => void) => + new MutationObserver(client, endpoints.byId.delete.mutationOptions({ onSuccess })).mutate({ + params: { id: 'endpoint-1' }, + }), + }, + { + name: 'enable', + method: 'POST', + path: '/endpoints/enable', + run: (client: QueryClient, onSuccess: () => void) => + new MutationObserver(client, endpoints.enable.post.mutationOptions({ onSuccess })).mutate({ + body: { endpoint_id: 'endpoint-1' }, + }), + }, + { + name: 'disable', + method: 'POST', + path: '/endpoints/disable', + run: (client: QueryClient, onSuccess: () => void) => + new MutationObserver(client, endpoints.disable.post.mutationOptions({ onSuccess })).mutate({ + body: { endpoint_id: 'endpoint-1' }, + }), + }, +] + +function seedEndpointCaches(client: QueryClient) { + const affectedKeys: QueryKey[] = [ + endpoints.list.get.queryKey({ input: { query: { page: 1, page_size: 100 } } }), + endpoints.list.plugin.get.queryKey({ + input: { query: { page: 1, page_size: 100, plugin_id: 'plugin-1' } }, + }), + endpoints.list.plugin.get.queryKey({ + input: { query: { page: 2, page_size: 20, plugin_id: 'plugin-2' } }, + }), + plugins.list.get.key({ type: 'query' }), + plugins.byCategory.list.get.key({ + type: 'infinite', + input: { params: { category: 'extension' } }, + }), + ] + const unrelatedKeys: QueryKey[] = [ + plugins.installedIds.get.key({ type: 'query' }), + consoleQuery.account.profile.get.key({ type: 'query' }), + ] + + for (const queryKey of [...affectedKeys, ...unrelatedKeys]) + client.setQueryData(queryKey, { cached: true }) + + return { affectedKeys, unrelatedKeys } +} + +describe('endpoint mutation cache policy', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it.each(mutations)( + '$name refreshes endpoint lists and plugin counts with local feedback', + async ({ run, method, path }) => { + const client = new QueryClient() + const { affectedKeys, unrelatedKeys } = seedEndpointCaches(client) + const onSuccess = vi.fn() + request.mockResolvedValue(Response.json({ success: true })) + + await run(client, onSuccess) + + expect(onSuccess).toHaveBeenCalledOnce() + expect(request).toHaveBeenCalledWith( + expect.stringContaining(`/workspaces/current${path}`), + expect.any(Object), + expect.objectContaining({ request: expect.objectContaining({ method }) }), + ) + if (method === 'PATCH') { + const sentRequest = request.mock.calls[0]?.[2].request + expect(await sentRequest?.json()).toEqual({ + name: 'Renamed', + settings: { enabled: false, limit: 0, token: '' }, + }) + } + for (const key of affectedKeys) expect(client.getQueryState(key)?.isInvalidated).toBe(true) + for (const key of unrelatedKeys) expect(client.getQueryState(key)?.isInvalidated).toBe(false) + client.clear() + }, + ) + + it.each(mutations)( + '$name invalidates uncertain server state and skips success feedback on failure', + async ({ run }) => { + const client = new QueryClient() + const { affectedKeys } = seedEndpointCaches(client) + const onSuccess = vi.fn() + request.mockResolvedValue( + Response.json({ message: 'Endpoint operation failed' }, { status: 500 }), + ) + + try { + await expect(run(client, onSuccess)).rejects.toThrow() + expect(onSuccess).not.toHaveBeenCalled() + for (const key of affectedKeys) expect(client.getQueryState(key)?.isInvalidated).toBe(true) + } finally { + client.clear() + } + }, + ) + + it('reconciles after a lost response without retrying the create', async () => { + const client = new QueryClient() + const { affectedKeys, unrelatedKeys } = seedEndpointCaches(client) + const onSuccess = vi.fn() + request.mockRejectedValue(new TypeError('Failed to fetch')) + const mutation = new MutationObserver(client, endpoints.post.mutationOptions({ onSuccess })) + + try { + await expect( + mutation.mutate({ + body: { name: 'Endpoint', plugin_unique_identifier: 'plugin@1.0', settings: {} }, + }), + ).rejects.toThrow('Failed to fetch') + expect(request).toHaveBeenCalledOnce() + expect(onSuccess).not.toHaveBeenCalled() + for (const key of affectedKeys) expect(client.getQueryState(key)?.isInvalidated).toBe(true) + for (const key of unrelatedKeys) expect(client.getQueryState(key)?.isInvalidated).toBe(false) + } finally { + client.clear() + } + }) +}) diff --git a/web/service/console/query-policies.ts b/web/service/console/query-policies.ts index 2a6f97db8a7..8e3b4f56f66 100644 --- a/web/service/console/query-policies.ts +++ b/web/service/console/query-policies.ts @@ -15,6 +15,99 @@ export function createConsoleQuery(consoleClient: ConsoleClient) { experimental_defaults: { workspaces: { current: { + endpoints: { + post: { + mutationOptions: { + onSettled: (_data, _error, _variables, _result, context) => { + return Promise.all([ + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.endpoints.list.key(), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.plugin.list.get.key(), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.plugin.byCategory.list.get.key(), + }), + ]) + }, + }, + }, + byId: { + patch: { + mutationOptions: { + onSettled: (_data, _error, _variables, _result, context) => { + return Promise.all([ + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.endpoints.list.key(), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.plugin.list.get.key(), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.plugin.byCategory.list.get.key(), + }), + ]) + }, + }, + }, + delete: { + mutationOptions: { + onSettled: (_data, _error, _variables, _result, context) => { + return Promise.all([ + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.endpoints.list.key(), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.plugin.list.get.key(), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.plugin.byCategory.list.get.key(), + }), + ]) + }, + }, + }, + }, + enable: { + post: { + mutationOptions: { + onSettled: (_data, _error, _variables, _result, context) => { + return Promise.all([ + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.endpoints.list.key(), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.plugin.list.get.key(), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.plugin.byCategory.list.get.key(), + }), + ]) + }, + }, + }, + }, + disable: { + post: { + mutationOptions: { + onSettled: (_data, _error, _variables, _result, context) => { + return Promise.all([ + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.endpoints.list.key(), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.plugin.list.get.key(), + }), + context.client.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.plugin.byCategory.list.get.key(), + }), + ]) + }, + }, + }, + }, + }, skills: { bySkillId: { delete: { diff --git a/web/service/use-endpoints.ts b/web/service/use-endpoints.ts deleted file mode 100644 index 14db1f7a1d6..00000000000 --- a/web/service/use-endpoints.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { EndpointsResponse } from '@/app/components/plugins/types' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { get, post } from './base' - -const NAME_SPACE = 'endpoints' - -export const useEndpointList = (pluginID: string) => { - return useQuery({ - queryKey: [NAME_SPACE, 'list', pluginID], - queryFn: () => - get('/workspaces/current/endpoints/list/plugin', { - params: { - plugin_id: pluginID, - page: 1, - page_size: 100, - }, - }), - }) -} - -export const useInvalidateEndpointList = () => { - const queryClient = useQueryClient() - return (pluginID: string) => { - queryClient.invalidateQueries({ - queryKey: [NAME_SPACE, 'list', pluginID], - }) - } -} - -export const useCreateEndpoint = ({ - onSuccess, - onError, -}: { - onSuccess?: () => void - onError?: (error: any) => void -}) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'create'], - mutationFn: (payload: { pluginUniqueID: string; state: Record }) => { - const { pluginUniqueID, state } = payload - const newName = state.name - delete state.name - return post('/workspaces/current/endpoints/create', { - body: { - plugin_unique_identifier: pluginUniqueID, - settings: state, - name: newName, - }, - }) - }, - onSuccess, - onError, - }) -} - -export const useUpdateEndpoint = ({ - onSuccess, - onError, -}: { - onSuccess?: () => void - onError?: (error: any) => void -}) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'update'], - mutationFn: (payload: { endpointID: string; state: Record }) => { - const { endpointID, state } = payload - const newName = state.name - delete state.name - return post('/workspaces/current/endpoints/update', { - body: { - endpoint_id: endpointID, - settings: state, - name: newName, - }, - }) - }, - onSuccess, - onError, - }) -} - -export const useDeleteEndpoint = ({ - onSuccess, - onError, -}: { - onSuccess?: () => void - onError?: (error: any) => void -}) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'delete'], - mutationFn: (endpointID: string) => { - return post('/workspaces/current/endpoints/delete', { - body: { - endpoint_id: endpointID, - }, - }) - }, - onSuccess, - onError, - }) -} - -export const useEnableEndpoint = ({ - onSuccess, - onError, -}: { - onSuccess?: () => void - onError?: (error: any) => void -}) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'enable'], - mutationFn: (endpointID: string) => { - return post('/workspaces/current/endpoints/enable', { - body: { - endpoint_id: endpointID, - }, - }) - }, - onSuccess, - onError, - }) -} - -export const useDisableEndpoint = ({ - onSuccess, - onError, -}: { - onSuccess?: () => void - onError?: (error: any) => void -}) => { - return useMutation({ - mutationKey: [NAME_SPACE, 'disable'], - mutationFn: (endpointID: string) => { - return post('/workspaces/current/endpoints/disable', { - body: { - endpoint_id: endpointID, - }, - }) - }, - onSuccess, - onError, - }) -} diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 12bb54648b4..876faa038c0 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -34,6 +34,7 @@ import type { VersionListResponse, } from '@/app/components/plugins/types' import type { Collection } from '@/app/components/tools/types' +import { zEndpointProviderDeclarationResponse } from '@dify/contracts/api/console/workspaces/zod.gen' import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { cloneDeep } from 'es-toolkit/object' import { useAtomValue } from 'jotai' @@ -222,19 +223,6 @@ const normalizePluginToolDeclaration = (value: unknown): PluginDeclaration['tool } } -const normalizePluginEndpointDeclaration = (value: unknown): PluginDeclaration['endpoint'] => { - if (!isRecord(value)) return undefined - - return { - settings: getRecordArray(value, 'settings').map(normalizeToolCredential), - endpoints: getRecordArray(value, 'endpoints').map((endpoint) => ({ - path: getString(endpoint.path), - method: getString(endpoint.method), - hidden: endpoint.hidden === undefined ? undefined : getBoolean(endpoint.hidden), - })), - } -} - const normalizeParameterDefault = (value: unknown) => { if (Array.isArray(value)) return value.filter((item) => typeof item === 'string') if (typeof value === 'string') return value @@ -440,7 +428,9 @@ const normalizePluginDeclaration = (plugin: InstalledPluginResponse): PluginDecl resource: declaration.resource, plugins: declaration.plugins, verified: declaration.verified ?? false, - endpoint: normalizePluginEndpointDeclaration(declaration.endpoint), + endpoint: isRecord(declaration.endpoint) + ? zEndpointProviderDeclarationResponse.parse(declaration.endpoint) + : undefined, tool: normalizePluginToolDeclaration(declaration.tool), datasource: normalizePluginToolDeclaration(declaration.datasource), model: declaration.model,