From f2ec17be9be952bdb0c1a07deb4502818245e605 Mon Sep 17 00:00:00 2001 From: GareArc Date: Thu, 7 May 2026 00:35:47 -0700 Subject: [PATCH] feat(openapi): unified POST /apps//run with per-mode dispatch Single bearer-accepting run route on the openapi namespace. Server reads apps.mode after AppResolver and dispatches via the _DISPATCH table to the per-mode helper. Per-mode constraints enforced inside helpers (422). Service-API /v1/* per-mode routes untouched. Also fixes a pre-existing latent bug in the openapi integration fixtures: App() rows were constructed without enable_site, which DB INSERT rejected (column is NOT NULL with no default). Now set enable_site=True alongside enable_api=True in the three fixtures that construct App() rows. --- api/controllers/openapi/__init__.py | 2 + api/controllers/openapi/app_run.py | 47 +++- .../controllers/openapi/conftest.py | 2 +- .../controllers/openapi/test_app_run.py | 249 ++++++++++++++++++ .../controllers/openapi/test_auth.py | 1 + 5 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 api/tests/integration_tests/controllers/openapi/test_app_run.py diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index f1a23df8c2..0938bce1a2 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -18,6 +18,7 @@ openapi_ns = Namespace("openapi", description="User-scoped operations", path="/" from . import ( account, + app_run, apps, apps_permitted, chat_messages, @@ -31,6 +32,7 @@ from . import ( __all__ = [ "account", + "app_run", "apps", "apps_permitted", "chat_messages", diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py index 6d9144c70b..769b6f0588 100644 --- a/api/controllers/openapi/app_run.py +++ b/api/controllers/openapi/app_run.py @@ -13,19 +13,26 @@ from collections.abc import Callable, Mapping from typing import Any, Literal from uuid import UUID -from pydantic import BaseModel, field_validator +from flask import request +from flask_restx import Resource +from pydantic import BaseModel, ValidationError, field_validator from werkzeug.exceptions import BadRequest, InternalServerError, NotFound, UnprocessableEntity import services +from controllers.openapi import openapi_ns +from controllers.openapi._audit import emit_app_run from controllers.openapi._models import ( ChatMessageResponse, CompletionMessageResponse, WorkflowRunResponse, ) +from controllers.openapi.auth.composition import OAUTH_BEARER_PIPELINE from controllers.service_api.app.error import ( AppUnavailableError, CompletionRequestError, ConversationCompletedError, + NotChatAppError, + NotWorkflowAppError, ProviderModelCurrentlyNotSupportError, ProviderNotInitializeError, ProviderQuotaExceededError, @@ -40,6 +47,7 @@ from core.errors.error import ( from graphon.model_runtime.errors.invoke import InvokeError from libs import helper from libs.helper import UUIDStrOrEmpty +from libs.oauth_bearer import Scope from models.model import App, AppMode from services.app_generate_service import AppGenerateService from services.errors.app import ( @@ -199,3 +207,40 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest, bool], tuple[Any, di AppMode.COMPLETION: _run_completion, AppMode.WORKFLOW: _run_workflow, } + + +@openapi_ns.route("/apps//run") +class AppRunApi(Resource): + @OAUTH_BEARER_PIPELINE.guard(scope=Scope.APPS_RUN) + def post(self, app_id: str, app_model: App, caller, caller_kind: str): + body = request.get_json(silent=True) or {} + body.pop("user", None) + try: + payload = AppRunRequest.model_validate(body) + except ValidationError as exc: + raise UnprocessableEntity(exc.json()) + + mode = AppMode.value_of(app_model.mode) + handler = _DISPATCH.get(mode) + if handler is None: + raise UnprocessableEntity("mode_not_runnable") + + streaming = payload.response_mode == "streaming" + try: + stream_obj, blocking_body = handler(app_model, caller, payload, streaming) + except UnprocessableEntity: + raise + except (NotChatAppError, NotWorkflowAppError): + raise + except ValueError: + raise + except Exception: + logger.exception("internal server error.") + raise InternalServerError() + + emit_app_run(app_id=app_model.id, tenant_id=app_model.tenant_id, + caller_kind=caller_kind, mode=str(app_model.mode)) + + if streaming: + return helper.compact_generate_response(stream_obj) + return blocking_body, 200 diff --git a/api/tests/integration_tests/controllers/openapi/conftest.py b/api/tests/integration_tests/controllers/openapi/conftest.py index 66f07484a5..19a8ab673b 100644 --- a/api/tests/integration_tests/controllers/openapi/conftest.py +++ b/api/tests/integration_tests/controllers/openapi/conftest.py @@ -51,7 +51,7 @@ def workspace_account(flask_app: Flask) -> Generator[tuple[Account, Tenant, Tena def app_in_workspace(flask_app: Flask, workspace_account) -> Generator[App, None, None]: _, tenant, _ = workspace_account with flask_app.app_context(): - app = App(tenant_id=tenant.id, name="a", mode="chat", status="normal", enable_api=True) + app = App(tenant_id=tenant.id, name="a", mode="chat", status="normal", enable_site=True, enable_api=True) db.session.add(app) db.session.commit() yield app diff --git a/api/tests/integration_tests/controllers/openapi/test_app_run.py b/api/tests/integration_tests/controllers/openapi/test_app_run.py new file mode 100644 index 0000000000..cc9be94681 --- /dev/null +++ b/api/tests/integration_tests/controllers/openapi/test_app_run.py @@ -0,0 +1,249 @@ +"""Integration tests for POST /openapi/v1/apps//run.""" + +from __future__ import annotations + +import uuid +from collections.abc import Generator + +import pytest +from flask import Flask + +from extensions.ext_database import db +from models import App + + +def test_run_chat_dispatches_to_chat_handler(flask_app, account_token, app_in_workspace, monkeypatch): + captured = {} + + def _fake_generate(*, app_model, user, args, invoke_from, streaming): + captured["mode"] = app_model.mode + captured["args"] = args + return { + "event": "message", + "task_id": "t", + "id": "m", + "message_id": "m", + "conversation_id": "c", + "mode": "chat", + "answer": "ok", + "created_at": 0, + } + + monkeypatch.setattr( + "controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate) + ) + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app_in_workspace.id}/run", + json={"inputs": {}, "query": "hi", "response_mode": "blocking"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + assert res.get_json()["mode"] == "chat" + assert captured["mode"] == "chat" + + +@pytest.fixture +def app_with_mode(flask_app: Flask, workspace_account): + """Factory that creates an App row in the workspace_account tenant with + a specified mode. Tracks rows for teardown. + """ + _, tenant, _ = workspace_account + created: list[App] = [] + + def _make(mode: str) -> App: + with flask_app.app_context(): + app = App( + tenant_id=tenant.id, + name=f"a-{mode}", + mode=mode, + status="normal", + enable_site=True, + enable_api=True, + ) + db.session.add(app) + db.session.commit() + db.session.refresh(app) + db.session.expunge(app) + created.append(app) + return app + + yield _make + + with flask_app.app_context(): + for app in created: + db.session.delete(db.session.merge(app)) + db.session.commit() + + +def test_run_chat_without_query_returns_422(flask_app, account_token, app_in_workspace, monkeypatch): + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app_in_workspace.id}/run", + json={"inputs": {}, "response_mode": "blocking"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 422 + assert b"query_required_for_chat" in res.data + + +def test_run_completion_dispatches_to_completion_handler( + flask_app, account_token, app_with_mode, monkeypatch +): + app = app_with_mode("completion") + + captured: dict = {} + + def _fake_generate(*, app_model, user, args, invoke_from, streaming): + captured["mode"] = app_model.mode + captured["args"] = args + return { + "event": "message", + "task_id": "t", + "id": "m", + "message_id": "m", + "mode": "completion", + "answer": "ok", + "created_at": 0, + } + + monkeypatch.setattr( + "controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate) + ) + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app.id}/run", + json={"inputs": {}, "response_mode": "blocking"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + assert res.get_json()["mode"] == "completion" + assert captured["mode"] == "completion" + + +def test_run_workflow_with_query_returns_422(flask_app, account_token, app_with_mode, monkeypatch): + app = app_with_mode("workflow") + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app.id}/run", + json={"inputs": {}, "query": "hi", "response_mode": "blocking"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 422 + assert b"query_not_supported_for_workflow" in res.data + + +def test_run_workflow_no_query_dispatches_to_workflow_handler( + flask_app, account_token, app_with_mode, monkeypatch +): + app = app_with_mode("workflow") + + def _fake_generate(*, app_model, user, args, invoke_from, streaming): + return { + "workflow_run_id": "wfr", + "task_id": "t", + "data": {"id": "wf-d", "workflow_id": "wf", "status": "succeeded"}, + } + + monkeypatch.setattr( + "controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate) + ) + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app.id}/run", + json={"inputs": {}, "response_mode": "blocking"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + body = res.get_json() + assert body["mode"] == "workflow" + assert body["workflow_run_id"] == "wfr" + + +def test_run_unsupported_mode_returns_422(flask_app, account_token, app_with_mode, monkeypatch): + app = app_with_mode("channel") + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app.id}/run", + json={"inputs": {}, "response_mode": "blocking"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 422 + assert b"mode_not_runnable" in res.data + + +def test_run_without_bearer_returns_401(flask_app, app_in_workspace): + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app_in_workspace.id}/run", + json={"inputs": {}, "query": "hi"}, + ) + assert res.status_code == 401 + + +def test_run_with_insufficient_scope_returns_403( + flask_app, account_token, app_in_workspace, monkeypatch +): + """Stub the authenticator to return an AuthContext with empty scopes.""" + from libs import oauth_bearer + + real_authenticate = oauth_bearer.BearerAuthenticator.authenticate + + def _stub_authenticate(self, token: str): + ctx = real_authenticate(self, token) + # Return a copy with empty scopes — frozen dataclass requires replace. + from dataclasses import replace + + return replace(ctx, scopes=frozenset()) + + monkeypatch.setattr(oauth_bearer.BearerAuthenticator, "authenticate", _stub_authenticate) + + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app_in_workspace.id}/run", + json={"inputs": {}, "query": "hi"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 403 + + +def test_run_with_unknown_app_returns_404(flask_app, account_token): + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{uuid.uuid4()}/run", + json={"inputs": {}, "query": "hi"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 404 + + +def test_run_streaming_returns_event_stream( + flask_app, account_token, app_in_workspace, monkeypatch +): + def _stream() -> Generator[str, None, None]: + yield "event: message\ndata: {\"x\": 1}\n\n" + + monkeypatch.setattr( + "controllers.openapi.app_run.AppGenerateService.generate", + staticmethod(lambda **kw: _stream()), + ) + + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app_in_workspace.id}/run", + json={"inputs": {}, "query": "hi", "response_mode": "streaming"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + assert res.headers["Content-Type"].startswith("text/event-stream") + assert b"event: message" in res.data + + +def test_run_without_inputs_returns_422(flask_app, account_token, app_in_workspace): + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app_in_workspace.id}/run", + json={"query": "hi"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 422 diff --git a/api/tests/integration_tests/controllers/openapi/test_auth.py b/api/tests/integration_tests/controllers/openapi/test_auth.py index 2d3c0d1827..5f0727fbbe 100644 --- a/api/tests/integration_tests/controllers/openapi/test_auth.py +++ b/api/tests/integration_tests/controllers/openapi/test_auth.py @@ -41,6 +41,7 @@ def other_workspace_app(flask_app: Flask) -> Generator[App, None, None]: name="b", mode="chat", status="normal", + enable_site=True, enable_api=True, ) db.session.add(app)