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:
L1nSn0w
2026-06-08 08:09:44 +00:00
committed by GitHub
co-authored by Claude Opus 4.8 autofix-ci[bot]
parent d0b376d31a
commit a15ecf6bec
27 changed files with 798 additions and 184 deletions
+8
View File
@@ -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 (
+49 -3
View File
@@ -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")
+14 -4
View File
@@ -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)
+2 -2
View File
@@ -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()
+2 -1
View File
@@ -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()
+2
View File
@@ -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}
+54 -11
View File
@@ -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())