From 7b6ceaebeaeaf17c37a7294b4a41df133ee5644a Mon Sep 17 00:00:00 2001 From: GareArc Date: Wed, 6 May 2026 03:04:04 -0700 Subject: [PATCH] feat(inner_api): batch-metadata endpoint for EE permitted-apps flow --- api/controllers/inner_api/__init__.py | 2 + api/controllers/inner_api/app/metadata.py | 66 +++++++++ .../inner_api/test_app_metadata.py | 138 ++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 api/controllers/inner_api/app/metadata.py create mode 100644 api/tests/unit_tests/controllers/inner_api/test_app_metadata.py diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index b38994f055..10ba6fe8b7 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -17,6 +17,7 @@ inner_api_ns = Namespace("inner_api", description="Internal API operations", pat from . import mail as _mail from .app import dsl as _app_dsl +from .app import metadata as _app_metadata from .plugin import plugin as _plugin from .workspace import workspace as _workspace @@ -24,6 +25,7 @@ api.add_namespace(inner_api_ns) __all__ = [ "_app_dsl", + "_app_metadata", "_mail", "_plugin", "_workspace", diff --git a/api/controllers/inner_api/app/metadata.py b/api/controllers/inner_api/app/metadata.py new file mode 100644 index 0000000000..9d5ae063a9 --- /dev/null +++ b/api/controllers/inner_api/app/metadata.py @@ -0,0 +1,66 @@ +"""Inner endpoint: batch-fetch app metadata for the EE permitted-apps flow. + +Called by ``dify-enterprise`` server to hydrate ``web_app_settings.app_id`` rows +with their ``apps`` table fields. Filters out non-``normal`` apps server-side. +""" + +import sqlalchemy as sa +from flask_restx import Resource +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from controllers.common.schema import register_schema_model +from controllers.console.wraps import setup_required +from controllers.inner_api import inner_api_ns +from controllers.inner_api.wraps import enterprise_inner_api_only +from extensions.ext_database import db +from models import App +from models.enums import AppStatus + + +class InnerAppBatchMetadataPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + ids: list[str] = Field(min_length=1, max_length=500, description="App UUIDs to fetch metadata for (1-500 entries)") + + +register_schema_model(inner_api_ns, InnerAppBatchMetadataPayload) + + +@inner_api_ns.route("/enterprise/apps/batch-metadata") +class InnerAppBatchMetadataApi(Resource): + @setup_required + @enterprise_inner_api_only + @inner_api_ns.doc("enterprise_app_batch_metadata") + @inner_api_ns.expect(inner_api_ns.models[InnerAppBatchMetadataPayload.__name__]) + @inner_api_ns.doc( + responses={ + 200: "Batch metadata returned", + 401: "Inner API key invalid", + 422: "Payload validation failed", + } + ) + def post(self): + """Batch-fetch app metadata for the EE permitted-apps flow.""" + try: + payload = InnerAppBatchMetadataPayload.model_validate(inner_api_ns.payload or {}) + except ValidationError as exc: + return {"message": exc.json()}, 422 + + rows = ( + db.session.execute(sa.select(App).where(App.id.in_(payload.ids), App.status == AppStatus.NORMAL)) + .scalars() + .all() + ) + + return { + "data": [ + { + "id": str(app.id), + "tenant_id": str(app.tenant_id), + "mode": app.mode, + "name": app.name, + "updated_at": app.updated_at.isoformat(), + } + for app in rows + ] + }, 200 diff --git a/api/tests/unit_tests/controllers/inner_api/test_app_metadata.py b/api/tests/unit_tests/controllers/inner_api/test_app_metadata.py new file mode 100644 index 0000000000..8b03d9ca7f --- /dev/null +++ b/api/tests/unit_tests/controllers/inner_api/test_app_metadata.py @@ -0,0 +1,138 @@ +"""Inner endpoint to batch-fetch app metadata for the EE permitted-apps flow.""" + +import builtins +import inspect +import uuid +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask +from flask.views import MethodView +from pydantic import ValidationError + +from controllers.inner_api import bp as inner_api_bp +from controllers.inner_api.app.metadata import InnerAppBatchMetadataApi, InnerAppBatchMetadataPayload + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +@pytest.fixture +def inner_app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(inner_api_bp) + return app + + +def test_route_registered(inner_app: Flask): + rules = {r.rule for r in inner_app.url_map.iter_rules()} + assert "/inner/api/enterprise/apps/batch-metadata" in rules + + +def test_dispatches_to_class(inner_app: Flask): + rule = next(r for r in inner_app.url_map.iter_rules() if r.rule == "/inner/api/enterprise/apps/batch-metadata") + assert inner_app.view_functions[rule.endpoint].view_class is InnerAppBatchMetadataApi + + +def test_post_method_only(inner_app: Flask): + rule = next(r for r in inner_app.url_map.iter_rules() if r.rule == "/inner/api/enterprise/apps/batch-metadata") + assert "POST" in rule.methods + assert "GET" not in rule.methods + + +def test_payload_rejects_empty_ids(): + with pytest.raises(ValidationError): + InnerAppBatchMetadataPayload.model_validate({"ids": []}) + + +def test_payload_rejects_too_many_ids(): + with pytest.raises(ValidationError): + InnerAppBatchMetadataPayload.model_validate({"ids": ["x"] * 501}) + + +def _make_app_row(*, id=None, tenant_id=None, mode="chat", name="Test", updated_at=None, status="normal"): + """Build a stand-in App row for handler tests.""" + row = MagicMock() + row.id = id or uuid.uuid4() + row.tenant_id = tenant_id or uuid.uuid4() + row.mode = mode + row.name = name + row.status = status + row.updated_at = updated_at or datetime(2026, 5, 6, 12, 0, 0, tzinfo=UTC) + return row + + +def test_post_returns_metadata_for_normal_apps(inner_app: Flask): + app_row = _make_app_row(name="App A", mode="workflow") + + api_instance = InnerAppBatchMetadataApi() + handler = inspect.unwrap(api_instance.post) + + with inner_app.test_request_context( + path="/inner/api/enterprise/apps/batch-metadata", + method="POST", + json={"ids": [str(app_row.id)]}, + ): + with ( + patch("controllers.inner_api.app.metadata.inner_api_ns") as mock_ns, + patch("controllers.inner_api.app.metadata.db") as mock_db, + ): + mock_ns.payload = {"ids": [str(app_row.id)]} + mock_scalars = MagicMock() + mock_scalars.all.return_value = [app_row] + mock_db.session.execute.return_value.scalars.return_value = mock_scalars + + body, status = handler(api_instance) + + assert status == 200 + assert body == { + "data": [ + { + "id": str(app_row.id), + "tenant_id": str(app_row.tenant_id), + "mode": "workflow", + "name": "App A", + "updated_at": "2026-05-06T12:00:00+00:00", + } + ] + } + + +def test_post_returns_empty_data_when_no_apps(inner_app: Flask): + api_instance = InnerAppBatchMetadataApi() + handler = inspect.unwrap(api_instance.post) + + with inner_app.test_request_context( + path="/inner/api/enterprise/apps/batch-metadata", + method="POST", + json={"ids": [str(uuid.uuid4())]}, + ): + with ( + patch("controllers.inner_api.app.metadata.inner_api_ns") as mock_ns, + patch("controllers.inner_api.app.metadata.db") as mock_db, + ): + mock_ns.payload = {"ids": [str(uuid.uuid4())]} + mock_scalars = MagicMock() + mock_scalars.all.return_value = [] + mock_db.session.execute.return_value.scalars.return_value = mock_scalars + + body, status = handler(api_instance) + + assert status == 200 + assert body == {"data": []} + + +def test_post_returns_422_on_invalid_payload(inner_app: Flask): + api_instance = InnerAppBatchMetadataApi() + handler = inspect.unwrap(api_instance.post) + + with inner_app.test_request_context(path="/inner/api/enterprise/apps/batch-metadata", method="POST"): + with patch("controllers.inner_api.app.metadata.inner_api_ns") as mock_ns: + mock_ns.payload = {"ids": []} # min_length=1 violation + + body, status = handler(api_instance) + + assert status == 422 + assert "message" in body