mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
feat(cli): adopt generated oRPC contract for unary endpoints (#37090)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
autofix-ci[bot]
parent
d0b376d31a
commit
a15ecf6bec
@@ -37,6 +37,8 @@ from controllers.openapi._models import (
|
||||
DeviceMutateRequest,
|
||||
DeviceMutateResponse,
|
||||
DevicePollRequest,
|
||||
FormSubmitResponse,
|
||||
HealthResponse,
|
||||
MemberActionResponse,
|
||||
MemberInvitePayload,
|
||||
MemberInviteResponse,
|
||||
@@ -49,9 +51,11 @@ from controllers.openapi._models import (
|
||||
PermittedExternalAppsListResponse,
|
||||
RevokeResponse,
|
||||
ServerVersionResponse,
|
||||
SessionListQuery,
|
||||
SessionListResponse,
|
||||
SessionRow,
|
||||
TagItem,
|
||||
TaskStopResponse,
|
||||
UsageInfo,
|
||||
WorkflowRunData,
|
||||
WorkspaceDetailResponse,
|
||||
@@ -74,6 +78,7 @@ register_schema_models(
|
||||
MemberListQuery,
|
||||
MemberRoleUpdatePayload,
|
||||
PermittedExternalAppsListQuery,
|
||||
SessionListQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
openapi_ns,
|
||||
@@ -100,11 +105,14 @@ register_response_schema_models(
|
||||
MemberListResponse,
|
||||
MemberInviteResponse,
|
||||
MemberActionResponse,
|
||||
TaskStopResponse,
|
||||
FormSubmitResponse,
|
||||
DeviceCodeResponse,
|
||||
DeviceLookupResponse,
|
||||
DeviceMutateResponse,
|
||||
FileResponse,
|
||||
ServerVersionResponse,
|
||||
HealthResponse,
|
||||
)
|
||||
|
||||
from . import (
|
||||
|
||||
@@ -87,8 +87,12 @@ class AppDescribeInfo(AppInfoResponse):
|
||||
|
||||
class AppDescribeResponse(BaseModel):
|
||||
info: AppDescribeInfo | None = None
|
||||
parameters: dict[str, Any] | None = None
|
||||
input_schema: dict[str, Any] | None = None
|
||||
# `parameters` (the app-config blob) and `input_schema` (a Draft 2020-12 JSON Schema derived
|
||||
# per-app) are deliberately open JSON, not under-annotated. The `x-dify-opaque` marker tells the
|
||||
# contract generator's readiness detector to treat them as intentional, so the route is not
|
||||
# flagged "annotations incomplete". CLI/web consume them as opaque objects either way.
|
||||
parameters: dict[str, Any] | None = Field(default=None, json_schema_extra={"x-dify-opaque": True})
|
||||
input_schema: dict[str, Any] | None = Field(default=None, json_schema_extra={"x-dify-opaque": True})
|
||||
|
||||
|
||||
class ChatMessageResponse(BaseModel):
|
||||
@@ -173,6 +177,15 @@ class SessionListResponse(BaseModel):
|
||||
data: list[SessionRow]
|
||||
|
||||
|
||||
class SessionListQuery(BaseModel):
|
||||
"""Pagination for GET /account/sessions. Strict (extra='forbid')."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
page: int = Field(1, ge=1)
|
||||
limit: int = Field(100, ge=1, le=MAX_PAGE_LIMIT)
|
||||
|
||||
|
||||
class RevokeResponse(BaseModel):
|
||||
status: str
|
||||
|
||||
@@ -223,6 +236,23 @@ class ServerVersionResponse(BaseModel):
|
||||
edition: Literal["SELF_HOSTED", "CLOUD"]
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Liveness payload for `GET /openapi/v1/_health` — no auth required."""
|
||||
|
||||
ok: bool
|
||||
|
||||
|
||||
def _csv_string_query_schema(schema: dict[str, Any]) -> None:
|
||||
"""Re-shape a set/list field's query schema to a comma-separated string — the wire form the
|
||||
handler actually accepts (`request.args` is flat + the validator splits on ','). Without this
|
||||
the generated contract would type it as an array and serialize `fields[0]=…&fields[1]=…`,
|
||||
which `extra='forbid'` rejects. Runtime `set[str]` validation is unaffected."""
|
||||
schema.pop("anyOf", None)
|
||||
schema.pop("items", None)
|
||||
schema.pop("uniqueItems", None)
|
||||
schema["type"] = "string"
|
||||
|
||||
|
||||
class AppDescribeQuery(BaseModel):
|
||||
"""`?fields=` allow-list for GET /apps/<id>/describe.
|
||||
|
||||
@@ -231,7 +261,7 @@ class AppDescribeQuery(BaseModel):
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
fields: set[str] | None = None
|
||||
fields: set[str] | None = Field(default=None, json_schema_extra=_csv_string_query_schema)
|
||||
workspace_id: str | None = None
|
||||
|
||||
@field_validator("workspace_id", mode="before")
|
||||
@@ -400,3 +430,19 @@ class MemberInviteResponse(BaseModel):
|
||||
|
||||
class MemberActionResponse(BaseModel):
|
||||
result: Literal["success"] = "success"
|
||||
|
||||
|
||||
class TaskStopResponse(BaseModel):
|
||||
"""200 body for POST /apps/<id>/tasks/<task_id>/stop. The handler always returns
|
||||
{"result": "success"}, so `result` is required (no default) — the generated contract
|
||||
types it as a required `'success'` rather than an optional field."""
|
||||
|
||||
result: Literal["success"]
|
||||
|
||||
|
||||
class FormSubmitResponse(BaseModel):
|
||||
"""Empty 200 body for POST /apps/<id>/form/human_input/<token>. `extra='forbid'`
|
||||
pins `additionalProperties: false` so the generated contract is an exact `{}` rather
|
||||
than an under-annotated open object."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -4,15 +4,17 @@ from datetime import UTC, datetime
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import NotFound
|
||||
from pydantic import ValidationError
|
||||
from werkzeug.exceptions import NotFound, UnprocessableEntity
|
||||
|
||||
from controllers.common.schema import query_params_from_model
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._models import (
|
||||
MAX_PAGE_LIMIT,
|
||||
AccountPayload,
|
||||
AccountResponse,
|
||||
PaginationEnvelope,
|
||||
RevokeResponse,
|
||||
SessionListQuery,
|
||||
SessionListResponse,
|
||||
SessionRow,
|
||||
WorkspacePayload,
|
||||
@@ -70,13 +72,21 @@ class AccountSessionsSelfApi(Resource):
|
||||
|
||||
@openapi_ns.route("/account/sessions")
|
||||
class AccountSessionsApi(Resource):
|
||||
@openapi_ns.doc(params=query_params_from_model(SessionListQuery))
|
||||
@openapi_ns.response(200, "Session list", openapi_ns.models[SessionListResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
def get(self, *, auth_data: AuthData):
|
||||
# Validate page/limit through the same model the contract advertises (extra='forbid',
|
||||
# page>=1, 1<=limit<=MAX_PAGE_LIMIT) so the server actually enforces those bounds rather
|
||||
# than silently coercing (e.g. page=0 -> empty slice). Mirrors AppDescribeQuery.
|
||||
try:
|
||||
query = SessionListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
except ValidationError as exc:
|
||||
raise UnprocessableEntity(exc.json())
|
||||
ctx = get_auth_ctx()
|
||||
now = datetime.now(UTC)
|
||||
page = int(request.args.get("page", "1"))
|
||||
limit = min(int(request.args.get("limit", "100")), MAX_PAGE_LIMIT)
|
||||
page = query.page
|
||||
limit = query.limit
|
||||
|
||||
all_rows = list_active_sessions(db.session, ctx, now)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from werkzeug.exceptions import BadRequest, HTTPException, InternalServerError,
|
||||
import services
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._audit import emit_app_run
|
||||
from controllers.openapi._models import AppRunRequest
|
||||
from controllers.openapi._models import AppRunRequest, TaskStopResponse
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from controllers.service_api.app.error import (
|
||||
@@ -159,7 +159,7 @@ class AppRunApi(Resource):
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>/stop")
|
||||
class AppRunTaskStopApi(Resource):
|
||||
@openapi_ns.response(200, "Task stopped")
|
||||
@openapi_ns.response(200, "Task stopped", openapi_ns.models[TaskStopResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.APPS_RUN)
|
||||
def post(self, app_id: str, task_id: str, *, auth_data: AuthData):
|
||||
app_model, caller, caller_kind = auth_data.require_app_context()
|
||||
|
||||
@@ -17,6 +17,7 @@ from werkzeug.exceptions import BadRequest, NotFound
|
||||
from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._models import FormSubmitResponse
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface
|
||||
@@ -70,7 +71,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
return _jsonify_form_definition(form)
|
||||
|
||||
@openapi_ns.expect(openapi_ns.models[HumanInputFormSubmitPayload.__name__])
|
||||
@openapi_ns.response(200, "Form submitted")
|
||||
@openapi_ns.response(200, "Form submitted", openapi_ns.models[FormSubmitResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.APPS_RUN)
|
||||
def post(self, app_id: str, form_token: str, *, auth_data: AuthData):
|
||||
app_model, caller, caller_kind = auth_data.require_app_context()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._models import HealthResponse
|
||||
|
||||
|
||||
@openapi_ns.route("/_health")
|
||||
class HealthApi(Resource):
|
||||
@openapi_ns.response(200, "Health check", openapi_ns.models[HealthResponse.__name__])
|
||||
def get(self):
|
||||
return {"ok": True}
|
||||
|
||||
@@ -21,9 +21,9 @@ User-scoped operations
|
||||
#### GET
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Health check | [HealthResponse](#healthresponse) |
|
||||
|
||||
### /_version
|
||||
|
||||
@@ -46,6 +46,13 @@ User-scoped operations
|
||||
### /account/sessions
|
||||
|
||||
#### GET
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| limit | query | | No | integer |
|
||||
| page | query | | No | integer |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
@@ -104,7 +111,7 @@ User-scoped operations
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| fields | query | | No | [ string ] |
|
||||
| fields | query | | No | string |
|
||||
| workspace_id | query | | No | string |
|
||||
|
||||
##### Responses
|
||||
@@ -163,9 +170,9 @@ Upload a file to use as an input variable when running the app
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Form submitted |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Form submitted | [FormSubmitResponse](#formsubmitresponse) |
|
||||
|
||||
### /apps/{app_id}/run
|
||||
|
||||
@@ -211,9 +218,9 @@ Upload a file to use as an input variable when running the app
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Task stopped |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Task stopped | [TaskStopResponse](#taskstopresponse) |
|
||||
|
||||
### /oauth/device/approve
|
||||
|
||||
@@ -446,7 +453,7 @@ Empty / omitted → all blocks. Unknown member → ValidationError → 422.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| fields | [ string ] | | No |
|
||||
| fields | string | | No |
|
||||
| workspace_id | string | | No |
|
||||
|
||||
#### AppDescribeResponse
|
||||
@@ -592,6 +599,23 @@ mode is a closed enum.
|
||||
| tenant_id | string | | No |
|
||||
| user_id | string | | No |
|
||||
|
||||
#### FormSubmitResponse
|
||||
|
||||
Empty 200 body for POST /apps/<id>/form/human_input/<token>. `extra='forbid'`
|
||||
pins `additionalProperties: false` so the generated contract is an exact `{}` rather
|
||||
than an under-annotated open object.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
|
||||
#### HealthResponse
|
||||
|
||||
Liveness payload for `GET /openapi/v1/_health` — no auth required.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| ok | boolean | | Yes |
|
||||
|
||||
#### HumanInputFormSubmitPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -708,6 +732,15 @@ Meta endpoint payload for `GET /openapi/v1/_version` — no auth required.
|
||||
| edition | string | *Enum:* `"CLOUD"`, `"SELF_HOSTED"` | Yes |
|
||||
| version | string | | Yes |
|
||||
|
||||
#### SessionListQuery
|
||||
|
||||
Pagination for GET /account/sessions. Strict (extra='forbid').
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| limit | integer | | No |
|
||||
| page | integer | | No |
|
||||
|
||||
#### SessionListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -736,6 +769,16 @@ Meta endpoint payload for `GET /openapi/v1/_version` — no auth required.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| name | string | | Yes |
|
||||
|
||||
#### TaskStopResponse
|
||||
|
||||
200 body for POST /apps/<id>/tasks/<task_id>/stop. The handler always returns
|
||||
{"result": "success"}, so `result` is required (no default) — the generated contract
|
||||
types it as a required `'success'` rather than an optional field.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### UsageInfo
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""User-scoped identity + session endpoints under /openapi/v1/account."""
|
||||
|
||||
import builtins
|
||||
import sys
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.views import MethodView
|
||||
from werkzeug.exceptions import UnprocessableEntity
|
||||
|
||||
from controllers.openapi import bp as openapi_bp
|
||||
from controllers.openapi.account import (
|
||||
@@ -13,6 +18,8 @@ from controllers.openapi.account import (
|
||||
AccountSessionsApi,
|
||||
AccountSessionsSelfApi,
|
||||
)
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
|
||||
if not hasattr(builtins, "MethodView"):
|
||||
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
||||
@@ -138,3 +145,74 @@ def test_subject_match_for_external_sso_filters_by_email_and_issuer():
|
||||
assert "subject_email" in rendered
|
||||
assert "subject_issuer" in rendered
|
||||
assert "account_id IS NULL" in rendered
|
||||
|
||||
|
||||
# --- GET /account/sessions query validation (the handler routes ?page/?limit through
|
||||
# SessionListQuery so the server enforces the bounds the contract advertises). The auth ctx and
|
||||
# DB read are stubbed so these exercise only the validation + paging path; __wrapped__ skips the
|
||||
# auth guard, which is covered separately in auth/. ---
|
||||
|
||||
_ACCOUNT_MOD = "controllers.openapi.account"
|
||||
|
||||
|
||||
def _session_auth_data() -> AuthData:
|
||||
return AuthData(
|
||||
token_type=TokenType.OAUTH_ACCOUNT,
|
||||
account_id=uuid.uuid4(),
|
||||
token_hash="test",
|
||||
token_id=uuid.uuid4(),
|
||||
scopes=frozenset({Scope.FULL}),
|
||||
required_scope=Scope.FULL,
|
||||
allowed_roles=None,
|
||||
)
|
||||
|
||||
|
||||
def _stub_session_deps(monkeypatch, rows):
|
||||
mod = sys.modules[_ACCOUNT_MOD]
|
||||
monkeypatch.setattr(mod, "get_auth_ctx", lambda: SimpleNamespace())
|
||||
monkeypatch.setattr(mod, "list_active_sessions", lambda *args, **kwargs: rows)
|
||||
monkeypatch.setattr(mod, "db", MagicMock())
|
||||
|
||||
|
||||
def test_sessions_list_valid_query_parses_page_and_limit(app, monkeypatch):
|
||||
"""A valid ?page&limit round-trips through SessionListQuery into the response envelope."""
|
||||
api = AccountSessionsApi()
|
||||
_stub_session_deps(monkeypatch, [])
|
||||
with app.test_request_context("/openapi/v1/account/sessions?page=2&limit=5"):
|
||||
body, status = api.get.__wrapped__(api, auth_data=_session_auth_data())
|
||||
assert status == 200
|
||||
assert body["page"] == 2
|
||||
assert body["limit"] == 5
|
||||
assert body["total"] == 0
|
||||
assert body["data"] == []
|
||||
|
||||
|
||||
def test_sessions_list_defaults_when_query_omitted(app, monkeypatch):
|
||||
"""No query → the model's defaults (page=1, limit=100) drive the envelope."""
|
||||
api = AccountSessionsApi()
|
||||
_stub_session_deps(monkeypatch, [])
|
||||
with app.test_request_context("/openapi/v1/account/sessions"):
|
||||
body, status = api.get.__wrapped__(api, auth_data=_session_auth_data())
|
||||
assert status == 200
|
||||
assert body["page"] == 1
|
||||
assert body["limit"] == 100
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query",
|
||||
[
|
||||
"page=0", # below ge=1 (previously coerced to a silent empty slice)
|
||||
"page=-3",
|
||||
"limit=0", # below ge=1
|
||||
"limit=999", # above le=MAX_PAGE_LIMIT
|
||||
"page=abc", # not an integer (previously a 500)
|
||||
"foo=bar", # extra='forbid'
|
||||
],
|
||||
)
|
||||
def test_sessions_list_rejects_out_of_bounds_query(app, monkeypatch, query):
|
||||
"""Out-of-range / unknown query params raise 422 instead of being silently coerced."""
|
||||
api = AccountSessionsApi()
|
||||
_stub_session_deps(monkeypatch, [])
|
||||
with app.test_request_context(f"/openapi/v1/account/sessions?{query}"):
|
||||
with pytest.raises(UnprocessableEntity):
|
||||
api.get.__wrapped__(api, auth_data=_session_auth_data())
|
||||
|
||||
Reference in New Issue
Block a user