feat(openapi): app reads + canonical pagination envelope

Read-side surface for difyctl describe / get / list:

- GET /openapi/v1/apps              paginated list (workspace_id required)
- GET /openapi/v1/apps/<id>         single app summary
- GET /openapi/v1/apps/<id>/parameters  port of service_api parameters
- GET /openapi/v1/apps/<id>/describe    merged { info, parameters }

All gated by validate_bearer(ACCEPT_USER_ANY) + require_scope(APPS_READ) +
require_workspace_member(ctx, tenant_id). SSO subjects 404 (account-only
helper account_or_404 deduplicates the guard across the four endpoints).

PaginationEnvelope[T] (page, limit, total, has_more, data) is the canonical
shape for every /openapi/v1/* list endpoint. has_more is computed by the
server from page * limit < total. /account/sessions migrates from the
legacy { sessions: [...] } shape to the envelope; integration tests assert
the legacy key is gone.
This commit is contained in:
GareArc 2026-05-05 18:08:12 -07:00
parent 591048d7c2
commit 86ba361ff1
No known key found for this signature in database
7 changed files with 343 additions and 45 deletions

View File

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

View File

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

View File

@ -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/<string:session_id>")

View File

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

View File

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

View File

@ -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/<string:app_id>/info" in rules
def test_app_info_dispatches_to_class():
app = _openapi_app()
rule = _rule(app, "/openapi/v1/apps/<string:app_id>/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

View File

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