feat(openapi): unified POST /apps/<id>/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.
This commit is contained in:
GareArc 2026-05-07 00:35:47 -07:00
parent 6532b4d161
commit f2ec17be9b
No known key found for this signature in database
5 changed files with 299 additions and 2 deletions

View File

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

View File

@ -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/<string:app_id>/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

View File

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

View File

@ -0,0 +1,249 @@
"""Integration tests for POST /openapi/v1/apps/<id>/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

View File

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