From 4d9bda7422a3d631357569c2ebbf5f55ceaacc0c Mon Sep 17 00:00:00 2001 From: Eddy ZHANG Date: Wed, 2 Sep 2026 03:40:46 +0000 Subject: [PATCH] refactor(inner_api): dep-inject request payloads with @model_validate (#41575) --- api/controllers/inner_api/app/dsl.py | 6 +- api/controllers/inner_api/mail.py | 6 +- .../inner_api/runtime_credentials.py | 6 +- .../controllers/inner_api/app/test_dsl.py | 59 ++++++---- .../controllers/inner_api/test_mail.py | 23 +++- .../inner_api/test_runtime_credentials.py | 104 ++++++++++-------- 6 files changed, 127 insertions(+), 77 deletions(-) diff --git a/api/controllers/inner_api/app/dsl.py b/api/controllers/inner_api/app/dsl.py index e4276d44f20..80e514855ee 100644 --- a/api/controllers/inner_api/app/dsl.py +++ b/api/controllers/inner_api/app/dsl.py @@ -14,7 +14,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from controllers.common.schema import query_params_from_model, register_schema_model -from controllers.console.wraps import setup_required +from controllers.console.wraps import model_validate, 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 @@ -61,9 +61,9 @@ class EnterpriseAppDSLImport(Resource): 404: "Creator account not found or inactive", } ) - def post(self, workspace_id: str): + @model_validate(InnerAppDSLImportPayload) + def post(self, args: InnerAppDSLImportPayload, workspace_id: str): """Import a DSL into a workspace on behalf of a specified creator.""" - args = InnerAppDSLImportPayload.model_validate(inner_api_ns.payload or {}) account = _get_active_account(args.creator_email) if account is None: diff --git a/api/controllers/inner_api/mail.py b/api/controllers/inner_api/mail.py index 353cfc9dda5..f445c411880 100644 --- a/api/controllers/inner_api/mail.py +++ b/api/controllers/inner_api/mail.py @@ -4,7 +4,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field from controllers.common.schema import register_schema_model -from controllers.console.wraps import setup_required +from controllers.console.wraps import model_validate, setup_required from controllers.inner_api import inner_api_ns from controllers.inner_api.wraps import inner_api_only from extensions.ext_application_services import application_services @@ -27,8 +27,8 @@ class BaseMail(Resource): @inner_api_ns.doc("send_inner_mail") @inner_api_ns.doc(description="Send internal email") @inner_api_ns.expect(inner_api_ns.models[InnerMailPayload.__name__]) - def post(self): - args = InnerMailPayload.model_validate(inner_api_ns.payload or {}) + @model_validate(InnerMailPayload) + def post(self, args: InnerMailPayload): application_services().inner_mail.send( InnerMailMessage( recipients=tuple(args.to), diff --git a/api/controllers/inner_api/runtime_credentials.py b/api/controllers/inner_api/runtime_credentials.py index bea65230d73..c828ab07822 100644 --- a/api/controllers/inner_api/runtime_credentials.py +++ b/api/controllers/inner_api/runtime_credentials.py @@ -15,7 +15,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from controllers.common.schema import register_schema_model -from controllers.console.wraps import setup_required +from controllers.console.wraps import model_validate, setup_required from controllers.inner_api import inner_api_ns from controllers.inner_api.wraps import enterprise_inner_api_only from core.helper import encrypter @@ -67,8 +67,8 @@ class EnterpriseRuntimeCredentialsResolve(Resource): }, ) @inner_api_ns.expect(inner_api_ns.models[InnerRuntimeCredentialsResolvePayload.__name__]) - def post(self): - args = InnerRuntimeCredentialsResolvePayload.model_validate(inner_api_ns.payload or {}) + @model_validate(InnerRuntimeCredentialsResolvePayload) + def post(self, args: InnerRuntimeCredentialsResolvePayload): if not args.credentials: return {"credentials": []}, 200 diff --git a/api/tests/unit_tests/controllers/inner_api/app/test_dsl.py b/api/tests/unit_tests/controllers/inner_api/app/test_dsl.py index 94ea55375de..309ad2647d2 100644 --- a/api/tests/unit_tests/controllers/inner_api/app/test_dsl.py +++ b/api/tests/unit_tests/controllers/inner_api/app/test_dsl.py @@ -16,6 +16,7 @@ from pydantic import ValidationError from sqlalchemy import event from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, scoped_session, sessionmaker +from werkzeug.exceptions import UnprocessableEntity from controllers.inner_api.app import dsl as dsl_module from controllers.inner_api.app.dsl import ( @@ -29,6 +30,7 @@ from models.account import AccountStatus, TenantAccountRole from models.model import AppMode, IconType from services.app_dsl_service import Import, ImportStatus from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError +from tests.unit_tests.config_override import config_overrides_context def _persist_app(session: Session) -> App: @@ -185,13 +187,12 @@ class TestEnterpriseAppDSLImport: self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.COMPLETED) unwrapped = inspect.unwrap(api_instance.post) - with app.test_request_context(): - with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns: - mock_ns.payload = { - "yaml_content": "version: 0.6.0\n", - "creator_email": "user@example.com", - } - result = unwrapped(api_instance, workspace_id="ws-123") + payload = { + "yaml_content": "version: 0.6.0\n", + "creator_email": "user@example.com", + } + with app.test_request_context(json=payload): + result = unwrapped(api_instance, InnerAppDSLImportPayload.model_validate(payload), workspace_id="ws-123") body, status_code = result assert status_code == 200 @@ -208,10 +209,11 @@ class TestEnterpriseAppDSLImport: self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.PENDING) unwrapped = inspect.unwrap(api_instance.post) - with app.test_request_context(): - with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns: - mock_ns.payload = {"yaml_content": "test", "creator_email": "u@e.com"} - body, status_code = unwrapped(api_instance, workspace_id="ws-123") + payload = {"yaml_content": "test", "creator_email": "u@e.com"} + with app.test_request_context(json=payload): + body, status_code = unwrapped( + api_instance, InnerAppDSLImportPayload.model_validate(payload), workspace_id="ws-123" + ) assert status_code == 202 assert body["status"] == "pending" @@ -225,10 +227,11 @@ class TestEnterpriseAppDSLImport: self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.FAILED) unwrapped = inspect.unwrap(api_instance.post) - with app.test_request_context(): - with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns: - mock_ns.payload = {"yaml_content": "test", "creator_email": "u@e.com"} - body, status_code = unwrapped(api_instance, workspace_id="ws-123") + payload = {"yaml_content": "test", "creator_email": "u@e.com"} + with app.test_request_context(json=payload): + body, status_code = unwrapped( + api_instance, InnerAppDSLImportPayload.model_validate(payload), workspace_id="ws-123" + ) assert status_code == 400 assert body["status"] == "failed" @@ -239,10 +242,9 @@ class TestEnterpriseAppDSLImport: mock_get_account.return_value = None unwrapped = inspect.unwrap(api_instance.post) - with app.test_request_context(): - with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns: - mock_ns.payload = {"yaml_content": "test", "creator_email": "missing@e.com"} - result = unwrapped(api_instance, workspace_id="ws-123") + payload = {"yaml_content": "test", "creator_email": "missing@e.com"} + with app.test_request_context(json=payload): + result = unwrapped(api_instance, InnerAppDSLImportPayload.model_validate(payload), workspace_id="ws-123") body, status_code = result assert status_code == 404 @@ -485,3 +487,22 @@ class TestEnterpriseAppDSLExport: body, status_code = result assert status_code == 404 assert "app not found" in body["message"] + + +class TestModelValidateDecorator: + """The handler tests above unwrap the view, so this is what covers the decorator.""" + + def test_invalid_body_is_rejected_before_the_handler_runs(self, app: Flask) -> None: + api_instance = EnterpriseAppDSLImport() + + with ( + patch("controllers.console.wraps._is_setup_completed", return_value=True), + config_overrides_context(INNER_API=True, INNER_API_KEY="inner-api-key"), + app.test_request_context( + method="POST", + json={}, + headers={"X-Inner-Api-Key": "inner-api-key"}, + ), + pytest.raises(UnprocessableEntity), + ): + api_instance.post(workspace_id="ws-123") diff --git a/api/tests/unit_tests/controllers/inner_api/test_mail.py b/api/tests/unit_tests/controllers/inner_api/test_mail.py index 83bf0c6c471..d9cc3a594ff 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_mail.py +++ b/api/tests/unit_tests/controllers/inner_api/test_mail.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch import pytest from flask import Flask from pydantic import ValidationError -from werkzeug.exceptions import NotFound +from werkzeug.exceptions import NotFound, UnprocessableEntity from controllers.inner_api.mail import BaseMail, BillingMail, EnterpriseMail, InnerMailPayload from controllers.inner_api.wraps import InnerApiUnauthorizedError @@ -96,16 +96,31 @@ class TestBaseMail: services = SimpleNamespace(inner_mail=mail_service) with ( - app.test_request_context(), - patch("controllers.inner_api.mail.inner_api_ns") as namespace, + app.test_request_context(method="POST", json=payload), patch("controllers.inner_api.mail.application_services", return_value=services), ): - namespace.payload = payload result = unwrap(resource_type.post)(resource_type()) assert result == ({"message": "success"}, 200) mail_service.send.assert_called_once_with(expected) + @pytest.mark.parametrize("resource_type", [EnterpriseMail, BillingMail]) + def test_invalid_body_is_rejected_before_the_application_service_runs( + self, resource_type: type[BaseMail], app: Flask + ) -> None: + """`super().post()` relies on the decorator to supply the payload, so this covers it.""" + mail_service = MagicMock() + services = SimpleNamespace(inner_mail=mail_service) + + with ( + app.test_request_context(method="POST", json={}), + patch("controllers.inner_api.mail.application_services", return_value=services), + pytest.raises(UnprocessableEntity), + ): + unwrap(resource_type.post)(resource_type()) + + mail_service.send.assert_not_called() + def test_disabled_inner_api_returns_not_found_before_setup(app: Flask, config_overrides: Callable[..., None]) -> None: config_overrides(INNER_API=False) diff --git a/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py b/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py index 8e5e9a3e04f..01e725f9eac 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py +++ b/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py @@ -8,6 +8,7 @@ import pytest from flask import Flask from sqlalchemy.engine import Engine from sqlalchemy.orm import Session +from werkzeug.exceptions import UnprocessableEntity from controllers.inner_api.runtime_credentials import ( EnterpriseRuntimeCredentialsResolve, @@ -15,6 +16,7 @@ from controllers.inner_api.runtime_credentials import ( ) from models.provider import ProviderCredential from models.tools import BuiltinToolProvider +from tests.unit_tests.config_override import config_overrides_context def test_runtime_credentials_payload_accepts_items(): @@ -73,19 +75,18 @@ def test_runtime_model_credentials_resolve_returns_decrypted_values( handler = EnterpriseRuntimeCredentialsResolve() unwrapped = inspect.unwrap(handler.post) - with app.test_request_context(): - with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: - mock_ns.payload = { - "tenant_id": "tenant-1", - "credentials": [ - { - "credential_id": "credential-1", - "provider": "langgenius/openai/openai", - "kind": "model", - } - ], + payload = { + "tenant_id": "tenant-1", + "credentials": [ + { + "credential_id": "credential-1", + "provider": "langgenius/openai/openai", + "kind": "model", } - body, status_code = unwrapped(handler) + ], + } + with app.test_request_context(json=payload): + body, status_code = unwrapped(handler, InnerRuntimeCredentialsResolvePayload.model_validate(payload)) assert status_code == 200 assert body["credentials"][0]["kind"] == "model" @@ -104,13 +105,12 @@ def test_runtime_model_credentials_resolve_rejects_unknown_provider(mock_provide handler = EnterpriseRuntimeCredentialsResolve() unwrapped = inspect.unwrap(handler.post) - with app.test_request_context(): - with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: - mock_ns.payload = { - "tenant_id": "tenant-1", - "credentials": [{"credential_id": "credential-1", "provider": "missing", "kind": "model"}], - } - body, status_code = unwrapped(handler) + payload = { + "tenant_id": "tenant-1", + "credentials": [{"credential_id": "credential-1", "provider": "missing", "kind": "model"}], + } + with app.test_request_context(json=payload): + body, status_code = unwrapped(handler, InnerRuntimeCredentialsResolvePayload.model_validate(payload)) assert status_code == 404 assert "provider" in body["message"] @@ -152,19 +152,18 @@ def test_runtime_tool_credentials_resolve_returns_decrypted_values( handler = EnterpriseRuntimeCredentialsResolve() unwrapped = inspect.unwrap(handler.post) - with app.test_request_context(): - with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: - mock_ns.payload = { - "tenant_id": "tenant-1", - "credentials": [ - { - "credential_id": "credential-1", - "provider": "langgenius/tavily/tavily", - "kind": "tool", - } - ], + payload = { + "tenant_id": "tenant-1", + "credentials": [ + { + "credential_id": "credential-1", + "provider": "langgenius/tavily/tavily", + "kind": "tool", } - body, status_code = unwrapped(handler) + ], + } + with app.test_request_context(json=payload): + body, status_code = unwrapped(handler, InnerRuntimeCredentialsResolvePayload.model_validate(payload)) assert status_code == 200 assert body["credentials"][0]["kind"] == "tool" @@ -201,13 +200,12 @@ def test_runtime_tool_credentials_resolve_rejects_unknown_credential( handler = EnterpriseRuntimeCredentialsResolve() unwrapped = inspect.unwrap(handler.post) - with app.test_request_context(): - with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: - mock_ns.payload = { - "tenant_id": "tenant-1", - "credentials": [{"credential_id": "missing", "provider": "langgenius/tavily/tavily", "kind": "tool"}], - } - body, status_code = unwrapped(handler) + payload = { + "tenant_id": "tenant-1", + "credentials": [{"credential_id": "missing", "provider": "langgenius/tavily/tavily", "kind": "tool"}], + } + with app.test_request_context(json=payload): + body, status_code = unwrapped(handler, InnerRuntimeCredentialsResolvePayload.model_validate(payload)) assert status_code == 404 assert "credential" in body["message"] @@ -216,13 +214,29 @@ def test_runtime_tool_credentials_resolve_rejects_unknown_credential( def test_runtime_credentials_resolve_rejects_unknown_kind(app: Flask): handler = EnterpriseRuntimeCredentialsResolve() unwrapped = inspect.unwrap(handler.post) - with app.test_request_context(): - with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: - mock_ns.payload = { - "tenant_id": "tenant-1", - "credentials": [{"credential_id": "credential-1", "provider": "x", "kind": "secret"}], - } - body, status_code = unwrapped(handler) + payload = { + "tenant_id": "tenant-1", + "credentials": [{"credential_id": "credential-1", "provider": "x", "kind": "secret"}], + } + with app.test_request_context(json=payload): + body, status_code = unwrapped(handler, InnerRuntimeCredentialsResolvePayload.model_validate(payload)) assert status_code == 400 assert "kind" in body["message"] + + +def test_invalid_body_is_rejected_before_the_handler_runs(app: Flask) -> None: + """The tests above unwrap the view, so this is what covers the decorator.""" + handler = EnterpriseRuntimeCredentialsResolve() + + with ( + patch("controllers.console.wraps._is_setup_completed", return_value=True), + config_overrides_context(INNER_API=True, INNER_API_KEY="inner-api-key"), + app.test_request_context( + method="POST", + json={}, + headers={"X-Inner-Api-Key": "inner-api-key"}, + ), + pytest.raises(UnprocessableEntity), + ): + handler.post()