mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
refactor(openapi): resource-oriented paths for /openapi/v1 + difyctl version gate (#38367)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
parent
abd720146d
commit
915655683c
@@ -2,11 +2,13 @@ from flask import Blueprint
|
||||
from flask_restx import Namespace
|
||||
|
||||
from controllers.openapi._errors import ErrorBody, OpenApiErrorCode, OpenApiErrorFormatter
|
||||
from controllers.openapi._version_gate import attach_version_gate
|
||||
from libs.device_flow_security import attach_anti_framing
|
||||
from libs.external_api import ExternalApi
|
||||
|
||||
bp = Blueprint("openapi", __name__, url_prefix="/openapi/v1")
|
||||
attach_anti_framing(bp)
|
||||
attach_version_gate(bp)
|
||||
|
||||
api = ExternalApi(
|
||||
bp,
|
||||
|
||||
@@ -45,6 +45,7 @@ class OpenApiErrorCode(StrEnum):
|
||||
TOO_MANY_REQUESTS = "too_many_requests"
|
||||
INTERNAL_ERROR = "internal_server_error"
|
||||
BAD_GATEWAY = "bad_gateway"
|
||||
UPGRADE_REQUIRED = "upgrade_required"
|
||||
UNKNOWN = "unknown"
|
||||
# domain codes (must match the error_code attribute of the exception
|
||||
# classes raised on the openapi surface)
|
||||
|
||||
@@ -279,7 +279,7 @@ def _csv_string_query_schema(schema: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
class AppDescribeQuery(BaseModel):
|
||||
"""`?fields=` allow-list for GET /apps/<id>/describe.
|
||||
"""`?fields=` allow-list for GET /apps/<id>.
|
||||
|
||||
Empty / omitted → all blocks. Unknown member → ValidationError → 422.
|
||||
"""
|
||||
@@ -441,7 +441,7 @@ class MemberActionResponse(BaseModel):
|
||||
|
||||
|
||||
class TaskStopResponse(BaseModel):
|
||||
"""200 body for POST /apps/<id>/tasks/<task_id>/stop. The handler always returns
|
||||
"""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."""
|
||||
|
||||
@@ -473,7 +473,7 @@ class AppDslImportPayload(BaseModel):
|
||||
|
||||
|
||||
class AppDslExportQuery(BaseModel):
|
||||
"""Query parameters for GET /apps/<app_id>/export."""
|
||||
"""Query parameters for GET /apps/<app_id>/dsl."""
|
||||
|
||||
include_secret: bool = Field(False, description="Include encrypted secret values in the exported DSL")
|
||||
workflow_id: UUIDStr | None = Field(
|
||||
@@ -488,7 +488,7 @@ class AppDslExportResponse(BaseModel):
|
||||
|
||||
|
||||
class FormSubmitResponse(BaseModel):
|
||||
"""Empty 200 body for POST /apps/<id>/form/human_input/<token>. `extra='forbid'`
|
||||
"""Empty 200 body for POST /apps/<id>/human-input-forms/<token>:submit. `extra='forbid'`
|
||||
pins `additionalProperties: false` so the generated contract is an exact `{}` rather
|
||||
than an under-annotated open object."""
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Version gate: reject outdated difyctl clients on /openapi/v1 with HTTP 426.
|
||||
|
||||
difyctl and the ``/openapi/v1`` surface ship in lockstep. A breaking path change
|
||||
(resource-oriented paths) means an outdated difyctl would call removed paths and
|
||||
get a bare 404; this gate returns ``426 Upgrade Required`` with an upgrade hint
|
||||
instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
from flask import Blueprint, Response, request
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.openapi._errors import ErrorBody, OpenApiErrorCode
|
||||
|
||||
_UPGRADE_HINT: Final = "Upgrade difyctl: https://docs.dify.ai/en/cli/install"
|
||||
|
||||
# difyctl sends `User-Agent: difyctl/<semver> (<os>; <arch>; <channel>)`.
|
||||
_DIFYCTL_UA_RE = re.compile(r"^difyctl/(\d+\.\d+\.\d+(?:-[\w.]+)?)")
|
||||
|
||||
_PREFIX: Final = "/openapi/v1/"
|
||||
|
||||
# Paths a too-old client must still reach to discover that it is outdated.
|
||||
_ALLOWLIST: Final = frozenset({"/openapi/v1/_version", "/openapi/v1/_health"})
|
||||
|
||||
|
||||
def _upgrade_required_response(client_version: str, min_version: str) -> Response:
|
||||
body = ErrorBody(
|
||||
code=OpenApiErrorCode.UPGRADE_REQUIRED,
|
||||
message=f"difyctl {client_version} is no longer supported; upgrade to >= {min_version}.",
|
||||
status=426,
|
||||
hint=_UPGRADE_HINT,
|
||||
)
|
||||
return Response(body.model_dump_json(exclude_none=True), status=426, mimetype="application/json")
|
||||
|
||||
|
||||
def attach_version_gate(bp: Blueprint) -> None:
|
||||
"""Reject difyctl clients older than ``[tool.dify] min_difyctl_version`` with 426.
|
||||
|
||||
Registered app-wide (``before_app_request``) rather than blueprint-scoped so it
|
||||
also fires for requests to *removed* paths — those no longer match an openapi
|
||||
route and would 404 before a blueprint-scoped ``before_request`` ever runs. The
|
||||
prefix guard scopes it back to ``/openapi/v1``. Fails open for non-difyctl or
|
||||
unparseable User-Agents (only a confidently-too-old difyctl is blocked).
|
||||
"""
|
||||
|
||||
@bp.before_app_request
|
||||
def _enforce_min_client_version() -> Response | None: # pyright: ignore[reportUnusedFunction]
|
||||
if not request.path.startswith(_PREFIX):
|
||||
return None
|
||||
if request.path in _ALLOWLIST:
|
||||
return None
|
||||
match = _DIFYCTL_UA_RE.match(request.headers.get("User-Agent", ""))
|
||||
if match is None:
|
||||
return None
|
||||
try:
|
||||
client_version = Version(match.group(1))
|
||||
except InvalidVersion:
|
||||
return None
|
||||
# Compare the numeric core (major.minor.patch) only — a pre-release build
|
||||
# like 0.2.0-rc.1 must not sort below the 0.2.0 floor.
|
||||
min_version = dify_config.tool.dify.min_difyctl_version
|
||||
if client_version.release[:3] < Version(min_version).release[:3]:
|
||||
return _upgrade_required_response(match.group(1), min_version)
|
||||
return None
|
||||
@@ -30,7 +30,7 @@ class AppDslImportApi(Resource):
|
||||
a new app.
|
||||
|
||||
Returns 202 when the DSL version requires an explicit confirmation step
|
||||
(major version mismatch). Callers must then POST to the confirm endpoint.
|
||||
(major version mismatch). Callers must then POST to the imports :confirm method.
|
||||
Returns 400 when the import failed due to invalid DSL or a business error.
|
||||
"""
|
||||
|
||||
@@ -79,7 +79,7 @@ class AppDslImportApi(Resource):
|
||||
return result, 200
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports/<string:import_id>/confirm")
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports/<string:import_id>:confirm")
|
||||
class AppDslImportConfirmApi(Resource):
|
||||
"""Confirm a pending DSL import identified by ``import_id``.
|
||||
|
||||
@@ -119,7 +119,7 @@ class AppDslImportConfirmApi(Resource):
|
||||
return result, 200
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/export")
|
||||
@openapi_ns.route("/apps/<string:app_id>/dsl")
|
||||
class AppDslExportApi(Resource):
|
||||
"""Export an app's current draft configuration as a DSL YAML string.
|
||||
|
||||
@@ -153,7 +153,7 @@ class AppDslExportApi(Resource):
|
||||
return AppDslExportResponse(data=data), 200
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/check-dependencies")
|
||||
@openapi_ns.route("/apps/<string:app_id>/dependencies:check")
|
||||
class AppDslCheckDependenciesApi(Resource):
|
||||
"""Check for leaked plugin dependencies after a DSL import.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""POST /openapi/v1/apps/<app_id>/run — mode-agnostic runner."""
|
||||
"""POST /openapi/v1/apps/<app_id>:run — mode-agnostic runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -138,7 +138,7 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest, Session], Any]] = {
|
||||
}
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/run")
|
||||
@openapi_ns.route("/apps/<string:app_id>:run")
|
||||
class AppRunApi(Resource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_RUN,
|
||||
@@ -174,7 +174,7 @@ class AppRunApi(Resource):
|
||||
return helper.compact_generate_response(stream_obj)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>/stop")
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>:stop")
|
||||
class AppRunTaskStopApi(Resource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_RUN,
|
||||
|
||||
@@ -129,7 +129,7 @@ def build_app_describe_response(app: App, fields: set[str] | None) -> AppDescrib
|
||||
return AppDescribeResponse(info=info, parameters=parameters, input_schema=input_schema)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/describe")
|
||||
@openapi_ns.route("/apps/<string:app_id>")
|
||||
class AppDescribeApi(AppReadResource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_READ,
|
||||
|
||||
@@ -87,7 +87,7 @@ class PermittedExternalAppsListApi(Resource):
|
||||
return env
|
||||
|
||||
|
||||
@openapi_ns.route("/permitted-external-apps/<string:app_id>/describe")
|
||||
@openapi_ns.route("/permitted-external-apps/<string:app_id>")
|
||||
class PermittedExternalAppDescribeApi(Resource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_READ_PERMITTED_EXTERNAL,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""POST /openapi/v1/apps/<app_id>/files/upload — upload a file for use in app inputs."""
|
||||
"""POST /openapi/v1/apps/<app_id>/files — upload a file for use in app inputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -26,7 +26,7 @@ from libs.oauth_bearer import Scope
|
||||
from services.file_service import FileService
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/files/upload")
|
||||
@openapi_ns.route("/apps/<string:app_id>/files")
|
||||
class AppFileUploadApi(Resource):
|
||||
@openapi_ns.doc("upload_file_for_app_input")
|
||||
@openapi_ns.doc(description="Upload a file to use as an input variable when running the app")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
OpenAPI bearer-authed human input form endpoints.
|
||||
|
||||
GET /apps/<app_id>/form/human_input/<form_token> — fetch paused form definition
|
||||
POST /apps/<app_id>/form/human_input/<form_token> — submit form response
|
||||
GET /apps/<app_id>/human-input-forms/<form_token> — fetch paused form definition
|
||||
POST /apps/<app_id>/human-input-forms/<form_token>:submit — submit form response
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -60,7 +60,7 @@ def _ensure_form_is_allowed_for_openapi(form) -> None:
|
||||
raise RecipientSurfaceMismatch()
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/form/human_input/<string:form_token>")
|
||||
@openapi_ns.route("/apps/<string:app_id>/human-input-forms/<string:form_token>")
|
||||
class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
@openapi_ns.response(200, "Form definition", openapi_ns.models[HumanInputFormDefinitionResponse.__name__])
|
||||
@auth_router.guard(
|
||||
@@ -79,6 +79,9 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
service.ensure_form_active(form)
|
||||
return _jsonify_form_definition(form)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/human-input-forms/<string:form_token>:submit")
|
||||
class OpenApiWorkflowHumanInputFormSubmitApi(Resource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_RUN,
|
||||
rbac=RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_TEST_AND_RUN),
|
||||
|
||||
@@ -113,7 +113,7 @@ class WorkspaceByIdApi(Resource):
|
||||
return _workspace_detail(tenant, membership)
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/switch")
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>:switch")
|
||||
class WorkspaceSwitchApi(Resource):
|
||||
"""Server-side switch — equivalent to the console's POST /workspaces/switch.
|
||||
|
||||
@@ -212,11 +212,12 @@ class WorkspaceMembersApi(Resource):
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>")
|
||||
class WorkspaceMemberApi(Resource):
|
||||
"""Remove a member.
|
||||
"""Remove a member (DELETE) or change a member's role (PATCH).
|
||||
|
||||
Self-removal and owner-removal are explicitly rejected by the service
|
||||
layer (CannotOperateSelfError, NoPermissionError) — both surface as
|
||||
400 per the spec, with the service's message preserved.
|
||||
400 per the spec, with the service's message preserved. Owner can never be
|
||||
assigned via PATCH (closed enum); admin cannot demote the standing owner.
|
||||
"""
|
||||
|
||||
@auth_router.guard_workspace(
|
||||
@@ -243,15 +244,6 @@ class WorkspaceMemberApi(Resource):
|
||||
|
||||
return MemberActionResponse()
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>/role")
|
||||
class WorkspaceMemberRoleApi(Resource):
|
||||
"""Change a member's role.
|
||||
|
||||
Owner cannot be assigned here (closed enum). Admin cannot demote the
|
||||
standing owner (service NoPermissionError → 400, per spec).
|
||||
"""
|
||||
|
||||
@auth_router.guard_workspace(
|
||||
scope=Scope.WORKSPACE_WRITE,
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
|
||||
@@ -259,7 +251,7 @@ class WorkspaceMemberRoleApi(Resource):
|
||||
)
|
||||
@returns(200, MemberActionResponse, description="Role updated")
|
||||
@accepts(body=MemberRoleUpdatePayload)
|
||||
def put(self, workspace_id: str, member_id: str, *, auth_data: AuthData, body: MemberRoleUpdatePayload):
|
||||
def patch(self, workspace_id: str, member_id: str, *, auth_data: AuthData, body: MemberRoleUpdatePayload):
|
||||
operator = _load_account(auth_data.account_id)
|
||||
tenant = _load_tenant(workspace_id)
|
||||
member = AccountService.get_account_by_id(db.session, member_id)
|
||||
|
||||
Reference in New Issue
Block a user