feat(dify-ui): add shared form primitives (#36334)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh
2026-05-19 05:38:57 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 7f392b6950
commit 04d62867af
51 changed files with 1824 additions and 557 deletions
+22 -2
View File
@@ -70,6 +70,21 @@ def _serialize_api_based_extension(extension: APIBasedExtension) -> dict[str, An
return APIBasedExtensionResponse.model_validate(extension, from_attributes=True).model_dump(mode="json")
def _serialize_saved_api_based_extension(extension: APIBasedExtension, api_key: str) -> dict[str, Any]:
"""Serialize a saved extension with the plaintext key used for response masking only.
APIBasedExtensionService.save mutates the ORM object to hold the encrypted token before returning it. The response
contract, however, should match list/detail responses, where api_key is masked from the decrypted token.
"""
return APIBasedExtensionResponse(
id=extension.id,
name=extension.name,
api_endpoint=extension.api_endpoint,
api_key=api_key,
created_at=to_timestamp(extension.created_at),
).model_dump(mode="json")
@console_ns.route("/code-based-extension")
class CodeBasedExtensionAPI(Resource):
@console_ns.doc("get_code_based_extension")
@@ -125,7 +140,7 @@ class APIBasedExtensionAPI(Resource):
api_key=payload.api_key,
)
return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data))
return _serialize_saved_api_based_extension(APIBasedExtensionService.save(extension_data), payload.api_key), 201
@console_ns.route("/api-based-extension/<uuid:id>")
@@ -160,14 +175,19 @@ class APIBasedExtensionDetailAPI(Resource):
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(current_tenant_id, api_based_extension_id)
payload = APIBasedExtensionPayload.model_validate(console_ns.payload or {})
api_key_for_response = extension_data_from_db.api_key
extension_data_from_db.name = payload.name
extension_data_from_db.api_endpoint = payload.api_endpoint
if payload.api_key != HIDDEN_VALUE:
extension_data_from_db.api_key = payload.api_key
api_key_for_response = payload.api_key
return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data_from_db))
return _serialize_saved_api_based_extension(
APIBasedExtensionService.save(extension_data_from_db),
api_key_for_response,
)
@console_ns.doc("delete_api_based_extension")
@console_ns.doc(description="Delete API-based extension")
@@ -0,0 +1,126 @@
"""Integration tests for console API-based extension endpoints using testcontainers."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from flask.testing import FlaskClient
from sqlalchemy.orm import Session
from constants import HIDDEN_VALUE
from libs.rsa import generate_key_pair
from models import Tenant
from tests.test_containers_integration_tests.controllers.console.helpers import (
authenticate_console_client,
create_console_account_and_tenant,
)
def _masked_api_key(api_key: str) -> str:
if len(api_key) <= 8:
return api_key[0] + "******" + api_key[-1]
return api_key[:3] + "******" + api_key[-3:]
@pytest.fixture
def api_extension_client(
db_session_with_containers: Session,
test_client_with_containers: FlaskClient,
) -> tuple[FlaskClient, dict[str, str], Tenant]:
account, tenant = create_console_account_and_tenant(db_session_with_containers)
tenant.encrypt_public_key = generate_key_pair(tenant.id)
db_session_with_containers.commit()
headers = authenticate_console_client(test_client_with_containers, account)
return test_client_with_containers, headers, tenant
@pytest.fixture(autouse=True)
def mock_api_based_extension_ping():
with patch("services.api_based_extension_service.APIBasedExtensionRequestor") as requestor:
requestor.return_value.request.return_value = {"result": "pong"}
yield requestor
def test_create_response_masks_plaintext_api_key(
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
) -> None:
client, headers, _ = api_extension_client
api_key = "plain-secret-12345"
response = client.post(
"/console/api/api-based-extension",
headers=headers,
json={
"name": "Docs API",
"api_endpoint": "https://docs.example.com/hook",
"api_key": api_key,
},
)
assert response.status_code == 201
assert response.json is not None
assert response.json["api_key"] == _masked_api_key(api_key)
def test_update_response_masks_new_plaintext_api_key(
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
) -> None:
client, headers, _ = api_extension_client
new_api_key = "new-secret-67890"
create_response = client.post(
"/console/api/api-based-extension",
headers=headers,
json={
"name": "Docs API",
"api_endpoint": "https://docs.example.com/hook",
"api_key": "old-secret-12345",
},
)
assert create_response.json is not None
update_response = client.post(
f"/console/api/api-based-extension/{create_response.json['id']}",
headers=headers,
json={
"name": "Docs API Updated",
"api_endpoint": "https://docs.example.com/v2",
"api_key": new_api_key,
},
)
assert update_response.status_code == 200
assert update_response.json is not None
assert update_response.json["api_key"] == _masked_api_key(new_api_key)
def test_update_response_masks_existing_plaintext_api_key_when_hidden_value_is_submitted(
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
) -> None:
client, headers, _ = api_extension_client
existing_api_key = "old-secret-12345"
create_response = client.post(
"/console/api/api-based-extension",
headers=headers,
json={
"name": "Docs API",
"api_endpoint": "https://docs.example.com/hook",
"api_key": existing_api_key,
},
)
assert create_response.json is not None
update_response = client.post(
f"/console/api/api-based-extension/{create_response.json['id']}",
headers=headers,
json={
"name": "Docs API Updated",
"api_endpoint": "https://docs.example.com/v2",
"api_key": HIDDEN_VALUE,
},
)
assert update_response.status_code == 200
assert update_response.json is not None
assert update_response.json["api_key"] == _masked_api_key(existing_api_key)
@@ -44,6 +44,12 @@ def _make_extension(
return extension
def _masked_api_key(api_key: str) -> str:
if len(api_key) <= 8:
return api_key[0] + "******" + api_key[-1]
return api_key[:3] + "******" + api_key[-3:]
@pytest.fixture(autouse=True)
def _mock_console_guards(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
"""Bypass console decorators so handlers can run in isolation."""
@@ -114,7 +120,7 @@ def test_api_based_extension_get_returns_tenant_extensions(app: Flask, monkeypat
def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pytest.MonkeyPatch):
saved_extension = _make_extension(name="Docs API", api_key="saved-secret")
saved_extension = _make_extension(name="Docs API", api_key="encrypted-token-from-save")
save_mock = MagicMock(return_value=saved_extension)
monkeypatch.setattr("controllers.console.extension.APIBasedExtensionService.save", save_mock)
@@ -125,7 +131,7 @@ def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pyt
}
with app.test_request_context("/console/api/api-based-extension", method="POST", json=payload):
response = APIBasedExtensionAPI().post()
response, status = APIBasedExtensionAPI().post()
args, _ = save_mock.call_args
created_extension: APIBasedExtension = args[0]
@@ -133,7 +139,9 @@ def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pyt
assert created_extension.name == payload["name"]
assert created_extension.api_endpoint == payload["api_endpoint"]
assert created_extension.api_key == payload["api_key"]
assert status == 201
assert response["name"] == saved_extension.name
assert response["api_key"] == _masked_api_key(payload["api_key"])
save_mock.assert_called_once()
@@ -183,6 +191,7 @@ def test_api_based_extension_detail_post_keeps_hidden_api_key(app: Flask, monkey
assert existing_extension.api_key == "keep-me"
save_mock.assert_called_once_with(existing_extension)
assert response["name"] == payload["name"]
assert response["api_key"] == _masked_api_key("keep-me")
def test_api_based_extension_detail_post_updates_api_key_when_provided(app: Flask, monkeypatch: pytest.MonkeyPatch):
@@ -212,6 +221,7 @@ def test_api_based_extension_detail_post_updates_api_key_when_provided(app: Flas
assert existing_extension.api_key == "new-secret"
save_mock.assert_called_once_with(existing_extension)
assert response["name"] == payload["name"]
assert response["api_key"] == _masked_api_key(payload["api_key"])
def test_api_based_extension_detail_delete_removes_extension(app: Flask, monkeypatch: pytest.MonkeyPatch):