refactor(plugins): migrate endpoint queries to generated contracts (#42685)

This commit is contained in:
yyh authored and GitHub committed 2026-09-22 05:54:58 +00:00
1 parent f7dbed5d86
commit 6d358eb680
26 files changed
+1240 -1689

No files matched your search

+28 -25
View File
@@ -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")
+26 -11
View File
@@ -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
+14 -8
View File
@@ -10067,7 +10067,7 @@ Create a new plugin endpoint
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint created successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 200 | Endpoint created successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
| 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)<br> |
| 200 | Endpoint created successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
| 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)<br> |
| 200 | Endpoint deleted successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
| 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)<br> |
| 200 | Endpoint disabled successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
| 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)<br> |
| 200 | Endpoint enabled successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
| 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)<br> |
| 200 | Endpoint updated successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
| 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)<br> |
| 200 | Endpoint deleted successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
| 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)<br> |
| 200 | Endpoint updated successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
| 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 |
+8 -6
View File
@@ -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,
@@ -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:
@@ -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:
+1 -22
View File
@@ -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
@@ -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 =
@@ -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
@@ -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 }) => (
<span data-testid="indicator" data-status={status} />
),
}))
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 }) => (
<div data-testid="endpoint-modal">
<button data-testid="modal-cancel" onClick={onCancel}>
Cancel
</button>
<button data-testid="modal-save" onClick={() => onSaved({ name: 'Updated' })}>
Save
</button>
</div>
),
}))
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(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
expect(screen.getByText('Test Endpoint'))!.toBeInTheDocument()
})
it('should render visible endpoints only', () => {
render(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
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(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
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(
<EndpointCard
pluginDetail={mockPluginDetail}
data={disabledData}
handleChange={mockHandleChange}
/>,
)
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(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
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(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
fireEvent.click(screen.getByRole('switch'))
expect(screen.getByText('plugin.detailPanel.endpointDisableTip'))!.toBeInTheDocument()
})
it('should call disableEndpoint when confirm disable', () => {
render(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
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(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
fireEvent.click(getDeleteButton())
expect(screen.getByText('plugin.detailPanel.endpointDeleteTip'))!.toBeInTheDocument()
})
it('should call deleteEndpoint when confirm delete', () => {
render(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
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(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
fireEvent.click(getEditButton())
expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument()
})
it('should call updateEndpoint when save in modal', () => {
render(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
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(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
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(
<EndpointCard
pluginDetail={mockPluginDetail}
data={dataWithNoEndpoints}
handleChange={mockHandleChange}
/>,
)
expect(screen.getByText('Test Endpoint'))!.toBeInTheDocument()
})
it('should call handleChange after enable', () => {
const disabledData = { ...mockEndpointData, enabled: false }
render(
<EndpointCard
pluginDetail={mockPluginDetail}
data={disabledData}
handleChange={mockHandleChange}
/>,
)
fireEvent.click(screen.getByRole('switch'))
expect(mockHandleChange).toHaveBeenCalled()
})
it('should hide disable confirm and revert state when cancel clicked', async () => {
render(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
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(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
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(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
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(
<EndpointCard
pluginDetail={mockPluginDetail}
data={disabledData}
handleChange={mockHandleChange}
/>,
)
fireEvent.click(screen.getByRole('switch'))
expect(mockEnableEndpoint).toHaveBeenCalled()
})
it('should show error toast when disable fails', () => {
failureFlags.disable = true
render(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
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(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
fireEvent.click(getDeleteButton())
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockDeleteEndpoint).toHaveBeenCalled()
})
it('should show error toast when update fails', () => {
render(
<EndpointCard
pluginDetail={mockPluginDetail}
data={mockEndpointData}
handleChange={mockHandleChange}
/>,
)
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()
})
})
})
@@ -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: [],
},
},
})
@@ -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 } }) => (
<div data-testid="endpoint-card">{data.name}</div>
),
}))
vi.mock('../endpoint-modal', () => ({
default: ({ onCancel, onSaved }: { onCancel: () => void; onSaved: (state: unknown) => void }) => (
<div data-testid="endpoint-modal">
<button data-testid="modal-cancel" onClick={onCancel}>
Cancel
</button>
<button data-testid="modal-save" onClick={() => onSaved({ name: 'New Endpoint' })}>
Save
</button>
</div>
),
}))
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<void> | undefined
const mutations: { path: string; method: string; body: unknown }[] = []
beforeEach(() => {
vi.clearAllMocks()
mockEndpointListData = { endpoints: mockEndpoints }
const renderEndpoints = () => render(<EndpointList detail={detail} />)
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(<EndpointList detail={createPluginDetail()} />)
expect(screen.getByText('plugin.detailPanel.endpoints'))!.toBeInTheDocument()
})
it('should render endpoint cards', () => {
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getByTestId('endpoint-card'))!.toBeInTheDocument()
expect(screen.getByText('Endpoint 1'))!.toBeInTheDocument()
})
it('should return null when no data', () => {
mockEndpointListData = undefined
const { container } = render(<EndpointList detail={createPluginDetail()} />)
expect(container)!.toBeEmptyDOMElement()
})
it('should show empty message when no endpoints', () => {
mockEndpointListData = { endpoints: [] }
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getByText('plugin.detailPanel.endpointsEmpty'))!.toBeInTheDocument()
})
it('should render add button', () => {
render(<EndpointList detail={createPluginDetail()} />)
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(<EndpointList detail={createPluginDetail()} />)
fireEvent.click(getAddButton())
expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument()
})
it('should hide modal when cancel clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
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(<EndpointList detail={createPluginDetail()} />)
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(<EndpointList detail={detail} />)
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(<EndpointList detail={createPluginDetail()} />)
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(<EndpointList detail={createPluginDetail()} />)
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<void>((resolve) => {
settle = resolve
})
})
it('should pass correct params to createEndpoint', () => {
render(<EndpointList detail={createPluginDetail()} />)
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)
})
})
@@ -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, string> | 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<string, unknown>
onChange: (v: Record<string, unknown>) => void
fieldMoreInfo?: (item: { url?: string }) => React.ReactNode
}) => {
return (
<div data-testid="form">
<input
data-testid="form-input"
value={(value.name as string) || ''}
onChange={(e) => onChange({ ...value, name: e.target.value })}
/>
{/* Render fieldMoreInfo to test url link */}
{fieldMoreInfo && (
<div data-testid="field-more-info">
{fieldMoreInfo({ url: 'https://example.com' })}
{fieldMoreInfo({})}
</div>
)}
</div>
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(
<EndpointModal
pluginDetail={mockPluginDetail}
onCancel={vi.fn()}
onSaved={onSaved}
settings={[
{ name: 'enabled', type: 'boolean', required: true, default: false },
{ name: 'limit', type: 'text-input', required: true, default: '0', label: null },
]}
/>,
)
},
}))
vi.mock('../../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
}))
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(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
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(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByText('plugin.detailPanel.endpointModalTitle'))!.toBeInTheDocument()
expect(screen.getByText('plugin.detailPanel.endpointModalDesc'))!.toBeInTheDocument()
})
it('should render form with fieldMoreInfo url link', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('field-more-info'))!.toBeInTheDocument()
expect(screen.getByText('tools.howToGet'))!.toBeInTheDocument()
})
it('should render readme entrance', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
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(
<EndpointModal
pluginDetail={mockPluginDetail}
onCancel={vi.fn()}
onSaved={onSaved}
defaultValues={{ name: 'Typed endpoint' }}
settings={[{ name: 'enabled', type: 'boolean', required: true, default: value }]}
/>,
)
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(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when close button clicked', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
const allButtons = screen.getAllByRole('button')
fireEvent.click(allButtons[0]!)
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should update form value when input changes', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
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(
<EndpointModal
pluginDetail={mockPluginDetail}
onCancel={vi.fn()}
onSaved={onSaved}
defaultValues={{ name: 'Optional endpoint' }}
settings={[{ name: 'enabled', type: 'boolean', default: '' }]}
/>,
)
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(
<EndpointModal
formSchemas={mockFormSchemas}
defaultValues={{ name: 'Default Name' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
it('preserves a nullable optional boolean and rejects an empty required boolean', async () => {
const user = userEvent.setup()
const onSaved = vi.fn()
const { rerender } = render(
<EndpointModal
pluginDetail={mockPluginDetail}
onCancel={vi.fn()}
onSaved={onSaved}
defaultValues={{ name: 'Nullable endpoint' }}
settings={[{ name: 'enabled', type: 'boolean', default: null }]}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSaved).toHaveBeenCalledWith({ name: 'Nullable endpoint', settings: { enabled: null } })
onSaved.mockClear()
rerender(
<EndpointModal
key="required"
pluginDetail={mockPluginDetail}
onCancel={vi.fn()}
onSaved={onSaved}
defaultValues={{ name: 'Required endpoint', enabled: '' }}
settings={[{ name: 'enabled', type: 'boolean', required: true, default: true }]}
/>,
)
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(
<EndpointModal
pluginDetail={mockPluginDetail}
onCancel={vi.fn()}
onSaved={onSaved}
defaultValues={defaults}
settings={[
{
name: 'token',
type: 'secret-input',
default: 'fallback',
label: { en_US: 'Token', zh_Hans: null },
placeholder: { en_US: 'Token' },
},
]}
/>,
)
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(
<EndpointModal
formSchemas={schemasWithDefaults}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
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(
<EndpointModal
formSchemas={schemasNoDefault}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
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(
<EndpointModal
formSchemas={schemasWithRequired}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
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(
<EndpointModal
formSchemas={schemasWithStringLabel}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
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(
<EndpointModal
formSchemas={mockFormSchemas}
defaultValues={{ name: 'Valid Name' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
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(
<EndpointModal
formSchemas={schemasOptional}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
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(
<EndpointModal
pluginDetail={mockPluginDetail}
onCancel={vi.fn()}
onSaved={onSaved}
settings={[]}
/>,
)
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(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: input }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
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(
<EndpointModal
formSchemas={schemasWithText}
defaultValues={{ text: 'hello' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
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(
<EndpointModal
pluginDetail={mockPluginDetail}
onCancel={vi.fn()}
onSaved={onSaved}
settings={[]}
defaultValues={{ name: 'Pending endpoint' }}
isPending
/>,
)
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()
})
})
@@ -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,
})
})
})
})
@@ -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<typeof EndpointModal>['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<string, unknown>) =>
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) => {
</IconButton>
</div>
</div>
{(data.declaration.endpoints ?? [])
{(data.declaration?.endpoints ?? [])
.filter((endpoint) => !endpoint.hidden)
.map((endpoint, index) => (
<div key={index} className="flex h-6 items-center">
.map((endpoint) => (
<div key={`${endpoint.method}:${endpoint.path}`} className="flex h-6 items-center">
<div className="w-12 shrink-0 system-xs-regular text-text-tertiary">
{endpoint.method}
</div>
@@ -207,21 +150,30 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => {
))}
</div>
<div className="flex items-center justify-between p-2 pl-3">
{active && (
{data.enabled && (
<div className="flex items-center gap-1 system-xs-semibold-uppercase text-util-colors-green-green-600">
<StatusDot status="success" />
{t(($) => $['detailPanel.serviceOk'], { ns: 'plugin' })}
</div>
)}
{!active && (
{!data.enabled && (
<div className="flex items-center gap-1 system-xs-semibold-uppercase text-text-tertiary">
<StatusDot status="disabled" />
{t(($) => $['detailPanel.disabled'], { ns: 'plugin' })}
</div>
)}
<Switch className="ml-3" checked={active} onCheckedChange={handleSwitch} size="sm" />
<Switch
className="ml-3"
checked={data.enabled}
onCheckedChange={handleSwitch}
disabled={isEnabling || isDisabling}
size="sm"
/>
</div>
<AlertDialog open={isShowDisableConfirm} onOpenChange={handleDisableConfirmOpenChange}>
<AlertDialog
open={isShowDisableConfirm}
onOpenChange={(open) => !open && hideDisableConfirm()}
>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
@@ -235,7 +187,10 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => {
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={() => disableEndpoint(endpointID)}>
<AlertDialogConfirmButton
loading={isDisabling}
onClick={() => disableEndpoint({ body: { endpoint_id: endpointID } })}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
@@ -255,7 +210,10 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => {
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={() => deleteEndpoint(endpointID)}>
<AlertDialogConfirmButton
loading={isDeleting}
onClick={() => deleteEndpoint({ params: { id: endpointID } })}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
@@ -263,10 +221,11 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => {
</AlertDialog>
{isShowEndpointModal && (
<EndpointModal
formSchemas={formSchemas as EndpointModalFormSchemas}
defaultValues={formValue}
settings={data.declaration?.settings ?? []}
defaultValues={{ ...data.settings, name: data.name }}
onCancel={hideEndpointModalConfirm}
onSaved={handleUpdate}
onSaved={(body) => updateEndpoint({ params: { id: endpointID }, body })}
isPending={isUpdating}
pluginDetail={pluginDetail}
/>
)}
@@ -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<PluginDetail['declaration']['endpoint']>
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<string, any>) =>
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)
)}
<div className="flex flex-col gap-2">
{data.endpoints.map((item) => (
<EndpointCard
key={item.id}
data={item}
handleChange={() => {
invalidateEndpointList(detail.plugin_id)
invalidateInstalledPluginList(detail.declaration.category)
}}
pluginDetail={detail}
/>
<EndpointCard key={item.id} data={item} pluginDetail={detail} />
))}
</div>
{isShowEndpointModal && (
<EndpointModal
formSchemas={formSchemas as any}
settings={declaration.settings ?? []}
onCancel={hideEndpointModal}
onSaved={handleCreate}
onSaved={(body) =>
createEndpoint({ body: { ...body, plugin_unique_identifier: pluginUniqueID } })
}
isPending={isPending}
pluginDetail={detail}
/>
)}
@@ -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<string, unknown>
onCancel: () => void
onSaved: (value: Record<string, any>) => void
onSaved: (value: EndpointUpdatePayload) => void
isPending?: boolean
pluginDetail: PluginDetail
}>
const extractDefaultValues = (schemas: any[]) => {
const result: Record<string, any> = {}
for (const field of schemas) {
if (field.default !== undefined) result[field.name] = field.default
}
return result
const fieldTypes: Record<ProviderConfigType, FormTypeEnum> = {
'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<Props> = ({
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<any>(initialValues)
const fields = [NAME_FIELD, ...settings]
const formSchemas = fields.map(toFormSchema)
const [tempCredential, setTempCredential] = React.useState<Record<string, unknown>>(() => {
const values: Record<string, unknown> = {
...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<string, string>),
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<Props> = ({
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
onClick={onCancel}
>
<RiCloseLine aria-hidden="true" className="size-4" />
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
</div>
<div className="mt-0.5 system-xs-regular text-text-tertiary">
@@ -116,14 +178,20 @@ const EndpointModal: FC<Props> = ({
</div>
<ReadmeEntrance pluginDetail={pluginDetail} className="px-0 pt-3" />
</div>
<div className="grow overflow-y-auto">
<form
className="grow overflow-y-auto"
onSubmit={(event) => {
event.preventDefault()
handleSave()
}}
>
<div className="px-4 py-2">
<Form
value={tempCredential}
onChange={(v) => {
setTempCredential(v)
}}
formSchemas={formSchemas as any}
formSchemas={formSchemas}
isEditMode={true}
showOnVariableMap={{}}
validating={false}
@@ -137,7 +205,7 @@ const EndpointModal: FC<Props> = ({
className="inline-flex items-center body-xs-regular text-text-accent-secondary"
>
{t(($) => $.howToGet, { ns: 'tools' })}
<RiArrowRightUpLine className="ml-1 size-3" />
<span aria-hidden className="ml-1 i-ri-arrow-right-up-line size-3" />
</a>
) : null
}
@@ -148,12 +216,12 @@ const EndpointModal: FC<Props> = ({
<Button onClick={onCancel}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button variant="primary" onClick={handleSave}>
<Button variant="primary" type="submit" loading={isPending}>
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
</div>
</div>
</form>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
@@ -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
@@ -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,
}
+2 -35
View File
@@ -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<string, any>
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',
@@ -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())
+19
View File
@@ -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
@@ -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<Response>>(),
}))
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()
}
})
})
+93
View File
@@ -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: {
-143
View File
@@ -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<EndpointsResponse>('/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<string, any> }) => {
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<string, any> }) => {
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,
})
}
+4 -14
View File
@@ -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,