mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
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:
parent
591048d7c2
commit
86ba361ff1
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>")
|
||||
|
||||
212
api/controllers/openapi/apps.py
Normal file
212
api/controllers/openapi/apps.py
Normal 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,
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user