diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index cf30ece801..63b30846e1 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -19,6 +19,7 @@ openapi_ns = Namespace("openapi", description="User-scoped operations", path="/" from . import ( account, app_info, + apps, chat_messages, completion_messages, index, @@ -31,6 +32,7 @@ from . import ( __all__ = [ "account", "app_info", + "apps", "chat_messages", "completion_messages", "index", diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index 9e70250823..18e0b876b9 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -2,10 +2,12 @@ from __future__ import annotations -from typing import Any +from typing import Any, Generic, TypeVar from pydantic import BaseModel +T = TypeVar("T") + class UsageInfo(BaseModel): prompt_tokens: int = 0 @@ -16,3 +18,17 @@ class UsageInfo(BaseModel): class MessageMetadata(BaseModel): usage: UsageInfo | None = None retriever_resources: list[dict[str, Any]] = [] + + +class PaginationEnvelope(BaseModel, Generic[T]): # noqa: UP046 + """Canonical pagination envelope for `/openapi/v1/*` list endpoints.""" + + page: int + limit: int + total: int + has_more: bool + data: list[T] + + @classmethod + def build(cls, *, page: int, limit: int, total: int, items: list[T]) -> PaginationEnvelope[T]: + return cls(page=page, limit=limit, total=total, has_more=page * limit < total, data=items) diff --git a/api/controllers/openapi/account.py b/api/controllers/openapi/account.py index e6a7974266..7637687efc 100644 --- a/api/controllers/openapi/account.py +++ b/api/controllers/openapi/account.py @@ -7,12 +7,13 @@ from __future__ import annotations from datetime import UTC, datetime -from flask import g +from flask import g, request from flask_restx import Resource from sqlalchemy import and_, select, update from werkzeug.exceptions import BadRequest, NotFound from controllers.openapi import openapi_ns +from controllers.openapi._models import PaginationEnvelope from extensions.ext_database import db from extensions.ext_redis import redis_client from libs.oauth_bearer import ( @@ -82,7 +83,10 @@ class AccountSessionsApi(Resource): def get(self): ctx = g.auth_ctx now = datetime.now(UTC) - rows = db.session.execute( + page = int(request.args.get("page", "1")) + limit = min(int(request.args.get("limit", "100")), 200) + + all_rows = db.session.execute( select( OAuthAccessToken.id, OAuthAccessToken.prefix, @@ -103,20 +107,26 @@ class AccountSessionsApi(Resource): .order_by(OAuthAccessToken.created_at.desc()) ).all() - return { - "sessions": [ - { - "id": str(r.id), - "prefix": r.prefix, - "client_id": r.client_id, - "device_label": r.device_label, - "created_at": _iso(r.created_at), - "last_used_at": _iso(r.last_used_at), - "expires_at": _iso(r.expires_at), - } - for r in rows - ] - }, 200 + total = len(all_rows) + sliced = all_rows[(page - 1) * limit : page * limit] + + items = [ + { + "id": str(r.id), + "prefix": r.prefix, + "client_id": r.client_id, + "device_label": r.device_label, + "created_at": _iso(r.created_at), + "last_used_at": _iso(r.last_used_at), + "expires_at": _iso(r.expires_at), + } + for r in sliced + ] + + return ( + PaginationEnvelope.build(page=page, limit=limit, total=total, items=items).model_dump(mode="json"), + 200, + ) @openapi_ns.route("/account/sessions/") diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py new file mode 100644 index 0000000000..bbad4fd503 --- /dev/null +++ b/api/controllers/openapi/apps.py @@ -0,0 +1,212 @@ +"""GET /openapi/v1/apps and per-app reads (single, parameters, describe). + +Read endpoints use validate_bearer + require_scope + require_workspace_member. +The OAuth bearer pipeline is reserved for /run (which gates on webapp_auth ACL). +""" + +from __future__ import annotations + +from typing import Any, cast + +import sqlalchemy as sa +from flask import g, request +from flask_restx import Resource +from werkzeug.exceptions import BadRequest, Forbidden, NotFound + +from controllers.common.fields import Parameters +from controllers.openapi import openapi_ns +from controllers.openapi._models import PaginationEnvelope +from controllers.service_api.app.error import AppUnavailableError +from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict +from extensions.ext_database import db +from libs.helper import escape_like_pattern +from libs.oauth_bearer import ( + ACCEPT_USER_ANY, + Scope, + SubjectType, + require_scope, + require_workspace_member, + validate_bearer, +) +from models import App +from models.model import AppMode, Tag, TagBinding + + +def account_or_404(ctx) -> None: + """Per-app reads are account-only; SSO subjects 404 to avoid leaking ID space.""" + if ctx.subject_type != SubjectType.ACCOUNT or ctx.account_id is None: + raise NotFound("app not found") + + +@openapi_ns.route("/apps/") +class AppByIdApi(Resource): + @validate_bearer(accept=ACCEPT_USER_ANY) + @require_scope(Scope.APPS_READ) # type: ignore[reportUntypedFunctionDecorator] + def get(self, app_id: str): + ctx = g.auth_ctx + account_or_404(ctx) + + app = db.session.get(App, app_id) + if not app or app.status != "normal": + raise NotFound("app not found") + + require_workspace_member(ctx, str(app.tenant_id)) + return app_info_payload(app), 200 + + +def app_info_payload(app: App) -> dict: + return { + "id": str(app.id), + "name": app.name, + "description": app.description, + "mode": app.mode, + "author": app.author_name, + "tags": [{"name": t.name} for t in app.tags], + } + + +@openapi_ns.route("/apps//parameters") +class AppParametersApi(Resource): + @validate_bearer(accept=ACCEPT_USER_ANY) + @require_scope(Scope.APPS_READ) # type: ignore[reportUntypedFunctionDecorator] + def get(self, app_id: str): + ctx = g.auth_ctx + account_or_404(ctx) + + app = db.session.get(App, app_id) + if not app or app.status != "normal": + raise NotFound("app not found") + + require_workspace_member(ctx, str(app.tenant_id)) + return parameters_payload(app), 200 + + +def parameters_payload(app: App) -> dict: + """Mirrors service_api/app/app.py::AppParameterApi response body.""" + if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: + workflow = app.workflow + if workflow is None: + raise AppUnavailableError() + features_dict: dict[str, Any] = workflow.features_dict + user_input_form = workflow.user_input_form(to_old_structure=True) + else: + app_model_config = app.app_model_config + if app_model_config is None: + raise AppUnavailableError() + features_dict = cast(dict[str, Any], app_model_config.to_dict()) + user_input_form = features_dict.get("user_input_form", []) + + parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form) + return Parameters.model_validate(parameters).model_dump(mode="json") + + +_EMPTY_PARAMETERS: dict[str, Any] = { + "opening_statement": None, + "suggested_questions": [], + "user_input_form": [], + "file_upload": None, + "system_parameters": {}, +} + + +@openapi_ns.route("/apps//describe") +class AppDescribeApi(Resource): + @validate_bearer(accept=ACCEPT_USER_ANY) + @require_scope(Scope.APPS_READ) # type: ignore[reportUntypedFunctionDecorator] + def get(self, app_id: str): + ctx = g.auth_ctx + account_or_404(ctx) + + app = db.session.get(App, app_id) + if not app or app.status != "normal": + raise NotFound("app not found") + + require_workspace_member(ctx, str(app.tenant_id)) + + try: + parameters = parameters_payload(app) + except AppUnavailableError: + # Apps without a model config still expose info; absent parameters + # render as explicit empty/null fields per spec. + parameters = dict(_EMPTY_PARAMETERS) + + return { + "info": { + "id": str(app.id), + "name": app.name, + "mode": app.mode, + "description": app.description, + "tags": [{"name": t.name} for t in app.tags], + "author": app.author_name, + "updated_at": app.updated_at.isoformat() if app.updated_at else None, + "service_api_enabled": bool(app.enable_api), + }, + "parameters": parameters, + }, 200 + + +@openapi_ns.route("/apps") +class AppListApi(Resource): + @validate_bearer(accept=ACCEPT_USER_ANY) + @require_scope(Scope.APPS_READ) # type: ignore[reportUntypedFunctionDecorator] + def get(self): + ctx = g.auth_ctx + if ctx.subject_type != SubjectType.ACCOUNT or ctx.account_id is None: + raise Forbidden("subject not permitted to list apps") + + workspace_id = request.args.get("workspace_id") + if not workspace_id: + raise BadRequest("workspace_id query param is required") + + require_workspace_member(ctx, workspace_id) + + page = int(request.args.get("page", "1")) + limit = min(int(request.args.get("limit", "20")), 100) + mode = request.args.get("mode") + name_filter = request.args.get("name") + tag_name = request.args.get("tag") + + filters = [ + App.tenant_id == workspace_id, + App.is_universal == False, + App.status == "normal", + ] + if mode: + filters.append(App.mode == mode) + if name_filter: + escaped = escape_like_pattern(name_filter[:30]) + filters.append(App.name.ilike(f"%{escaped}%", escape="\\")) + if tag_name: + tag_app_ids = ( + db.session.query(TagBinding.target_id) + .join(Tag, Tag.id == TagBinding.tag_id) + .filter(Tag.tenant_id == workspace_id, Tag.type == "app", Tag.name == tag_name) + .subquery() + ) + filters.append(App.id.in_(sa.select(tag_app_ids.c.target_id))) + + total = db.session.execute(sa.select(sa.func.count()).select_from(App).where(*filters)).scalar() or 0 + rows = ( + db.session.execute( + sa.select(App).where(*filters).order_by(App.created_at.desc()).limit(limit).offset((page - 1) * limit) + ) + .scalars() + .all() + ) + + items = [ + { + "id": str(r.id), + "name": r.name, + "description": r.description, + "mode": r.mode, + "tags": [{"name": t.name} for t in r.tags], + "updated_at": r.updated_at.isoformat() if r.updated_at else None, + "created_by_name": getattr(r, "author_name", None), + } + for r in rows + ] + return ( + PaginationEnvelope.build(page=page, limit=limit, total=int(total), items=items).model_dump(mode="json"), + 200, + ) diff --git a/api/tests/unit_tests/controllers/openapi/test_account.py b/api/tests/unit_tests/controllers/openapi/test_account.py index 5a08db4964..518627bfd8 100644 --- a/api/tests/unit_tests/controllers/openapi/test_account.py +++ b/api/tests/unit_tests/controllers/openapi/test_account.py @@ -99,6 +99,8 @@ def test_subject_match_for_account_filters_by_account_id(): token_id=_uuid.uuid4(), source="oauth_account", expires_at=None, + token_hash="h1", + verified_tenants={}, ) clauses = _subject_match(ctx) # One predicate, on account_id @@ -125,6 +127,8 @@ def test_subject_match_for_external_sso_filters_by_email_and_issuer(): token_id=_uuid.uuid4(), source="oauth_external_sso", expires_at=None, + token_hash="h1", + verified_tenants={}, ) clauses = _subject_match(ctx) assert len(clauses) == 3 diff --git a/api/tests/unit_tests/controllers/openapi/test_app_info.py b/api/tests/unit_tests/controllers/openapi/test_app_info.py index f024e1bd3f..fec1424589 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_info.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_info.py @@ -1,23 +1,43 @@ -from types import SimpleNamespace -from unittest.mock import patch +import builtins from flask import Flask -from flask_restx import Api +from flask.views import MethodView + +from controllers.openapi import bp as openapi_bp +from controllers.openapi.app_info import AppInfoApi + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] -def _client(): - from controllers.openapi import ( - app_info, # noqa: F401 - openapi_ns, - ) - +def _openapi_app() -> Flask: app = Flask(__name__) - api = Api(app) - api.add_namespace(openapi_ns, path="/openapi/v1") - return app.test_client() + app.config["TESTING"] = True + app.register_blueprint(openapi_bp) + return app -def test_app_info_returns_response_model(bypass_pipeline): +def _rule(app: Flask, path: str): + return next(r for r in app.url_map.iter_rules() if r.rule == path) + + +def test_app_info_route_registered(): + rules = {r.rule for r in _openapi_app().url_map.iter_rules()} + assert "/openapi/v1/apps//info" in rules + + +def test_app_info_dispatches_to_class(): + app = _openapi_app() + rule = _rule(app, "/openapi/v1/apps//info") + assert app.view_functions[rule.endpoint].view_class is AppInfoApi + assert "GET" in rule.methods + + +def test_app_info_payload_shape(): + from types import SimpleNamespace + + from controllers.openapi.apps import app_info_payload + app_obj = SimpleNamespace( id="app1", name="X", @@ -26,23 +46,12 @@ def test_app_info_returns_response_model(bypass_pipeline): author_name="alice", tags=[SimpleNamespace(name="prod")], ) - with patch("controllers.openapi.app_info._unpack_app", return_value=app_obj): - r = _client().get("/openapi/v1/apps/app1/info") - assert r.status_code == 200 - body = r.get_json() - assert body == { + payload = app_info_payload(app_obj) + assert payload == { "id": "app1", "name": "X", "description": "d", "mode": "chat", - "author_name": "alice", - "tags": ["prod"], + "author": "alice", + "tags": [{"name": "prod"}], } - - -def test_app_info_response_model_validates(): - from controllers.openapi.app_info import AppInfoResponse - - m = AppInfoResponse(id="x", name="N", mode="chat") - assert m.tags == [] - assert m.description is None diff --git a/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py b/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py new file mode 100644 index 0000000000..ce32f80a2e --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py @@ -0,0 +1,45 @@ +"""Unit tests for PaginationEnvelope generic Pydantic model.""" + +from __future__ import annotations + +from pydantic import BaseModel + +from controllers.openapi._models import PaginationEnvelope + + +class _Row(BaseModel): + id: str + name: str + + +def test_envelope_basic_fields(): + env = PaginationEnvelope[_Row](page=1, limit=20, total=42, has_more=True, data=[_Row(id="a", name="A")]) + dumped = env.model_dump(mode="json") + assert dumped == { + "page": 1, + "limit": 20, + "total": 42, + "has_more": True, + "data": [{"id": "a", "name": "A"}], + } + + +def test_envelope_empty_data_no_more(): + env = PaginationEnvelope[_Row](page=1, limit=20, total=0, has_more=False, data=[]) + assert env.model_dump(mode="json")["data"] == [] + assert env.model_dump(mode="json")["has_more"] is False + + +def test_envelope_has_more_true_when_total_exceeds_page_window(): + env = PaginationEnvelope[_Row].build(page=1, limit=20, total=42, items=[_Row(id="a", name="A")]) + assert env.has_more is True + + +def test_envelope_has_more_false_when_total_within_page_window(): + env = PaginationEnvelope[_Row].build(page=2, limit=20, total=22, items=[_Row(id="a", name="A")]) + assert env.has_more is False + + +def test_envelope_has_more_false_for_last_page(): + env = PaginationEnvelope[_Row].build(page=3, limit=20, total=42, items=[_Row(id="a", name="A")]) + assert env.has_more is False