feat(inner_api): batch-metadata endpoint for EE permitted-apps flow

This commit is contained in:
GareArc 2026-05-06 03:04:04 -07:00
parent 35d9b6a0f8
commit 7b6ceaebea
No known key found for this signature in database
3 changed files with 206 additions and 0 deletions

View File

@ -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",

View File

@ -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

View File

@ -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