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
= ({
-
-
+
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,