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:
L1nSn0w 2026-07-08 10:22:59 +08:00 committed by GitHub
parent abd720146d
commit 915655683c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
69 changed files with 1097 additions and 570 deletions

View File

@ -6,6 +6,17 @@ class PyProjectConfig(BaseModel):
version: str = Field(description="Dify version", default="")
class DifyToolConfig(BaseModel):
min_difyctl_version: str = Field(
description="Oldest difyctl version served on /openapi/v1",
default="0.0.0",
)
class ToolConfig(BaseModel):
dify: DifyToolConfig = Field(default=DifyToolConfig())
class PyProjectTomlConfig(BaseSettings):
"""
configs in api/pyproject.toml
@ -15,3 +26,8 @@ class PyProjectTomlConfig(BaseSettings):
description="configs in the project section of pyproject.toml",
default=PyProjectConfig(),
)
tool: ToolConfig = Field(
description="configs in the [tool.*] section of pyproject.toml",
default=ToolConfig(),
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -93,21 +93,7 @@ User-scoped operations
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /apps/{app_id}/check-dependencies
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Dependencies checked | **application/json**: [CheckDependenciesResult](#checkdependenciesresult)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /apps/{app_id}/describe
### [GET] /apps/{app_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
@ -123,7 +109,21 @@ User-scoped operations
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /apps/{app_id}/export
### [GET] /apps/{app_id}/dependencies:check
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Dependencies checked | **application/json**: [CheckDependenciesResult](#checkdependenciesresult)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /apps/{app_id}/dsl
#### Parameters
| Name | Located in | Description | Required | Schema |
@ -140,7 +140,7 @@ User-scoped operations
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [POST] /apps/{app_id}/files/upload
### [POST] /apps/{app_id}/files
Upload a file to use as an input variable when running the app
#### Parameters
@ -160,7 +160,7 @@ Upload a file to use as an input variable when running the app
| 415 | Unsupported file type or blocked extension | |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /apps/{app_id}/form/human_input/{form_token}
### [GET] /apps/{app_id}/human-input-forms/{form_token}
#### Parameters
| Name | Located in | Description | Required | Schema |
@ -174,7 +174,7 @@ Upload a file to use as an input variable when running the app
| ---- | ----------- | ------ |
| 200 | Form definition | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)<br> |
### [POST] /apps/{app_id}/form/human_input/{form_token}
### [POST] /apps/{app_id}/human-input-forms/{form_token}:submit
#### Parameters
| Name | Located in | Description | Required | Schema |
@ -196,7 +196,38 @@ Upload a file to use as an input variable when running the app
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [POST] /apps/{app_id}/run
### [GET] /apps/{app_id}/tasks/{task_id}/events
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| continue_on_pause | query | Whether to keep the event stream open on pause | No | boolean |
| include_state_snapshot | query | Whether to include workflow state snapshots | No | boolean |
| app_id | path | | Yes | string |
| task_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | SSE event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
### [POST] /apps/{app_id}/tasks/{task_id}:stop
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| task_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Task stopped | **application/json**: [TaskStopResponse](#taskstopresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [POST] /apps/{app_id}:run
#### Parameters
| Name | Located in | Description | Required | Schema |
@ -216,37 +247,6 @@ Upload a file to use as an input variable when running the app
| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /apps/{app_id}/tasks/{task_id}/events
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| continue_on_pause | query | Whether to keep the event stream open on pause | No | boolean |
| include_state_snapshot | query | Whether to include workflow state snapshots | No | boolean |
| app_id | path | | Yes | string |
| task_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | SSE event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
### [POST] /apps/{app_id}/tasks/{task_id}/stop
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| task_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Task stopped | **application/json**: [TaskStopResponse](#taskstopresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [POST] /oauth/device/approve
#### Request Body
@ -330,7 +330,7 @@ Upload a file to use as an input variable when running the app
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /permitted-external-apps/{app_id}/describe
### [GET] /permitted-external-apps/{app_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
@ -391,7 +391,7 @@ Upload a file to use as an input variable when running the app
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [POST] /workspaces/{workspace_id}/apps/imports/{import_id}/confirm
### [POST] /workspaces/{workspace_id}/apps/imports/{import_id}:confirm
#### Parameters
| Name | Located in | Description | Required | Schema |
@ -460,7 +460,7 @@ Upload a file to use as an input variable when running the app
| 200 | Member removed | **application/json**: [MemberActionResponse](#memberactionresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [PUT] /workspaces/{workspace_id}/members/{member_id}/role
### [PATCH] /workspaces/{workspace_id}/members/{member_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
@ -482,7 +482,7 @@ Upload a file to use as an input variable when running the app
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [POST] /workspaces/{workspace_id}/switch
### [POST] /workspaces/{workspace_id}:switch
#### Parameters
| Name | Located in | Description | Required | Schema |
@ -532,7 +532,7 @@ Upload a file to use as an input variable when running the app
#### AppDescribeQuery
`?fields=` allow-list for GET /apps/<id>/describe.
`?fields=` allow-list for GET /apps/<id>.
Empty / omitted → all blocks. Unknown member → ValidationError → 422.
@ -550,7 +550,7 @@ Empty / omitted → all blocks. Unknown member → ValidationError → 422.
#### AppDslExportQuery
Query parameters for GET /apps/<app_id>/export.
Query parameters for GET /apps/<app_id>/dsl.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
@ -762,7 +762,7 @@ future server adds a code. Formatter tests pin emitted values to the enum.
#### FormSubmitResponse
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.
@ -1022,7 +1022,7 @@ generated CLI whitelist all derive from it.
#### TaskStopResponse
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.

View File

@ -52,6 +52,11 @@ dependencies = [
# Before adding new dependency, consider place it in
# alphabet order (a-z) and suitable group.
[tool.dify]
# Oldest difyctl served on /openapi/v1. Bump in lockstep with breaking /openapi/v1
# changes (paired with difyctl's own version + its compat.minDify).
min_difyctl_version = "0.2.0"
[tool.setuptools]
packages = []

View File

@ -1,4 +1,4 @@
"""Integration tests for POST /openapi/v1/apps/<id>/run."""
"""Integration tests for POST /openapi/v1/apps/<id>:run."""
from __future__ import annotations
@ -36,7 +36,7 @@ def test_run_chat_dispatches_to_chat_handler(
monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate))
client = flask_app.test_client()
res = client.post(
f"/openapi/v1/apps/{app_in_workspace.id}/run",
f"/openapi/v1/apps/{app_in_workspace.id}:run",
json={"inputs": {}, "query": "hi", "response_mode": "blocking", "user": "spoof@x.com"},
headers={"Authorization": f"Bearer {account_token}"},
)
@ -85,7 +85,7 @@ def test_run_chat_without_query_returns_422(
):
client = flask_app.test_client()
res = client.post(
f"/openapi/v1/apps/{app_in_workspace.id}/run",
f"/openapi/v1/apps/{app_in_workspace.id}:run",
json={"inputs": {}, "response_mode": "blocking"},
headers={"Authorization": f"Bearer {account_token}"},
)
@ -116,7 +116,7 @@ def test_run_completion_dispatches_to_completion_handler(
monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate))
client = flask_app.test_client()
res = client.post(
f"/openapi/v1/apps/{app.id}/run",
f"/openapi/v1/apps/{app.id}:run",
json={"inputs": {}, "response_mode": "blocking"},
headers={"Authorization": f"Bearer {account_token}"},
)
@ -131,7 +131,7 @@ def test_run_workflow_with_query_returns_422(
app = app_with_mode("workflow")
client = flask_app.test_client()
res = client.post(
f"/openapi/v1/apps/{app.id}/run",
f"/openapi/v1/apps/{app.id}:run",
json={"inputs": {}, "query": "hi", "response_mode": "blocking"},
headers={"Authorization": f"Bearer {account_token}"},
)
@ -154,7 +154,7 @@ def test_run_workflow_no_query_dispatches_to_workflow_handler(
monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate))
client = flask_app.test_client()
res = client.post(
f"/openapi/v1/apps/{app.id}/run",
f"/openapi/v1/apps/{app.id}:run",
json={"inputs": {}, "response_mode": "blocking"},
headers={"Authorization": f"Bearer {account_token}"},
)
@ -170,7 +170,7 @@ def test_run_unsupported_mode_returns_422(
app = app_with_mode("channel")
client = flask_app.test_client()
res = client.post(
f"/openapi/v1/apps/{app.id}/run",
f"/openapi/v1/apps/{app.id}:run",
json={"inputs": {}, "response_mode": "blocking"},
headers={"Authorization": f"Bearer {account_token}"},
)
@ -181,7 +181,7 @@ def test_run_unsupported_mode_returns_422(
def test_run_without_bearer_returns_401(flask_app: Flask, app_in_workspace):
client = flask_app.test_client()
res = client.post(
f"/openapi/v1/apps/{app_in_workspace.id}/run",
f"/openapi/v1/apps/{app_in_workspace.id}:run",
json={"inputs": {}, "query": "hi"},
)
assert res.status_code == 401
@ -205,7 +205,7 @@ def test_run_with_insufficient_scope_returns_403(
client = flask_app.test_client()
res = client.post(
f"/openapi/v1/apps/{app_in_workspace.id}/run",
f"/openapi/v1/apps/{app_in_workspace.id}:run",
json={"inputs": {}, "query": "hi"},
headers={"Authorization": f"Bearer {account_token}"},
)
@ -215,7 +215,7 @@ def test_run_with_insufficient_scope_returns_403(
def test_run_with_unknown_app_returns_404(flask_app: Flask, account_token):
client = flask_app.test_client()
res = client.post(
f"/openapi/v1/apps/{uuid.uuid4()}/run",
f"/openapi/v1/apps/{uuid.uuid4()}:run",
json={"inputs": {}, "query": "hi"},
headers={"Authorization": f"Bearer {account_token}"},
)
@ -235,7 +235,7 @@ def test_run_streaming_returns_event_stream(
client = flask_app.test_client()
res = client.post(
f"/openapi/v1/apps/{app_in_workspace.id}/run",
f"/openapi/v1/apps/{app_in_workspace.id}:run",
json={"inputs": {}, "query": "hi", "response_mode": "streaming"},
headers={"Authorization": f"Bearer {account_token}"},
)
@ -247,7 +247,7 @@ def test_run_streaming_returns_event_stream(
def test_run_without_inputs_returns_422(flask_app: Flask, account_token, app_in_workspace):
client = flask_app.test_client()
res = client.post(
f"/openapi/v1/apps/{app_in_workspace.id}/run",
f"/openapi/v1/apps/{app_in_workspace.id}:run",
json={"query": "hi"},
headers={"Authorization": f"Bearer {account_token}"},
)

View File

@ -37,7 +37,7 @@ def test_apps_describe_returns_merged_shape(
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe",
f"/openapi/v1/apps/{app_in_workspace.id}",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 200
@ -53,7 +53,7 @@ def test_apps_describe_full_includes_input_schema(
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe",
f"/openapi/v1/apps/{app_in_workspace.id}",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 200
@ -70,7 +70,7 @@ def test_apps_describe_fields_info_only(
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info",
f"/openapi/v1/apps/{app_in_workspace.id}?fields=info",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 200
@ -86,7 +86,7 @@ def test_apps_describe_fields_parameters_only(
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=parameters",
f"/openapi/v1/apps/{app_in_workspace.id}?fields=parameters",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 200
@ -102,7 +102,7 @@ def test_apps_describe_fields_input_schema_only(
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=input_schema",
f"/openapi/v1/apps/{app_in_workspace.id}?fields=input_schema",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 200
@ -118,7 +118,7 @@ def test_apps_describe_fields_combined(
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info,input_schema",
f"/openapi/v1/apps/{app_in_workspace.id}?fields=info,input_schema",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 200
@ -134,7 +134,7 @@ def test_apps_describe_fields_unknown_returns_422(
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=garbage",
f"/openapi/v1/apps/{app_in_workspace.id}?fields=garbage",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 422
@ -146,7 +146,7 @@ def test_apps_describe_fields_extra_param_returns_422(
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info&page=1",
f"/openapi/v1/apps/{app_in_workspace.id}?fields=info&page=1",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 422

View File

@ -167,7 +167,7 @@ class TestDslImportConfirm:
api = AppDslImportConfirmApi()
with app.test_request_context(
f"/openapi/v1/workspaces/{tenant.id}/apps/imports/{import_id}/confirm", method="POST"
f"/openapi/v1/workspaces/{tenant.id}/apps/imports/{import_id}:confirm", method="POST"
):
result, code = unwrap(api.post)(
api, workspace_id=tenant.id, import_id=import_id, auth_data=auth_for(account)
@ -198,7 +198,7 @@ class TestDslExport:
db_session_with_containers.commit()
api = AppDslExportApi()
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/export"):
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/dsl"):
response, code = unwrap(api.get)(
api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model), query=AppDslExportQuery()
)
@ -216,7 +216,7 @@ class TestDslExport:
app_model, account = _app_and_account(db_session_with_containers, mode="workflow")
api = AppDslExportApi()
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/export"):
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/dsl"):
result, code = unwrap(api.get)(
api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model), query=AppDslExportQuery()
)
@ -232,7 +232,7 @@ class TestDslCheckDependencies:
app_model, account = _app_and_account(db_session_with_containers, mode="chat")
api = AppDslCheckDependenciesApi()
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/check-dependencies"):
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/dependencies:check"):
result, code = unwrap(api.get)(api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model))
assert code == 200

View File

@ -38,7 +38,7 @@ class TestAppRunTaskStop:
task_id = str(uuid4())
api = AppRunTaskStopApi()
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/tasks/{task_id}/stop", method="POST"):
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/tasks/{task_id}:stop", method="POST"):
result = unwrap(api.post)(
api,
app_id=app_model.id,

View File

@ -120,7 +120,7 @@ class TestAppDescribe:
app_model = _create_app(db_session_with_containers, account, name="Describe Me", enable_api=True)
api = AppDescribeApi()
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/describe?fields=info"):
with app.test_request_context(f"/openapi/v1/apps/{app_model.id}?fields=info"):
result = unwrap(api.get)(
api, app_id=app_model.id, auth_data=auth_for(account), query=AppDescribeQuery(fields="info")
)
@ -138,7 +138,7 @@ class TestAppDescribe:
missing_id = str(uuid4())
api = AppDescribeApi()
with app.test_request_context(f"/openapi/v1/apps/{missing_id}/describe"):
with app.test_request_context(f"/openapi/v1/apps/{missing_id}"):
with pytest.raises(NotFound):
unwrap(api.get)(api, app_id=missing_id, auth_data=auth_for(account), query=AppDescribeQuery())
@ -151,6 +151,6 @@ class TestAppDescribe:
hidden = _create_app(db_session_with_containers, account, name="Hidden", enable_api=False)
api = AppDescribeApi()
with app.test_request_context(f"/openapi/v1/apps/{hidden.id}/describe"):
with app.test_request_context(f"/openapi/v1/apps/{hidden.id}"):
with pytest.raises(NotFound):
unwrap(api.get)(api, app_id=hidden.id, auth_data=auth_for(account), query=AppDescribeQuery())

View File

@ -43,7 +43,7 @@ class TestAppFileUpload:
api = AppFileUploadApi()
data = {"file": (BytesIO(content), "note.txt", "text/plain")}
with app.test_request_context(
f"/openapi/v1/apps/{app_model.id}/files/upload",
f"/openapi/v1/apps/{app_model.id}/files",
method="POST",
data=data,
content_type="multipart/form-data",

View File

@ -95,7 +95,7 @@ class TestWorkspaceSwitch:
)
api = WorkspaceSwitchApi()
with app.test_request_context(f"/openapi/v1/workspaces/{target.id}/switch", method="POST"):
with app.test_request_context(f"/openapi/v1/workspaces/{target.id}:switch", method="POST"):
detail = unwrap(api.post)(api, workspace_id=target.id, auth_data=auth_for(account))
# Response reflects the post-switch state.
@ -118,6 +118,6 @@ class TestWorkspaceSwitch:
assert outsider_ws is not None
api = WorkspaceSwitchApi()
with app.test_request_context(f"/openapi/v1/workspaces/{outsider_ws.id}/switch", method="POST"):
with app.test_request_context(f"/openapi/v1/workspaces/{outsider_ws.id}:switch", method="POST"):
with pytest.raises(NotFound):
unwrap(api.post)(api, workspace_id=outsider_ws.id, auth_data=auth_for(account))

View File

@ -72,7 +72,7 @@ def test_run_chat_always_calls_generate_with_streaming_true(
"AppGenerateService",
GenerateService,
)
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/run", method="POST"):
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}:run", method="POST"):
_run_chat(
_make_app(),
_make_account(),
@ -84,9 +84,9 @@ def test_run_chat_always_calls_generate_with_streaming_true(
def test_stop_task_endpoint_registered(openapi_app):
"""POST /openapi/v1/apps/<id>/tasks/<task_id>/stop must be registered."""
"""POST /openapi/v1/apps/<id>/tasks/<task_id>:stop must be registered."""
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
assert "/openapi/v1/apps/<string:app_id>/tasks/<string:task_id>/stop" in rules
assert "/openapi/v1/apps/<string:app_id>/tasks/<string:task_id>:stop" in rules
def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch):
@ -117,7 +117,7 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipel
)
api = AppRunTaskStopApi()
with app.test_request_context("/openapi/v1/apps/app-1/tasks/task-1/stop", method="POST"):
with app.test_request_context("/openapi/v1/apps/app-1/tasks/task-1:stop", method="POST"):
result = api.post.__wrapped__(
api,
app_id="app-1",

View File

@ -62,7 +62,7 @@ class TestOpenApiHumanInputFormGet:
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
caller = SimpleNamespace(id="acct-1")
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/tok-1"):
resp = api.get.__wrapped__(
api,
app_id="app-1",
@ -89,7 +89,7 @@ class TestOpenApiHumanInputFormGet:
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
caller = SimpleNamespace(id="acct-1")
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/bad"):
with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/bad"):
with pytest.raises(HumanInputFormNotFound):
api.get.__wrapped__(
api,
@ -117,7 +117,7 @@ class TestOpenApiHumanInputFormGet:
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
caller = SimpleNamespace(id="acct-1")
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/tok-1"):
with pytest.raises(HumanInputFormNotFound):
api.get.__wrapped__(
api,
@ -145,7 +145,7 @@ class TestOpenApiHumanInputFormGet:
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
caller = SimpleNamespace(id="acct-1")
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/tok-1"):
with pytest.raises(RecipientSurfaceMismatch):
api.get.__wrapped__(
api,
@ -165,7 +165,7 @@ class TestOpenApiHumanInputFormPost:
)
def test_post_account_caller_uses_user_id(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch):
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi
form = self._make_form()
service_mock = Mock()
@ -175,12 +175,12 @@ class TestOpenApiHumanInputFormPost:
monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock)
monkeypatch.setattr(module, "db", SimpleNamespace(engine=object()))
api = OpenApiWorkflowHumanInputFormApi()
api = OpenApiWorkflowHumanInputFormSubmitApi()
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
caller = SimpleNamespace(id="acct-42")
with app.test_request_context(
"/openapi/v1/apps/app-1/form/human_input/tok-1",
"/openapi/v1/apps/app-1/human-input-forms/tok-1:submit",
method="POST",
json={"action": "approve", "inputs": {"field1": "val"}},
):
@ -202,7 +202,7 @@ class TestOpenApiHumanInputFormPost:
assert result == ({}, 200)
def test_post_end_user_caller_uses_end_user_id(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch):
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi
form = self._make_form()
service_mock = Mock()
@ -212,12 +212,12 @@ class TestOpenApiHumanInputFormPost:
monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock)
monkeypatch.setattr(module, "db", SimpleNamespace(engine=object()))
api = OpenApiWorkflowHumanInputFormApi()
api = OpenApiWorkflowHumanInputFormSubmitApi()
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
caller = SimpleNamespace(id="eu-7")
with app.test_request_context(
"/openapi/v1/apps/app-1/form/human_input/tok-1",
"/openapi/v1/apps/app-1/human-input-forms/tok-1:submit",
method="POST",
json={"action": "approve", "inputs": {}},
):
@ -241,7 +241,7 @@ class TestOpenApiHumanInputFormPost:
def test_post_standalone_web_app_recipient_submits(
self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch
):
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi
form = self._make_form(recipient_type=RecipientType.STANDALONE_WEB_APP)
service_mock = Mock()
@ -251,12 +251,12 @@ class TestOpenApiHumanInputFormPost:
monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock)
monkeypatch.setattr(module, "db", SimpleNamespace(engine=object()))
api = OpenApiWorkflowHumanInputFormApi()
api = OpenApiWorkflowHumanInputFormSubmitApi()
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
caller = SimpleNamespace(id="anyone")
with app.test_request_context(
"/openapi/v1/apps/app-1/form/human_input/tok-1",
"/openapi/v1/apps/app-1/human-input-forms/tok-1:submit",
method="POST",
json={"action": "approve", "inputs": {}},
):
@ -272,14 +272,14 @@ class TestOpenApiHumanInputFormPost:
def test_post_rejects_invalid_body_with_422(self, app: Flask, bypass_pipeline):
"""Malformed body → 422 via @accepts (was an unmapped pydantic error → 500)."""
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi
api = OpenApiWorkflowHumanInputFormApi()
api = OpenApiWorkflowHumanInputFormSubmitApi()
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
caller = SimpleNamespace(id="acct-42")
with app.test_request_context(
"/openapi/v1/apps/app-1/form/human_input/tok-1",
"/openapi/v1/apps/app-1/human-input-forms/tok-1:submit",
method="POST",
json={"inputs": {"field1": "val"}}, # missing required "action"
):

View File

@ -0,0 +1,100 @@
"""Tests for the difyctl version gate on /openapi/v1 (HTTP 426 Upgrade Required).
The gate is an app-level ``before_app_request`` hook: it must fire before routing,
so requests to *removed* paths (which no longer match a route) become 426 rather
than a bare 404. It reads the difyctl version from the User-Agent and fails open
for anything it can't confidently identify as an outdated difyctl.
"""
from __future__ import annotations
import uuid
import pytest
from flask import Flask
# Floor is [tool.dify] min_difyctl_version = "0.2.0". Comparison is on the numeric
# core (major.minor.patch), so 0.2.0-alpha passes (core 0.2.0 == floor) while
# 0.1.0 (core 0.1.0 < 0.2.0) is blocked.
OLD_UA = "difyctl/0.1.0 (darwin; arm64; stable)"
CURRENT_UA = "difyctl/0.2.0-alpha (darwin; arm64; stable)"
@pytest.fixture
def client(openapi_app: Flask):
return openapi_app.test_client()
def _gated_path() -> str:
"""An existing, auth-guarded route on the surface (GET /apps/<id>)."""
return f"/openapi/v1/apps/{uuid.uuid4()}"
class TestVersionGate:
def test_old_client_gets_426_with_upgrade_body(self, client):
res = client.get(_gated_path(), headers={"User-Agent": OLD_UA})
assert res.status_code == 426
body = res.get_json()
assert body["code"] == "upgrade_required"
assert body["status"] == 426
assert "0.1.0" in body["message"]
assert "0.2.0" in body["message"]
assert "docs.dify.ai" in body["hint"]
def test_removed_old_path_gets_426_not_404(self, client):
# /apps/<id>/run was renamed to /apps/<id>:run — the old path matches no
# route. The app-level gate must still turn it into 426, not a bare 404.
res = client.post(
f"/openapi/v1/apps/{uuid.uuid4()}/run",
headers={"User-Agent": OLD_UA},
json={"inputs": {}},
)
assert res.status_code == 426
assert res.get_json()["code"] == "upgrade_required"
def test_current_client_passes_gate(self, client):
# Gate passes → normal dispatch (auth rejects, never the gate's 426).
# 0.2.0-alpha == floor on the numeric core, so it passes despite the suffix.
res = client.get(_gated_path(), headers={"User-Agent": CURRENT_UA})
assert res.status_code != 426
def test_prerelease_at_floor_passes(self, client):
# Numeric-core comparison: a pre-release of the floor version (0.2.0-rc.1,
# core 0.2.0) passes, even though 0.2.0-rc.1 < 0.2.0 under naive ordering.
res = client.get(_gated_path(), headers={"User-Agent": "difyctl/0.2.0-rc.1 (darwin; arm64; rc)"})
assert res.status_code != 426
def test_prerelease_below_floor_gets_426(self, client):
# 0.1.9-rc.1 has core 0.1.9 < 0.2.0 floor → still blocked.
res = client.get(_gated_path(), headers={"User-Agent": "difyctl/0.1.9-rc.1 (darwin; arm64; rc)"})
assert res.status_code == 426
def test_non_difyctl_ua_passes(self, client):
res = client.get(_gated_path(), headers={"User-Agent": "curl/8.4.0"})
assert res.status_code != 426
def test_missing_ua_passes(self, client):
res = client.get(_gated_path())
assert res.status_code != 426
def test_unparseable_version_passes(self, client):
res = client.get(_gated_path(), headers={"User-Agent": "difyctl/notaversion (x; y; z)"})
assert res.status_code != 426
def test_version_probe_allowlisted(self, client):
res = client.get("/openapi/v1/_version", headers={"User-Agent": OLD_UA})
assert res.status_code == 200
def test_health_allowlisted(self, client):
res = client.get("/openapi/v1/_health", headers={"User-Agent": OLD_UA})
assert res.status_code == 200

View File

@ -1,7 +1,7 @@
"""Member endpoints under /openapi/v1/workspaces/<id>/...
Coverage:
- Route registration (5 endpoints across 4 URL patterns)
- Route registration (5 endpoints across 3 URL patterns)
- Body validation lands at 400 (per spec not Pydantic's default 422)
- Domain exception HTTP code mapping is preserved with the service's
original message (so CLI users see what the console user sees)
@ -37,7 +37,6 @@ from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePay
from controllers.openapi.auth.data import AuthData
from controllers.openapi.workspaces import (
WorkspaceMemberApi,
WorkspaceMemberRoleApi,
WorkspaceMembersApi,
WorkspaceSwitchApi,
)
@ -175,7 +174,7 @@ def _account_service(**overrides) -> SimpleNamespace:
def test_switch_route_registered(openapi_app: Flask):
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/switch")
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>:switch")
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceSwitchApi
assert "POST" in rule.methods
@ -191,12 +190,7 @@ def test_member_by_id_route_registered(openapi_app: Flask):
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/members/<string:member_id>")
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMemberApi
assert "DELETE" in rule.methods
def test_member_role_route_registered(openapi_app: Flask):
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/members/<string:member_id>/role")
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMemberRoleApi
assert "PUT" in rule.methods
assert "PATCH" in rule.methods
# ---------------------------------------------------------------------------
@ -250,17 +244,17 @@ def test_update_role_rejects_invalid_body_with_422(app: Flask, bypass_pipeline):
"""Invalid role-update body surfaces as 422 through @accepts (was 400)."""
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMemberRoleApi()
api = WorkspaceMemberApi()
with app.test_request_context(
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
method="PUT",
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
method="PATCH",
data=json.dumps({"role": "owner"}), # closed enum rejects owner
content_type="application/json",
):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(UnprocessableEntity):
api.put.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id))
api.patch.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id))
# ---------------------------------------------------------------------------
@ -291,7 +285,7 @@ def test_switch_returns_workspace_detail_with_current_true(
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}:switch", method="POST"):
_seed(_auth_ctx(account_id=acct_id))
body, status = api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
@ -320,7 +314,7 @@ def test_switch_404s_when_service_raises_account_not_link_tenant(
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}:switch", method="POST"):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(NotFound):
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
@ -767,7 +761,7 @@ def test_delete_member_404_when_member_missing(app: Flask, bypass_pipeline, monk
def test_update_role_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch):
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMemberRoleApi()
api = WorkspaceMemberApi()
mock_db = MagicMock()
mock_db.session.get.side_effect = [
@ -785,13 +779,15 @@ def test_update_role_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
method="PUT",
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
method="PATCH",
data=json.dumps({"role": "admin"}),
content_type="application/json",
):
_seed(_auth_ctx(account_id=acct_id))
body, status = api.put.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id))
body, status = api.patch.__wrapped__(
api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id)
)
assert status == 200
assert body == {"result": "success"}
@ -811,7 +807,7 @@ def test_update_role_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest
def test_update_role_exception_mapping(app: Flask, bypass_pipeline, monkeypatch, exc, expected):
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMemberRoleApi()
api = WorkspaceMemberApi()
mock_db = MagicMock()
mock_db.session.get.side_effect = [
@ -828,14 +824,14 @@ def test_update_role_exception_mapping(app: Flask, bypass_pipeline, monkeypatch,
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
method="PUT",
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
method="PATCH",
data=json.dumps({"role": "admin"}),
content_type="application/json",
):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(expected):
api.put.__wrapped__(
api.patch.__wrapped__(
api,
workspace_id=ws_id,
member_id=member_id,

View File

@ -1,13 +1,13 @@
{
"name": "@langgenius/difyctl",
"type": "module",
"version": "0.1.0-alpha",
"version": "0.2.0-alpha",
"description": "Dify command-line interface",
"difyctl": {
"channel": "alpha",
"compat": {
"minDify": "1.15.0",
"maxDify": "1.15.0"
"minDify": "1.16.0",
"maxDify": "1.16.0"
},
"release": {
"tagPrefix": "difyctl-v",

View File

@ -15,41 +15,41 @@ function run(args: string[]): { code: number, stdout: string, stderr: string } {
}
}
describe('release-naming compat-check (compat 1.15.0..1.15.0)', () => {
describe('release-naming compat-check (compat 1.16.0..1.16.0)', () => {
it('accepts a version inside the window', () => {
expect(run(['compat-check', '1.15.0']).code).toBe(0)
expect(run(['compat-check', '1.16.0']).code).toBe(0)
})
it('accepts the inclusive lower bound', () => {
expect(run(['compat-check', '1.15.0']).code).toBe(0)
expect(run(['compat-check', '1.16.0']).code).toBe(0)
})
it('accepts the inclusive upper bound', () => {
expect(run(['compat-check', '1.15.0']).code).toBe(0)
expect(run(['compat-check', '1.16.0']).code).toBe(0)
})
it('accepts a v-prefixed tag', () => {
expect(run(['compat-check', 'v1.15.0']).code).toBe(0)
expect(run(['compat-check', 'v1.16.0']).code).toBe(0)
})
it('rejects a version below the lower bound', () => {
expect(run(['compat-check', '1.14.9']).code).not.toBe(0)
expect(run(['compat-check', '1.15.9']).code).not.toBe(0)
})
it('rejects a version above the upper bound', () => {
expect(run(['compat-check', '1.15.1']).code).not.toBe(0)
expect(run(['compat-check', '1.16.1']).code).not.toBe(0)
})
it('treats a prerelease of the bound as below it (1.15.0-rc1 < 1.15.0)', () => {
expect(run(['compat-check', '1.15.0-rc1']).code).not.toBe(0)
it('treats a prerelease of the bound as below it (1.16.0-rc1 < 1.16.0)', () => {
expect(run(['compat-check', '1.16.0-rc1']).code).not.toBe(0)
})
it('ignores build metadata on the bound (1.15.0+build == 1.15.0)', () => {
expect(run(['compat-check', '1.15.0+build123']).code).toBe(0)
it('ignores build metadata on the bound (1.16.0+build == 1.16.0)', () => {
expect(run(['compat-check', '1.16.0+build123']).code).toBe(0)
})
it('ignores build metadata when out of range (1.15.1+build still rejected)', () => {
expect(run(['compat-check', '1.15.1+build123']).code).not.toBe(0)
it('ignores build metadata when out of range (1.16.1+build still rejected)', () => {
expect(run(['compat-check', '1.16.1+build123']).code).not.toBe(0)
})
it('requires a version argument', () => {
@ -60,7 +60,7 @@ describe('release-naming compat-check (compat 1.15.0..1.15.0)', () => {
describe('release-naming github-env', () => {
it('emits difyctlTag = tagPrefix + version', () => {
const { stdout } = run(['github-env'])
expect(stdout).toMatch(/^difyctlTag=difyctl-v0\.1\.0-alpha$/m)
expect(stdout).toMatch(/^difyctlTag=difyctl-v0\.2\.0-alpha$/m)
})
it('still emits the existing trace fields', () => {
@ -75,14 +75,14 @@ describe('release-naming edge channel', () => {
expect(run(['channels']).stdout).toMatch(/^edge$/m)
})
it('edge-version derives <pkgcore>-edge.<sha> stripping the alpha prerelease', () => {
// package.json version is 0.1.0-alpha -> core 0.1.0
expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe('0.1.0-edge.2fd7b82')
it('edge-version derives <pkgcore>-edge.<sha> from the package version', () => {
// package.json version is 0.2.0-alpha -> core 0.2.0
expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe('0.2.0-edge.2fd7b82')
})
it('edge-version accepts a 40-char sha', () => {
const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000'
expect(run(['edge-version', sha]).stdout.trim()).toBe(`0.1.0-edge.${sha}`)
expect(run(['edge-version', sha]).stdout.trim()).toBe(`0.2.0-edge.${sha}`)
})
it('edge-version rejects a non-hex sha', () => {

View File

@ -81,7 +81,7 @@ describe('release-r2-edge manifest', () => {
it('carries the compat window from package.json', () => {
const { json } = buildManifest()
expect(json.compat).toEqual({ minDify: '1.15.0', maxDify: '1.15.0' })
expect(json.compat).toEqual({ minDify: '1.16.0', maxDify: '1.16.0' })
})
it('lists all 5 targets with asset name + sha256 from the checksums file', () => {

View File

@ -26,7 +26,7 @@ describe('AppDslClient.exportDsl', () => {
const yaml = await makeClient(stub.url).exportDsl('app-1')
expect(stub.captured.method).toBe('GET')
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/export')
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/dsl')
expect(yaml).toBe(DSL_YAML)
})
@ -90,7 +90,7 @@ describe('AppDslClient.confirmImport', () => {
const result = await makeClient(stub.url).confirmImport('ws-1', 'imp-1')
expect(stub.captured.method).toBe('POST')
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/apps/imports/imp-1/confirm')
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/apps/imports/imp-1:confirm')
expect(result.status).toBe('completed')
})
})
@ -107,7 +107,7 @@ describe('AppDslClient.checkDependencies', () => {
const result = await makeClient(stub.url).checkDependencies('app-1')
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/check-dependencies')
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/dependencies:check')
expect(result.leaked_dependencies).toEqual([])
})
})

View File

@ -33,7 +33,7 @@ export class AppDslClient {
}
async exportDsl(appId: string, query?: ExportQuery): Promise<string> {
const resp = await this.orpc.apps.byAppId.export.get({
const resp = await this.orpc.apps.byAppId.dsl.get({
params: { app_id: appId },
query: query !== undefined
? {
@ -52,7 +52,7 @@ export class AppDslClient {
}
async checkDependencies(appId: string): Promise<CheckDependenciesResult> {
return this.orpc.apps.byAppId.checkDependencies.get({
return this.orpc.apps.byAppId.dependencies.check.get({
params: { app_id: appId },
})
}

View File

@ -54,7 +54,7 @@ export class AppRunClient {
body: Record<string, unknown>,
opts: StreamOptions = {},
): Promise<AsyncIterable<SseEvent>> {
const res = await this.http.stream(`apps/${encodeURIComponent(appId)}/run`, {
const res = await this.http.stream(`apps/${encodeURIComponent(appId)}:run`, {
method: 'POST',
json: body,
headers: { Accept: 'text/event-stream' },
@ -79,7 +79,7 @@ export class AppRunClient {
action: string,
inputs: Record<string, unknown>,
): Promise<void> {
await this.orpc.apps.byAppId.form.humanInput.byFormToken.post({
await this.orpc.apps.byAppId.humanInputForms.byFormToken.submit.post({
params: { app_id: appId, form_token: formToken },
body: { action, inputs },
})

View File

@ -82,12 +82,12 @@ describe('AppsClient.describe', () => {
await stub?.stop()
})
it('hits /apps/<id>/describe, omits workspace_id and fields when not given', async () => {
it('hits /apps/<id>, omits workspace_id and fields when not given', async () => {
stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap))
const res = await makeClient(stub.url).describe('app-1')
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/describe')
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1')
const q = queryOf(stub.captured.url)
expect(q.has('workspace_id')).toBe(false)
expect(q.has('fields')).toBe(false)
@ -107,6 +107,6 @@ describe('AppsClient.describe', () => {
await makeClient(stub.url).describe('app/with space')
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app%2Fwith%20space/describe')
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app%2Fwith%20space')
})
})

View File

@ -37,7 +37,7 @@ export class AppsClient implements AppReader {
}
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
return this.orpc.apps.byAppId.describe.get({
return this.orpc.apps.byAppId.get({
params: { app_id: appId },
query: {
fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined,

View File

@ -41,7 +41,7 @@ describe('FileUploadClient.upload', () => {
const result = await makeClient(stub.url).upload('app-1', filePath)
expect(stub.captured.method).toBe('POST')
expect(stub.captured.url).toBe('/openapi/v1/apps/app-1/files/upload')
expect(stub.captured.url).toBe('/openapi/v1/apps/app-1/files')
// The client must let fetch own the multipart Content-Type + boundary; it
// must NOT coerce this to application/json the way a json body would.
const contentType = stub.captured.headers?.['content-type'] ?? ''
@ -61,7 +61,7 @@ describe('FileUploadClient.upload', () => {
await makeClient(stub.url).upload('app/with space', filePath)
expect(stub.captured.url).toBe('/openapi/v1/apps/app%2Fwith%20space/files/upload')
expect(stub.captured.url).toBe('/openapi/v1/apps/app%2Fwith%20space/files')
})
it('propagates a server 413 as a classified BaseError', async () => {

View File

@ -65,7 +65,7 @@ export class FileUploadClient {
form.append('file', blob, filename)
return this.http.post<UploadedFile>(
`apps/${encodeURIComponent(appId)}/files/upload`,
`apps/${encodeURIComponent(appId)}/files`,
{ body: form, timeoutMs: 60_000 },
)
}

View File

@ -154,13 +154,13 @@ describe('MembersClient.updateRole', () => {
await stub?.stop()
})
it('PUTs role payload to /role subresource', async () => {
it('PATCHes role payload to the member resource', async () => {
stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap))
const result = await makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' })
expect(stub.captured.method).toBe('PUT')
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1/role')
expect(stub.captured.method).toBe('PATCH')
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1')
expect(JSON.parse(stub.captured.body ?? '{}')).toEqual({ role: 'admin' })
expect(result.result).toBe('success')
})
@ -181,7 +181,7 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
await stub?.stop()
})
it('POSTs /workspaces/<id>/switch and returns workspace detail', async () => {
it('POSTs /workspaces/<id>:switch and returns workspace detail', async () => {
stub = await startStubServer(cap =>
jsonResponder(
200,
@ -200,7 +200,7 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
const result = await client.switch('ws-1')
expect(stub.captured.method).toBe('POST')
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/switch')
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1:switch')
expect(result.current).toBe(true)
})

View File

@ -47,7 +47,7 @@ export class MembersClient {
memberId: string,
payload: MemberRoleUpdatePayload,
): Promise<MemberActionResponse> {
return this.orpc.workspaces.byWorkspaceId.members.byMemberId.role.put({
return this.orpc.workspaces.byWorkspaceId.members.byMemberId.patch({
params: { workspace_id: workspaceId, member_id: memberId },
body: payload,
})

View File

@ -12,15 +12,15 @@ describe('PermittedExternalAppsClient', () => {
it('list calls permittedExternalApps.get with paging/filter query', async () => {
const c = new PermittedExternalAppsClient(fakeHttp())
const get = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 0, has_more: false, data: [] })
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get, byAppId: { describe: { get: vi.fn() } } } }
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get, byAppId: { get: vi.fn() } } }
await c.list({ workspaceId: '', page: 2, limit: 5, mode: undefined, name: 'a' })
expect(get).toHaveBeenCalledWith({ query: { page: 2, limit: 5, mode: undefined, name: 'a' } })
})
it('describe calls permittedExternalApps.byAppId.describe.get with app_id + fields', async () => {
it('describe calls permittedExternalApps.byAppId.get with app_id + fields', async () => {
const c = new PermittedExternalAppsClient(fakeHttp())
const dget = vi.fn().mockResolvedValue({ info: null, parameters: null, input_schema: null })
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get: vi.fn(), byAppId: { describe: { get: dget } } } }
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get: vi.fn(), byAppId: { get: dget } } }
await c.describe('app-1', ['info'])
expect(dget).toHaveBeenCalledWith({ params: { app_id: 'app-1' }, query: { fields: 'info' } })
})

View File

@ -26,7 +26,7 @@ export class PermittedExternalAppsClient implements AppReader {
}
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
return this.orpc.permittedExternalApps.byAppId.describe.get({
return this.orpc.permittedExternalApps.byAppId.get({
params: { app_id: appId },
query: { fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined },
})

View File

@ -19,7 +19,7 @@ export class WorkspacesClient {
/**
* Server-side workspace switch via OpenAPI POST
* `/workspaces/{id}/switch` the bearer-authed equivalent of the
* `/workspaces/{id}:switch` the bearer-authed equivalent of the
* console's POST `/workspaces/switch`. The server updates the caller's
* `current` tenant_account_join row. Callers MUST refresh their local
* `hosts.yml` only after this resolves never fall back to a local

57
cli/src/cache/compat-store.test.ts vendored Normal file
View File

@ -0,0 +1,57 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ENV_CACHE_DIR } from '@/store/dir'
import { CACHE_COMPAT, getCache } from '@/store/manager'
import { loadCompatStore } from './compat-store'
const HOST = 'https://cloud.dify.ai'
const NOW = new Date('2026-05-20T12:00:00.000Z')
describe('compat-store', () => {
let dir: string
let prev: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-compat-'))
prev = process.env[ENV_CACHE_DIR]
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
if (prev === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prev
await rm(dir, { recursive: true, force: true })
})
const store = (now: Date = NOW) => loadCompatStore({ store: getCache(CACHE_COMPAT), now: () => now })
it('is not fresh before anything is marked', async () => {
expect((await store()).isFreshCompatible(HOST)).toBe(false)
})
it('is fresh right after markCompatible, and persists across loads', async () => {
await (await store()).markCompatible(HOST)
expect((await store()).isFreshCompatible(HOST)).toBe(true)
})
it('stays fresh within the 1h TTL', async () => {
const past = new Date(NOW.getTime() - 30 * 60 * 1000)
await (await store(past)).markCompatible(HOST)
expect((await store(NOW)).isFreshCompatible(HOST)).toBe(true)
})
it('expires after the 1h TTL', async () => {
const past = new Date(NOW.getTime() - 61 * 60 * 1000)
await (await store(past)).markCompatible(HOST)
expect((await store(NOW)).isFreshCompatible(HOST)).toBe(false)
})
it('tracks hosts independently', async () => {
const s = await store()
await s.markCompatible(HOST)
expect(s.isFreshCompatible('https://other.dify.ai')).toBe(false)
})
})

71
cli/src/cache/compat-store.ts vendored Normal file
View File

@ -0,0 +1,71 @@
import type { Store } from '@/store/store'
import { CACHE_COMPAT, getCache } from '@/store/manager'
// How long a host stays "known compatible" before difyctl re-probes /_version.
export const COMPAT_TTL_MS = 60 * 60 * 1000
// Only *positive* (compatible) verdicts are cached — never "too old". A host that
// was too old is re-probed every time, so a just-upgraded server clears a previous
// block immediately instead of staying locked out for the whole TTL.
const COMPATIBLE_KEY = { key: 'compatible', default: {} as Record<string, string> } as const
export type CompatStore = {
readonly isFreshCompatible: (host: string, now?: Date) => boolean
readonly markCompatible: (host: string, now?: Date) => Promise<void>
}
export type CompatStoreOptions = {
readonly store?: Store
readonly now?: () => Date
readonly ttlMs?: number
}
export async function loadCompatStore(opts: CompatStoreOptions = {}): Promise<CompatStore> {
const store = opts.store ?? getCache(CACHE_COMPAT)
const ttlMs = opts.ttlMs ?? COMPAT_TTL_MS
const clock = opts.now ?? (() => new Date())
const memory = await readCompatible(store)
return {
isFreshCompatible: (host, now) => {
const last = memory.get(host)
if (last === undefined)
return false
const elapsed = Math.max(0, (now ?? clock()).getTime() - last)
return elapsed < ttlMs
},
markCompatible: async (host, now) => {
const stamp = (now ?? clock()).getTime()
memory.set(host, stamp)
// Re-read disk inside the write cycle so concurrent processes touching
// different hosts don't clobber each other's stamps.
const onDisk = await readCompatible(store)
onDisk.set(host, stamp)
await writeCompatible(store, onDisk)
},
}
}
async function readCompatible(store: Store): Promise<Map<string, number>> {
const out = new Map<string, number>()
let raw: Record<string, string>
try {
raw = await store.get(COMPATIBLE_KEY)
}
catch {
return out
}
for (const [host, iso] of Object.entries(raw)) {
const t = Date.parse(iso)
if (!Number.isNaN(t))
out.set(host, t)
}
return out
}
async function writeCompatible(store: Store, state: Map<string, number>): Promise<void> {
const compatible: Record<string, string> = {}
for (const [host, t] of state)
compatible[host] = new Date(t).toISOString()
await store.set(COMPATIBLE_KEY, compatible)
}

View File

@ -14,6 +14,7 @@ import { createHttpClient } from '@/http/client'
import { getTokenStore } from '@/store/manager'
import { realStreams } from '@/sys/io/streams'
import { hostWithScheme, openAPIBase } from '@/util/host'
import { enforceDifyVersion } from '@/version/enforce'
import { versionInfo } from '@/version/info'
import { maybeNudgeCompat } from '@/version/nudge'
import { resolveRetryAttempts } from './global-flags.js'
@ -55,6 +56,10 @@ export async function buildAuthedContext(
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
// Hard gate: refuse a server too old for this difyctl (throws → exit 6).
// Cached per host (1h) so most commands don't re-probe. Then the soft nudge
// handles the "server too new" direction.
await enforceDifyVersion(host)
await runCompatNudge({ host, io })
return { reg, active, store, http, host, io, cache }

View File

@ -2,6 +2,7 @@ import type { CommandEffect } from '@/framework/command'
import { DifyCommand } from '@/commands/_shared/dify-command'
import { Flags } from '@/framework/flags'
import { realStreams } from '@/sys/io/streams'
import { enforceDifyVersion } from '@/version/enforce'
import { agentGuide } from './guide'
import { runLogin } from './login'
@ -38,6 +39,9 @@ export default class Login extends DifyCommand {
host: flags.host,
noBrowser: flags['no-browser'],
insecure: flags.insecure,
verifyServer: async (host) => {
await enforceDifyVersion(host, { forceFresh: true })
},
})
}

View File

@ -31,6 +31,10 @@ export type LoginOptions = {
readonly browserEnv?: BrowserEnv
readonly browserOpener?: BrowserOpener
readonly clock?: Clock
// Version guard for the freshly-authenticated host; wired to enforceDifyVersion
// at the command boundary. Runs before the session is persisted so we never
// save credentials for a server too old for this difyctl. Defaults to a no-op.
readonly verifyServer?: (host: string) => Promise<void>
}
export async function runLogin(opts: LoginOptions): Promise<Registry> {
@ -70,6 +74,9 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
spinner.stop()
}
// Refuse to persist a session to a server too old for this difyctl.
await (opts.verifyServer ?? (async () => {}))(host)
const storeBundle = opts.store ?? await detectTokenStore()
const display = bareHost(host)
const email = accountEmail(success)

View File

@ -41,7 +41,7 @@ describe('resumeApp pre-flight subject strategy', () => {
const http = {
baseURL: 'http://localhost',
request: vi.fn().mockImplementation((opts: { path: string }) => {
if (typeof opts.path === 'string' && opts.path.includes('form/human_input')) {
if (typeof opts.path === 'string' && opts.path.includes('human-input-forms')) {
return Promise.resolve(FORM_RESP)
}
// reconnect stream — return an async iterable that ends immediately

View File

@ -50,7 +50,7 @@ export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Pr
let action = opts.action
if (action === undefined) {
const formResp = await deps.http.get<{ user_actions: { id: string }[] }>(
`apps/${encodeURIComponent(opts.appId)}/form/human_input/${encodeURIComponent(opts.formToken)}`,
`apps/${encodeURIComponent(opts.appId)}/human-input-forms/${encodeURIComponent(opts.formToken)}`,
)
if (formResp.user_actions.length === 1) {
action = formResp.user_actions[0]?.id ?? ''

View File

@ -65,7 +65,7 @@ async function executeRun(
const m = await meta.get(opts.appId, [FieldInfo])
const mode = m.info?.mode ?? ''
if (mode === '')
throw new Error(`app ${opts.appId}: mode missing from /describe`)
throw new Error(`app ${opts.appId}: mode missing from app metadata`)
if (mode === RUN_MODES.Workflow && opts.message !== undefined && opts.message !== '') {
throw new BaseError({

View File

@ -27,7 +27,7 @@ export type UseWorkspaceDeps = {
* workspace list and let the caller pick one interactively (TTY only).
*
* The server-side switch is the source of truth: if POST
* `/workspaces/<id>/switch` fails we abort before touching `hosts.yml`, so
* `/workspaces/<id>:switch` fails we abort before touching `hosts.yml`, so
* local state never diverges from the server.
*/
export async function runUseWorkspace(

View File

@ -109,7 +109,7 @@ describe('Version command', () => {
}
it('--check-compat exits with COMPAT_FAIL_EXIT_CODE when compat is unsupported', async () => {
vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ status: 'unsupported' }))
vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ status: 'too_new' }))
const exitSpy = stubProcessExit()
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
@ -119,7 +119,7 @@ describe('Version command', () => {
})
it('--check-compat -o json emits the JSON envelope on stdout before exiting', async () => {
vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ status: 'unsupported' }))
vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ status: 'too_new' }))
const exitSpy = stubProcessExit()
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
@ -131,7 +131,7 @@ describe('Version command', () => {
expect(stdoutSpy).toHaveBeenCalled()
const written = stdoutSpy.mock.calls.map(c => String(c[0])).join('')
const parsed = JSON.parse(written) as { compat: { status: string } }
expect(parsed.compat.status).toBe('unsupported')
expect(parsed.compat.status).toBe('too_new')
expect(exitSpy).toHaveBeenCalledWith(COMPAT_FAIL_EXIT_CODE)
})

View File

@ -184,7 +184,7 @@ describe('http client', () => {
const client = createHttpClient({ baseURL: base(mock.url), bearer: 'dfoa_test' })
let caught: unknown
try {
await client.get('apps/nope/describe')
await client.get('apps/nope')
}
catch (err) { caught = err }
expect(isHttpClientError(caught)).toBe(true)
@ -545,7 +545,7 @@ describe('empty / No-Content bodies', () => {
})
try {
const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test' })
await expect(client.post('apps/app-1/tasks/t-1/stop', { json: {} })).resolves.toBeUndefined()
await expect(client.post('apps/app-1/tasks/t-1:stop', { json: {} })).resolves.toBeUndefined()
}
finally {
await stub.stop()

View File

@ -74,7 +74,7 @@ describe('classifyResponse — canonical ErrorBody', () => {
describe('classifyResponse 403', () => {
it('maps 403 to AccessDenied (exit 4 bucket)', async () => {
const req403 = new Request('https://x/openapi/v1/apps/abc/export')
const req403 = new Request('https://x/openapi/v1/apps/abc/dsl')
const res403 = new Response(
JSON.stringify({ code: 'unsupported_token_type', message: 'unsupported_token_type', status: 403 }),
{ status: 403, headers: { 'content-type': 'application/json' } },
@ -91,6 +91,31 @@ describe('classifyResponse 403', () => {
})
})
describe('classifyResponse 426', () => {
it('maps 426 to VersionSkew (exit 6) and surfaces the server upgrade message', async () => {
const body = {
code: 'upgrade_required',
message: 'difyctl 0.1.0 is no longer supported; upgrade to >= 0.2.0.',
status: 426,
hint: 'Upgrade difyctl: https://docs.dify.ai/en/cli/install',
}
const err = await classified(426, body)
expect(err.code).toBe(ErrorCode.VersionSkew)
expect(err.exit()).toBe(6)
expect(err.message).toBe('difyctl 0.1.0 is no longer supported; upgrade to >= 0.2.0.')
expect(err.serverError?.code).toBe('upgrade_required')
})
it('426 with no parseable ErrorBody falls back to a version message', async () => {
const err = await classified(426, 'not json')
expect(err.code).toBe(ErrorCode.VersionSkew)
expect(err.message).toBe('client version no longer supported by the server')
})
})
describe('classifyResponse — non-conforming bodies (no fallback by design)', () => {
it('non-JSON body yields no serverError, classification by status', async () => {
const err = await classified(502, '<html>bad gateway</html>')

View File

@ -50,11 +50,22 @@ const ACCESS_DENIED_CLASS: StatusClass = {
includeRaw: false,
}
// 426 Upgrade Required: the server rejected this difyctl as too old. Give it the
// version-compat exit code so scripts can tell it apart from a generic failure.
// The server's ErrorBody.code ("upgrade_required") + message still ride along.
const VERSION_COMPAT_CLASS: StatusClass = {
code: ErrorCode.VersionSkew,
fallbackMessage: () => 'client version no longer supported by the server',
includeRaw: false,
}
function statusClass(status: number): StatusClass {
if (status === 401)
return AUTH_EXPIRED_CLASS
if (status === 403)
return ACCESS_DENIED_CLASS
if (status === 426)
return VERSION_COMPAT_CLASS
if (status === 429)
return RATE_LIMITED_CLASS
if (status >= 500)

View File

@ -7,6 +7,7 @@ import { FileTokenStore, KeychainTokenStore } from './token-store'
export const CACHE_APP_INFO = 'app-info'
export const CACHE_NUDGE = 'nudge'
export const CACHE_COMPAT = 'compat'
const HOSTS_FILE = 'hosts.yml'
const TOKENS_FILE = 'tokens.yml'
export const CONFIG_FILE_NAME = 'config.yml'

View File

@ -32,14 +32,38 @@ describe('evaluateCompat', () => {
expect(evaluateCompat('1.7.0', range).status).toBe('compatible')
})
it('returns unsupported when server is below minimum', () => {
it('returns too_old when server is below minimum', () => {
const v = evaluateCompat('1.5.9', range)
expect(v.status).toBe('unsupported')
expect(v.status).toBe('too_old')
expect(v.detail).toContain('1.5.9')
})
it('returns unsupported when server is above maximum', () => {
expect(evaluateCompat('2.0.0', range).status).toBe('unsupported')
it('returns too_new when server is above maximum', () => {
expect(evaluateCompat('2.0.0', range).status).toBe('too_new')
})
describe('ignores pre-release/channel suffixes (numeric-core comparison)', () => {
it('treats a pre-release of the upper bound as compatible', () => {
// 1.7.0-rc.1 has core 1.7.0 == maxDify; a suffix-sensitive range would push
// it out of [1.6.0, 1.7.0], but its numeric core is in range.
expect(evaluateCompat('1.7.0-rc.1', range).status).toBe('compatible')
})
it('treats a pre-release of the lower bound as compatible', () => {
expect(evaluateCompat('1.6.0-alpha', range).status).toBe('compatible')
})
it('still flags a pre-release whose core is below the minimum as too_old', () => {
expect(evaluateCompat('1.5.9-rc.1', range).status).toBe('too_old')
})
it('still flags a pre-release whose core is above the maximum as too_new', () => {
expect(evaluateCompat('2.0.0-alpha', range).status).toBe('too_new')
})
it('strips suffixes on the range bounds too', () => {
expect(evaluateCompat('1.6.5', { minDify: '1.6.0-alpha', maxDify: '1.7.0-rc' }).status).toBe('compatible')
})
})
it('returns unknown when server version is empty', () => {

View File

@ -1,4 +1,5 @@
import { parseRange, satisfies, tryParse } from 'std-semver'
import type { SemVer } from 'std-semver'
import { compare, tryParse } from 'std-semver'
export type DifyCompat = {
readonly minDify: string
@ -14,7 +15,7 @@ export function compatString(): string {
return `dify >=${difyCompat.minDify}, <=${difyCompat.maxDify}`
}
export type CompatStatus = 'compatible' | 'unsupported' | 'unknown'
export type CompatStatus = 'compatible' | 'too_old' | 'too_new' | 'unknown'
export type CompatVerdict = {
readonly status: CompatStatus
@ -27,6 +28,12 @@ function clamp(s: string): string {
return s.length > DETAIL_MAX_LEN ? `${s.slice(0, DETAIL_MAX_LEN)}` : s
}
// Numeric core (major.minor.patch) with pre-release/build stripped, so ordering
// ignores channel suffixes: a 0.2.0-rc.1 build compares equal to the 0.2.0 floor.
function core(v: SemVer): SemVer {
return { major: v.major, minor: v.minor, patch: v.patch, prerelease: [], build: [] }
}
export function evaluateCompat(
serverVersion: string | undefined,
range: DifyCompat = difyCompat,
@ -34,25 +41,20 @@ export function evaluateCompat(
if (serverVersion === undefined || serverVersion === '')
return { status: 'unknown', detail: 'server version unknown' }
const parsedServer = tryParse(serverVersion)
if (parsedServer === undefined)
const server = tryParse(serverVersion)
if (server === undefined)
return { status: 'unknown', detail: `server version ${JSON.stringify(clamp(serverVersion))} is not valid semver` }
// The compat range is inclusive at both ends, exactly the format compatString prints.
const expr = `>=${range.minDify} <=${range.maxDify}`
const parsedRange = (() => {
try {
return parseRange(expr)
}
catch {
return undefined
}
})()
if (parsedRange === undefined)
return { status: 'unknown', detail: `compat range ${JSON.stringify(expr)} is not valid semver` }
const min = tryParse(range.minDify)
const max = tryParse(range.maxDify)
if (min === undefined || max === undefined)
return { status: 'unknown', detail: `compat range ${JSON.stringify(`>=${range.minDify} <=${range.maxDify}`)} is not valid semver` }
if (satisfies(parsedServer, parsedRange))
return { status: 'compatible', detail: `server ${serverVersion} in [${range.minDify}, ${range.maxDify}]` }
if (compare(core(server), core(min)) < 0)
return { status: 'too_old', detail: `server ${serverVersion} is older than the minimum ${range.minDify}` }
return { status: 'unsupported', detail: `server ${serverVersion} outside [${range.minDify}, ${range.maxDify}]` }
if (compare(core(server), core(max)) > 0)
return { status: 'too_new', detail: `server ${serverVersion} is newer than the tested maximum ${range.maxDify}` }
return { status: 'compatible', detail: `server ${serverVersion} in [${range.minDify}, ${range.maxDify}]` }
}

View File

@ -0,0 +1,86 @@
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
import type { CompatStore } from '@/cache/compat-store'
import { describe, expect, it, vi } from 'vitest'
import { ErrorCode } from '@/errors/codes'
import { enforceDifyVersion } from './enforce'
// Injected build range in tests is __DIFYCTL_MIN_DIFY__=1.6.0 / MAX=1.7.0 (test/setup.ts):
// 1.5.0 → too_old, 1.6.4 → compatible, 99.0.0 → too_new, '' → unknown.
const HOST = 'https://cloud.dify.ai'
function fakeStore(fresh = false): CompatStore & { readonly marked: string[] } {
const marked: string[] = []
return {
marked,
isFreshCompatible: () => fresh,
markCompatible: async (host) => {
marked.push(host)
},
}
}
const server = (version: string): ServerVersionResponse => ({ version, edition: 'SELF_HOSTED' })
describe('enforceDifyVersion', () => {
it('throws version_skew (exit 6) when the server is too old, and never caches it', async () => {
const store = fakeStore()
const probe = vi.fn(async () => server('1.5.0'))
await expect(enforceDifyVersion(HOST, { store, probe })).rejects.toMatchObject({ code: ErrorCode.VersionSkew })
expect(store.marked).toHaveLength(0)
})
it('passes and caches when the server is compatible', async () => {
const store = fakeStore()
const probe = vi.fn(async () => server('1.6.4'))
const res = await enforceDifyVersion(HOST, { store, probe })
expect(res?.version).toBe('1.6.4')
expect(store.marked).toEqual([HOST])
})
it('passes (soft, no throw) and caches when the server is too new', async () => {
const store = fakeStore()
const probe = vi.fn(async () => server('99.0.0'))
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeDefined()
expect(store.marked).toEqual([HOST])
})
it('skips the probe entirely when the host is fresh-compatible', async () => {
const store = fakeStore(true)
const probe = vi.fn(async () => server('1.5.0')) // would throw if it ran
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeUndefined()
expect(probe).not.toHaveBeenCalled()
})
it('re-probes despite a fresh cache when forceFresh is set', async () => {
const store = fakeStore(true)
const probe = vi.fn(async () => server('1.5.0'))
await expect(enforceDifyVersion(HOST, { store, probe, forceFresh: true }))
.rejects
.toMatchObject({ code: ErrorCode.VersionSkew })
expect(probe).toHaveBeenCalledOnce()
})
it('fails open (never blocks, never caches) when the probe errors', async () => {
const store = fakeStore()
const probe = vi.fn(async () => {
throw new Error('net down')
})
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeUndefined()
expect(store.marked).toHaveLength(0)
})
it('does not block or cache on an unknown server version', async () => {
const store = fakeStore()
const probe = vi.fn(async () => server(''))
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeDefined()
expect(store.marked).toHaveLength(0)
})
})

View File

@ -0,0 +1,69 @@
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
import type { CompatStore } from '@/cache/compat-store'
import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta'
import { loadCompatStore } from '@/cache/compat-store'
import { newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { createHttpClient } from '@/http/client'
import { openAPIBase } from '@/util/host'
import { difyCompat, evaluateCompat } from './compat'
import { versionInfo } from './info'
export type ServerVersionProbe = (host: string) => Promise<ServerVersionResponse>
const UPGRADE_HINT
= `upgrade the Dify server to >= ${difyCompat.minDify} `
+ '(https://docs.dify.ai/en/getting-started/install-self-hosted)'
// /_version is unauthenticated; same timeout/no-retry budget as the auto-nudge probe.
const defaultProbe: ServerVersionProbe = async (host) => {
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
return new MetaClient(http).serverVersion()
}
export type EnforceOptions = {
readonly probe?: ServerVersionProbe
readonly store?: CompatStore
readonly forceFresh?: boolean
}
/**
* Hard version gate for the client server direction: refuse a Dify server older
* than this difyctl requires (its removed paths would only 404 otherwise).
*
* Cached: a host recently confirmed compatible is not re-probed for COMPAT_TTL_MS.
* Only "compatible" is cached, so a just-upgraded server clears a previous block at
* once. Fails open on any probe error a flaky network never blocks a command.
* Returns the probed server version when it actually probed (skipped/failed undefined),
* so the caller can reuse it.
*/
export async function enforceDifyVersion(
host: string,
opts: EnforceOptions = {},
): Promise<ServerVersionResponse | undefined> {
const store = opts.store ?? await loadCompatStore()
if (opts.forceFresh !== true && store.isFreshCompatible(host))
return undefined
const probe = opts.probe ?? defaultProbe
let server: ServerVersionResponse
try {
server = await probe(host)
}
catch {
return undefined
}
const verdict = evaluateCompat(server.version)
if (verdict.status === 'too_old') {
throw newError(
ErrorCode.VersionSkew,
`Dify server ${server.version} is too old for difyctl ${versionInfo.version}: ${verdict.detail}`,
).withHint(UPGRADE_HINT)
}
if (verdict.status === 'compatible' || verdict.status === 'too_new')
await store.markCompatible(host)
return server
}

View File

@ -44,7 +44,9 @@ export async function maybeNudgeCompat(host: string, deps: NudgeDeps): Promise<v
}
const verdict = evaluateCompat(server.version)
if (verdict.status !== 'unsupported')
// Only "too new" is a soft nudge here; "too old" is hard-failed up front by
// enforceDifyVersion, so the command never reaches this path for it.
if (verdict.status !== 'too_new')
return
deps.emit(formatBanner(deps.clientVersion, server.version, deps.color === true))

View File

@ -105,7 +105,7 @@ describe('runVersionProbe', () => {
expect(report.compat.status).toBe('compatible')
})
it('returns unsupported when server version is out of range', async () => {
it('returns too_new when server version is above range', async () => {
const report = await runVersionProbe({
skipServer: false,
loadActive: async () => active(),
@ -113,7 +113,7 @@ describe('runVersionProbe', () => {
})
expect(report.server.reachable).toBe(true)
expect(report.compat.status).toBe('unsupported')
expect(report.compat.status).toBe('too_new')
})
it('returns unknown when server returns an empty version string', async () => {

View File

@ -131,7 +131,7 @@ describe('renderVersionText', () => {
compat: {
minDify: '1.6.0',
maxDify: '1.7.0',
status: 'unsupported',
status: 'too_new',
detail: 'server 99.0.0 outside [1.6.0, 1.7.0]',
},
}
@ -175,7 +175,7 @@ describe('renderVersionText', () => {
compat: {
minDify: '1.6.0',
maxDify: '1.7.0',
status: 'unsupported',
status: 'too_new',
detail: 'server 99.0.0 outside [1.6.0, 1.7.0]',
},
}

View File

@ -15,7 +15,8 @@ export type RenderOptions = {
const COMPAT_LABEL: Record<VersionReport['compat']['status'], string> = {
compatible: 'ok',
unsupported: 'incompatible',
too_old: 'incompatible (server too old)',
too_new: 'incompatible (server too new)',
unknown: 'unknown',
}
@ -50,7 +51,8 @@ export function renderVersionText(report: VersionReport, opts: RenderOptions = {
lines.push('')
const verdictText = `Compatibility: ${COMPAT_LABEL[compat.status]}${compat.detail}`
lines.push(compat.status === 'unsupported' ? c.yellow(verdictText) : verdictText)
const incompatible = compat.status === 'too_old' || compat.status === 'too_new'
lines.push(incompatible ? c.yellow(verdictText) : verdictText)
if (client.channel !== 'stable') {
lines.push('')

View File

@ -3,7 +3,7 @@
*
* Test cases sourced from: Dify CLI Enhanced spec Dify CLI/Discovery/Single App Query (22 cases)
*
* Note: difyctl get app <id> queries a single app via GET /apps/<id>/describe?fields=info.
* Note: difyctl get app <id> queries a single app via GET /apps/<id>?fields=info.
* The response is returned in list-envelope format {page,limit,total,data:[...]}.
*/

View File

@ -111,15 +111,15 @@ describe('dify-mock fixture server', () => {
expect(body.data.map(r => r.id).sort()).toEqual(['app-3', 'app-4'])
})
it('GET /openapi/v1/apps/:id/describe returns 404 for unknown id', async () => {
const r = await fetch(`${mock.url}/openapi/v1/apps/nope/describe?workspace_id=550e8400-e29b-41d4-a716-446655440000`, {
it('GET /openapi/v1/apps/:id returns 404 for unknown id', async () => {
const r = await fetch(`${mock.url}/openapi/v1/apps/nope?workspace_id=550e8400-e29b-41d4-a716-446655440000`, {
headers: { Authorization: 'Bearer dfoa_test' },
})
expect(r.status).toBe(404)
})
it('GET /openapi/v1/apps/:id/describe returns the app for known id', async () => {
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1/describe?workspace_id=550e8400-e29b-41d4-a716-446655440000`, {
it('GET /openapi/v1/apps/:id returns the app for known id', async () => {
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000`, {
headers: { Authorization: 'Bearer dfoa_test' },
})
expect(r.status).toBe(200)
@ -127,8 +127,8 @@ describe('dify-mock fixture server', () => {
expect(body.info.id).toBe('app-1')
})
it('POST /openapi/v1/apps/:id/run returns SSE stream for chat app', async () => {
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1/run`, {
it('POST /openapi/v1/apps/:id:run returns SSE stream for chat app', async () => {
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1:run`, {
method: 'POST',
headers: {
'Authorization': 'Bearer dfoa_test',
@ -142,8 +142,8 @@ describe('dify-mock fixture server', () => {
expect(text).toContain('"answer":"echo: "')
})
it('POST /openapi/v1/apps/:id/run returns SSE stream for workflow app', async () => {
const r = await fetch(`${mock.url}/openapi/v1/apps/app-2/run`, {
it('POST /openapi/v1/apps/:id:run returns SSE stream for workflow app', async () => {
const r = await fetch(`${mock.url}/openapi/v1/apps/app-2:run`, {
method: 'POST',
headers: {
'Authorization': 'Bearer dfoa_test',
@ -157,8 +157,8 @@ describe('dify-mock fixture server', () => {
expect(text).toContain('"workflow_finished"')
})
it('GET /openapi/v1/apps/:id/describe?fields=info returns slim payload', async () => {
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1/describe?workspace_id=550e8400-e29b-41d4-a716-446655440000&fields=info`, {
it('GET /openapi/v1/apps/:id?fields=info returns slim payload', async () => {
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000&fields=info`, {
headers: { Authorization: 'Bearer dfoa_test' },
})
expect(r.status).toBe(200)
@ -168,8 +168,8 @@ describe('dify-mock fixture server', () => {
expect(body.input_schema).toBeNull()
})
it('GET /openapi/v1/apps/:id/describe full returns parameters when present', async () => {
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1/describe?workspace_id=550e8400-e29b-41d4-a716-446655440000`, {
it('GET /openapi/v1/apps/:id full returns parameters when present', async () => {
const r = await fetch(`${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000`, {
headers: { Authorization: 'Bearer dfoa_test' },
})
expect(r.status).toBe(200)

View File

@ -15,9 +15,9 @@ export type DifyMock = {
scenario: Scenario
setScenario: (s: Scenario) => void
stop: () => Promise<void>
/** Body of the most recent POST to /apps/:id/run */
/** Body of the most recent POST to /apps/:id:run */
lastRunBody: Record<string, unknown> | null
/** Number of times POST /apps/:id/files/upload was called */
/** Number of times POST /apps/:id/files was called */
uploadCallCount: number
/** Body of the most recent POST to /workspaces/:id/apps/imports */
lastImportBody: Record<string, unknown> | null
@ -251,7 +251,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
})
})
app.get('/openapi/v1/apps/:id/describe', (c) => {
app.get('/openapi/v1/apps/:id', (c) => {
const id = c.req.param('id')
const wsId = c.req.query('workspace_id')
const fieldsRaw = c.req.query('fields') ?? ''
@ -279,7 +279,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
})
})
app.get('/openapi/v1/permitted-external-apps/:id/describe', (c) => {
app.get('/openapi/v1/permitted-external-apps/:id', (c) => {
const id = c.req.param('id')
const fieldsRaw = c.req.query('fields') ?? ''
const fields = fieldsRaw === '' ? [] : fieldsRaw.split(',').map(s => s.trim()).filter(s => s !== '')
@ -307,7 +307,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
})
})
app.get('/openapi/v1/apps/:id/export', (c) => {
app.get('/openapi/v1/apps/:id/dsl', (c) => {
const id = c.req.param('id')
const found = APPS.find(a => a.id === id)
if (found === undefined)
@ -315,7 +315,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
return c.json({ data: DSL_YAML })
})
app.get('/openapi/v1/apps/:id/check-dependencies', (c) => {
app.get('/openapi/v1/apps/:id/dependencies:check', (c) => {
const id = c.req.param('id')
const found = APPS.find(a => a.id === id)
if (found === undefined)
@ -335,12 +335,13 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 })
})
app.post('/openapi/v1/workspaces/:wsId/apps/imports/:importId/confirm', (c) => {
app.post('/openapi/v1/workspaces/:wsId/apps/imports/:importId:confirm', (c) => {
return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 })
})
app.post('/openapi/v1/apps/:id/run', async (c) => {
const id = c.req.param('id')
app.post('/openapi/v1/apps/:id:run', async (c) => {
// Hono drops the param adjacent to the `:run` literal; recover the app id from the path.
const id = c.req.path.replace(/^.*\/apps\//, '').replace(/:run$/, '')
const body = await c.req.json() as { query?: string, inputs?: unknown }
if (state !== undefined)
state.lastRunBody = body as Record<string, unknown>
@ -400,7 +401,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } })
})
app.post('/openapi/v1/apps/:id/files/upload', async (c) => {
app.post('/openapi/v1/apps/:id/files', async (c) => {
if (state !== undefined)
state.uploadCallCount++
const form = await c.req.formData()
@ -421,11 +422,11 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
)
})
app.post('/openapi/v1/apps/:id/tasks/:taskId/stop', (c) => {
app.post('/openapi/v1/apps/:id/tasks/:taskId:stop', (c) => {
return c.json({ result: 'success' })
})
app.post('/openapi/v1/apps/:id/form/human_input/:formToken', (c) => {
app.post('/openapi/v1/apps/:id/human-input-forms/:formToken:submit', (c) => {
return c.json({})
})

View File

@ -12,16 +12,16 @@ import {
zGetAccountResponse,
zGetAccountSessionsQuery,
zGetAccountSessionsResponse,
zGetAppsByAppIdCheckDependenciesPath,
zGetAppsByAppIdCheckDependenciesResponse,
zGetAppsByAppIdDescribePath,
zGetAppsByAppIdDescribeQuery,
zGetAppsByAppIdDescribeResponse,
zGetAppsByAppIdExportPath,
zGetAppsByAppIdExportQuery,
zGetAppsByAppIdExportResponse,
zGetAppsByAppIdFormHumanInputByFormTokenPath,
zGetAppsByAppIdFormHumanInputByFormTokenResponse,
zGetAppsByAppIdDependenciesCheckPath,
zGetAppsByAppIdDependenciesCheckResponse,
zGetAppsByAppIdDslPath,
zGetAppsByAppIdDslQuery,
zGetAppsByAppIdDslResponse,
zGetAppsByAppIdHumanInputFormsByFormTokenPath,
zGetAppsByAppIdHumanInputFormsByFormTokenResponse,
zGetAppsByAppIdPath,
zGetAppsByAppIdQuery,
zGetAppsByAppIdResponse,
zGetAppsByAppIdTasksByTaskIdEventsPath,
zGetAppsByAppIdTasksByTaskIdEventsQuery,
zGetAppsByAppIdTasksByTaskIdEventsResponse,
@ -30,9 +30,9 @@ import {
zGetHealthResponse,
zGetOauthDeviceLookupQuery,
zGetOauthDeviceLookupResponse,
zGetPermittedExternalAppsByAppIdDescribePath,
zGetPermittedExternalAppsByAppIdDescribeQuery,
zGetPermittedExternalAppsByAppIdDescribeResponse,
zGetPermittedExternalAppsByAppIdPath,
zGetPermittedExternalAppsByAppIdQuery,
zGetPermittedExternalAppsByAppIdResponse,
zGetPermittedExternalAppsQuery,
zGetPermittedExternalAppsResponse,
zGetVersionResponse,
@ -42,11 +42,14 @@ import {
zGetWorkspacesByWorkspaceIdPath,
zGetWorkspacesByWorkspaceIdResponse,
zGetWorkspacesResponse,
zPostAppsByAppIdFilesUploadPath,
zPostAppsByAppIdFilesUploadResponse,
zPostAppsByAppIdFormHumanInputByFormTokenBody,
zPostAppsByAppIdFormHumanInputByFormTokenPath,
zPostAppsByAppIdFormHumanInputByFormTokenResponse,
zPatchWorkspacesByWorkspaceIdMembersByMemberIdBody,
zPatchWorkspacesByWorkspaceIdMembersByMemberIdPath,
zPatchWorkspacesByWorkspaceIdMembersByMemberIdResponse,
zPostAppsByAppIdFilesPath,
zPostAppsByAppIdFilesResponse,
zPostAppsByAppIdHumanInputFormsByFormTokenSubmitBody,
zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath,
zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse,
zPostAppsByAppIdRunBody,
zPostAppsByAppIdRunPath,
zPostAppsByAppIdRunResponse,
@ -70,9 +73,6 @@ import {
zPostWorkspacesByWorkspaceIdMembersResponse,
zPostWorkspacesByWorkspaceIdSwitchPath,
zPostWorkspacesByWorkspaceIdSwitchResponse,
zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody,
zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath,
zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse,
} from './zod.gen'
export const get = oc
@ -168,54 +168,36 @@ export const get5 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppIdCheckDependencies',
path: '/apps/{app_id}/check-dependencies',
operationId: 'getAppsByAppIdDependenciesCheck',
path: '/apps/{app_id}/dependencies:check',
tags: ['openapi'],
})
.input(z.object({ params: zGetAppsByAppIdCheckDependenciesPath }))
.output(zGetAppsByAppIdCheckDependenciesResponse)
.input(z.object({ params: zGetAppsByAppIdDependenciesCheckPath }))
.output(zGetAppsByAppIdDependenciesCheckResponse)
export const checkDependencies = {
export const check = {
get: get5,
}
export const dependencies = {
check,
}
export const get6 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppIdDescribe',
path: '/apps/{app_id}/describe',
operationId: 'getAppsByAppIdDsl',
path: '/apps/{app_id}/dsl',
tags: ['openapi'],
})
.input(
z.object({
params: zGetAppsByAppIdDescribePath,
query: zGetAppsByAppIdDescribeQuery.optional(),
}),
)
.output(zGetAppsByAppIdDescribeResponse)
.input(z.object({ params: zGetAppsByAppIdDslPath, query: zGetAppsByAppIdDslQuery.optional() }))
.output(zGetAppsByAppIdDslResponse)
export const describe = {
export const dsl = {
get: get6,
}
export const get7 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppIdExport',
path: '/apps/{app_id}/export',
tags: ['openapi'],
})
.input(
z.object({ params: zGetAppsByAppIdExportPath, query: zGetAppsByAppIdExportQuery.optional() }),
)
.output(zGetAppsByAppIdExportResponse)
export const export_ = {
get: get7,
}
/**
* Upload a file to use as an input variable when running the app
*/
@ -224,78 +206,59 @@ export const post = oc
description: 'Upload a file to use as an input variable when running the app',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAppsByAppIdFilesUpload',
path: '/apps/{app_id}/files/upload',
operationId: 'postAppsByAppIdFiles',
path: '/apps/{app_id}/files',
successStatus: 201,
tags: ['openapi'],
})
.input(z.object({ params: zPostAppsByAppIdFilesUploadPath }))
.output(zPostAppsByAppIdFilesUploadResponse)
export const upload = {
post,
}
.input(z.object({ params: zPostAppsByAppIdFilesPath }))
.output(zPostAppsByAppIdFilesResponse)
export const files = {
upload,
post,
}
export const get8 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppIdFormHumanInputByFormToken',
path: '/apps/{app_id}/form/human_input/{form_token}',
tags: ['openapi'],
})
.input(z.object({ params: zGetAppsByAppIdFormHumanInputByFormTokenPath }))
.output(zGetAppsByAppIdFormHumanInputByFormTokenResponse)
export const post2 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAppsByAppIdFormHumanInputByFormToken',
path: '/apps/{app_id}/form/human_input/{form_token}',
operationId: 'postAppsByAppIdHumanInputFormsByFormTokenSubmit',
path: '/apps/{app_id}/human-input-forms/{form_token}:submit',
tags: ['openapi'],
})
.input(
z.object({
body: zPostAppsByAppIdFormHumanInputByFormTokenBody,
params: zPostAppsByAppIdFormHumanInputByFormTokenPath,
body: zPostAppsByAppIdHumanInputFormsByFormTokenSubmitBody,
params: zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath,
}),
)
.output(zPostAppsByAppIdFormHumanInputByFormTokenResponse)
.output(zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse)
export const byFormToken = {
get: get8,
export const submit = {
post: post2,
}
export const humanInput = {
export const get7 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppIdHumanInputFormsByFormToken',
path: '/apps/{app_id}/human-input-forms/{form_token}',
tags: ['openapi'],
})
.input(z.object({ params: zGetAppsByAppIdHumanInputFormsByFormTokenPath }))
.output(zGetAppsByAppIdHumanInputFormsByFormTokenResponse)
export const byFormToken = {
get: get7,
submit,
}
export const humanInputForms = {
byFormToken,
}
export const form = {
humanInput,
}
export const post3 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAppsByAppIdRun',
path: '/apps/{app_id}/run',
tags: ['openapi'],
})
.input(z.object({ body: zPostAppsByAppIdRunBody, params: zPostAppsByAppIdRunPath }))
.output(zPostAppsByAppIdRunResponse)
export const run = {
post: post3,
}
export const get9 = oc
export const get8 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -312,22 +275,22 @@ export const get9 = oc
.output(zGetAppsByAppIdTasksByTaskIdEventsResponse)
export const events = {
get: get9,
get: get8,
}
export const post4 = oc
export const post3 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAppsByAppIdTasksByTaskIdStop',
path: '/apps/{app_id}/tasks/{task_id}/stop',
path: '/apps/{app_id}/tasks/{task_id}:stop',
tags: ['openapi'],
})
.input(z.object({ params: zPostAppsByAppIdTasksByTaskIdStopPath }))
.output(zPostAppsByAppIdTasksByTaskIdStopResponse)
export const stop = {
post: post4,
post: post3,
}
export const byTaskId = {
@ -339,14 +302,40 @@ export const tasks = {
byTaskId,
}
export const post4 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAppsByAppIdRun',
path: '/apps/{app_id}:run',
tags: ['openapi'],
})
.input(z.object({ body: zPostAppsByAppIdRunBody, params: zPostAppsByAppIdRunPath }))
.output(zPostAppsByAppIdRunResponse)
export const run = {
post: post4,
}
export const get9 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppId',
path: '/apps/{app_id}',
tags: ['openapi'],
})
.input(z.object({ params: zGetAppsByAppIdPath, query: zGetAppsByAppIdQuery.optional() }))
.output(zGetAppsByAppIdResponse)
export const byAppId = {
checkDependencies,
describe,
export: export_,
get: get9,
dependencies,
dsl,
files,
form,
run,
humanInputForms,
tasks,
run,
}
export const get10 = oc
@ -456,24 +445,20 @@ export const get12 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getPermittedExternalAppsByAppIdDescribe',
path: '/permitted-external-apps/{app_id}/describe',
operationId: 'getPermittedExternalAppsByAppId',
path: '/permitted-external-apps/{app_id}',
tags: ['openapi'],
})
.input(
z.object({
params: zGetPermittedExternalAppsByAppIdDescribePath,
query: zGetPermittedExternalAppsByAppIdDescribeQuery.optional(),
params: zGetPermittedExternalAppsByAppIdPath,
query: zGetPermittedExternalAppsByAppIdQuery.optional(),
}),
)
.output(zGetPermittedExternalAppsByAppIdDescribeResponse)
export const describe2 = {
get: get12,
}
.output(zGetPermittedExternalAppsByAppIdResponse)
export const byAppId2 = {
describe: describe2,
get: get12,
}
export const get13 = oc
@ -497,7 +482,7 @@ export const post9 = oc
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkspacesByWorkspaceIdAppsImportsByImportIdConfirm',
path: '/workspaces/{workspace_id}/apps/imports/{import_id}/confirm',
path: '/workspaces/{workspace_id}/apps/imports/{import_id}:confirm',
tags: ['openapi'],
})
.input(z.object({ params: zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath }))
@ -536,26 +521,6 @@ export const apps2 = {
imports,
}
export const put = oc
.route({
inputStructure: 'detailed',
method: 'PUT',
operationId: 'putWorkspacesByWorkspaceIdMembersByMemberIdRole',
path: '/workspaces/{workspace_id}/members/{member_id}/role',
tags: ['openapi'],
})
.input(
z.object({
body: zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody,
params: zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath,
}),
)
.output(zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse)
export const role = {
put,
}
export const delete3 = oc
.route({
inputStructure: 'detailed',
@ -567,9 +532,25 @@ export const delete3 = oc
.input(z.object({ params: zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath }))
.output(zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse)
export const patch = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'patchWorkspacesByWorkspaceIdMembersByMemberId',
path: '/workspaces/{workspace_id}/members/{member_id}',
tags: ['openapi'],
})
.input(
z.object({
body: zPatchWorkspacesByWorkspaceIdMembersByMemberIdBody,
params: zPatchWorkspacesByWorkspaceIdMembersByMemberIdPath,
}),
)
.output(zPatchWorkspacesByWorkspaceIdMembersByMemberIdResponse)
export const byMemberId = {
delete: delete3,
role,
patch,
}
export const get14 = oc
@ -616,7 +597,7 @@ export const post12 = oc
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkspacesByWorkspaceIdSwitch',
path: '/workspaces/{workspace_id}/switch',
path: '/workspaces/{workspace_id}:switch',
tags: ['openapi'],
})
.input(z.object({ params: zPostWorkspacesByWorkspaceIdSwitchPath }))

View File

@ -347,6 +347,7 @@ export type OpenApiErrorCode
| 'unknown'
| 'unsupported_file_type'
| 'unsupported_media_type'
| 'upgrade_required'
export type Package = {
plugin_unique_identifier: string
@ -617,30 +618,7 @@ export type GetAppsResponses = {
export type GetAppsResponse = GetAppsResponses[keyof GetAppsResponses]
export type GetAppsByAppIdCheckDependenciesData = {
body?: never
path: {
app_id: string
}
query?: never
url: '/apps/{app_id}/check-dependencies'
}
export type GetAppsByAppIdCheckDependenciesErrors = {
default: ErrorBody
}
export type GetAppsByAppIdCheckDependenciesError
= GetAppsByAppIdCheckDependenciesErrors[keyof GetAppsByAppIdCheckDependenciesErrors]
export type GetAppsByAppIdCheckDependenciesResponses = {
200: CheckDependenciesResult
}
export type GetAppsByAppIdCheckDependenciesResponse
= GetAppsByAppIdCheckDependenciesResponses[keyof GetAppsByAppIdCheckDependenciesResponses]
export type GetAppsByAppIdDescribeData = {
export type GetAppsByAppIdData = {
body?: never
path: {
app_id: string
@ -648,25 +626,46 @@ export type GetAppsByAppIdDescribeData = {
query?: {
fields?: string
}
url: '/apps/{app_id}/describe'
url: '/apps/{app_id}'
}
export type GetAppsByAppIdDescribeErrors = {
export type GetAppsByAppIdErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetAppsByAppIdDescribeError
= GetAppsByAppIdDescribeErrors[keyof GetAppsByAppIdDescribeErrors]
export type GetAppsByAppIdError = GetAppsByAppIdErrors[keyof GetAppsByAppIdErrors]
export type GetAppsByAppIdDescribeResponses = {
export type GetAppsByAppIdResponses = {
200: AppDescribeResponse
}
export type GetAppsByAppIdDescribeResponse
= GetAppsByAppIdDescribeResponses[keyof GetAppsByAppIdDescribeResponses]
export type GetAppsByAppIdResponse = GetAppsByAppIdResponses[keyof GetAppsByAppIdResponses]
export type GetAppsByAppIdExportData = {
export type GetAppsByAppIdDependenciesCheckData = {
body?: never
path: {
app_id: string
}
query?: never
url: '/apps/{app_id}/dependencies:check'
}
export type GetAppsByAppIdDependenciesCheckErrors = {
default: ErrorBody
}
export type GetAppsByAppIdDependenciesCheckError
= GetAppsByAppIdDependenciesCheckErrors[keyof GetAppsByAppIdDependenciesCheckErrors]
export type GetAppsByAppIdDependenciesCheckResponses = {
200: CheckDependenciesResult
}
export type GetAppsByAppIdDependenciesCheckResponse
= GetAppsByAppIdDependenciesCheckResponses[keyof GetAppsByAppIdDependenciesCheckResponses]
export type GetAppsByAppIdDslData = {
body?: never
path: {
app_id: string
@ -675,33 +674,32 @@ export type GetAppsByAppIdExportData = {
include_secret?: boolean
workflow_id?: string
}
url: '/apps/{app_id}/export'
url: '/apps/{app_id}/dsl'
}
export type GetAppsByAppIdExportErrors = {
export type GetAppsByAppIdDslErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetAppsByAppIdExportError = GetAppsByAppIdExportErrors[keyof GetAppsByAppIdExportErrors]
export type GetAppsByAppIdDslError = GetAppsByAppIdDslErrors[keyof GetAppsByAppIdDslErrors]
export type GetAppsByAppIdExportResponses = {
export type GetAppsByAppIdDslResponses = {
200: AppDslExportResponse
}
export type GetAppsByAppIdExportResponse
= GetAppsByAppIdExportResponses[keyof GetAppsByAppIdExportResponses]
export type GetAppsByAppIdDslResponse = GetAppsByAppIdDslResponses[keyof GetAppsByAppIdDslResponses]
export type PostAppsByAppIdFilesUploadData = {
export type PostAppsByAppIdFilesData = {
body?: never
path: {
app_id: string
}
query?: never
url: '/apps/{app_id}/files/upload'
url: '/apps/{app_id}/files'
}
export type PostAppsByAppIdFilesUploadErrors = {
export type PostAppsByAppIdFilesErrors = {
400: unknown
401: unknown
413: unknown
@ -709,79 +707,56 @@ export type PostAppsByAppIdFilesUploadErrors = {
default: ErrorBody
}
export type PostAppsByAppIdFilesUploadError
= PostAppsByAppIdFilesUploadErrors[keyof PostAppsByAppIdFilesUploadErrors]
export type PostAppsByAppIdFilesError = PostAppsByAppIdFilesErrors[keyof PostAppsByAppIdFilesErrors]
export type PostAppsByAppIdFilesUploadResponses = {
export type PostAppsByAppIdFilesResponses = {
201: FileResponse
}
export type PostAppsByAppIdFilesUploadResponse
= PostAppsByAppIdFilesUploadResponses[keyof PostAppsByAppIdFilesUploadResponses]
export type PostAppsByAppIdFilesResponse
= PostAppsByAppIdFilesResponses[keyof PostAppsByAppIdFilesResponses]
export type GetAppsByAppIdFormHumanInputByFormTokenData = {
export type GetAppsByAppIdHumanInputFormsByFormTokenData = {
body?: never
path: {
app_id: string
form_token: string
}
query?: never
url: '/apps/{app_id}/form/human_input/{form_token}'
url: '/apps/{app_id}/human-input-forms/{form_token}'
}
export type GetAppsByAppIdFormHumanInputByFormTokenResponses = {
export type GetAppsByAppIdHumanInputFormsByFormTokenResponses = {
200: HumanInputFormDefinitionResponse
}
export type GetAppsByAppIdFormHumanInputByFormTokenResponse
= GetAppsByAppIdFormHumanInputByFormTokenResponses[keyof GetAppsByAppIdFormHumanInputByFormTokenResponses]
export type GetAppsByAppIdHumanInputFormsByFormTokenResponse
= GetAppsByAppIdHumanInputFormsByFormTokenResponses[keyof GetAppsByAppIdHumanInputFormsByFormTokenResponses]
export type PostAppsByAppIdFormHumanInputByFormTokenData = {
export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitData = {
body: HumanInputFormSubmitPayload
path: {
app_id: string
form_token: string
}
query?: never
url: '/apps/{app_id}/form/human_input/{form_token}'
url: '/apps/{app_id}/human-input-forms/{form_token}:submit'
}
export type PostAppsByAppIdFormHumanInputByFormTokenErrors = {
export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitErrors = {
422: ErrorBody
default: ErrorBody
}
export type PostAppsByAppIdFormHumanInputByFormTokenError
= PostAppsByAppIdFormHumanInputByFormTokenErrors[keyof PostAppsByAppIdFormHumanInputByFormTokenErrors]
export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitError
= PostAppsByAppIdHumanInputFormsByFormTokenSubmitErrors[keyof PostAppsByAppIdHumanInputFormsByFormTokenSubmitErrors]
export type PostAppsByAppIdFormHumanInputByFormTokenResponses = {
export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses = {
200: FormSubmitResponse
}
export type PostAppsByAppIdFormHumanInputByFormTokenResponse
= PostAppsByAppIdFormHumanInputByFormTokenResponses[keyof PostAppsByAppIdFormHumanInputByFormTokenResponses]
export type PostAppsByAppIdRunData = {
body: AppRunRequest
path: {
app_id: string
}
query?: never
url: '/apps/{app_id}/run'
}
export type PostAppsByAppIdRunErrors = {
422: ErrorBody
}
export type PostAppsByAppIdRunError = PostAppsByAppIdRunErrors[keyof PostAppsByAppIdRunErrors]
export type PostAppsByAppIdRunResponses = {
200: EventStreamResponse
}
export type PostAppsByAppIdRunResponse
= PostAppsByAppIdRunResponses[keyof PostAppsByAppIdRunResponses]
export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse
= PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses[keyof PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses]
export type GetAppsByAppIdTasksByTaskIdEventsData = {
body?: never
@ -810,7 +785,7 @@ export type PostAppsByAppIdTasksByTaskIdStopData = {
task_id: string
}
query?: never
url: '/apps/{app_id}/tasks/{task_id}/stop'
url: '/apps/{app_id}/tasks/{task_id}:stop'
}
export type PostAppsByAppIdTasksByTaskIdStopErrors = {
@ -827,6 +802,28 @@ export type PostAppsByAppIdTasksByTaskIdStopResponses = {
export type PostAppsByAppIdTasksByTaskIdStopResponse
= PostAppsByAppIdTasksByTaskIdStopResponses[keyof PostAppsByAppIdTasksByTaskIdStopResponses]
export type PostAppsByAppIdRunData = {
body: AppRunRequest
path: {
app_id: string
}
query?: never
url: '/apps/{app_id}:run'
}
export type PostAppsByAppIdRunErrors = {
422: ErrorBody
}
export type PostAppsByAppIdRunError = PostAppsByAppIdRunErrors[keyof PostAppsByAppIdRunErrors]
export type PostAppsByAppIdRunResponses = {
200: EventStreamResponse
}
export type PostAppsByAppIdRunResponse
= PostAppsByAppIdRunResponses[keyof PostAppsByAppIdRunResponses]
export type PostOauthDeviceApproveData = {
body: DeviceMutateRequest
path?: never
@ -926,7 +923,7 @@ export type GetPermittedExternalAppsResponses = {
export type GetPermittedExternalAppsResponse
= GetPermittedExternalAppsResponses[keyof GetPermittedExternalAppsResponses]
export type GetPermittedExternalAppsByAppIdDescribeData = {
export type GetPermittedExternalAppsByAppIdData = {
body?: never
path: {
app_id: string
@ -934,23 +931,23 @@ export type GetPermittedExternalAppsByAppIdDescribeData = {
query?: {
fields?: string
}
url: '/permitted-external-apps/{app_id}/describe'
url: '/permitted-external-apps/{app_id}'
}
export type GetPermittedExternalAppsByAppIdDescribeErrors = {
export type GetPermittedExternalAppsByAppIdErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetPermittedExternalAppsByAppIdDescribeError
= GetPermittedExternalAppsByAppIdDescribeErrors[keyof GetPermittedExternalAppsByAppIdDescribeErrors]
export type GetPermittedExternalAppsByAppIdError
= GetPermittedExternalAppsByAppIdErrors[keyof GetPermittedExternalAppsByAppIdErrors]
export type GetPermittedExternalAppsByAppIdDescribeResponses = {
export type GetPermittedExternalAppsByAppIdResponses = {
200: AppDescribeResponse
}
export type GetPermittedExternalAppsByAppIdDescribeResponse
= GetPermittedExternalAppsByAppIdDescribeResponses[keyof GetPermittedExternalAppsByAppIdDescribeResponses]
export type GetPermittedExternalAppsByAppIdResponse
= GetPermittedExternalAppsByAppIdResponses[keyof GetPermittedExternalAppsByAppIdResponses]
export type GetWorkspacesData = {
body?: never
@ -1027,7 +1024,7 @@ export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmData = {
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/apps/imports/{import_id}/confirm'
url: '/workspaces/{workspace_id}/apps/imports/{import_id}:confirm'
}
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors = {
@ -1120,30 +1117,30 @@ export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses = {
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse
= DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses[keyof DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses]
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleData = {
export type PatchWorkspacesByWorkspaceIdMembersByMemberIdData = {
body: MemberRoleUpdatePayload
path: {
member_id: string
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/members/{member_id}/role'
url: '/workspaces/{workspace_id}/members/{member_id}'
}
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors = {
export type PatchWorkspacesByWorkspaceIdMembersByMemberIdErrors = {
422: ErrorBody
default: ErrorBody
}
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleError
= PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors[keyof PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors]
export type PatchWorkspacesByWorkspaceIdMembersByMemberIdError
= PatchWorkspacesByWorkspaceIdMembersByMemberIdErrors[keyof PatchWorkspacesByWorkspaceIdMembersByMemberIdErrors]
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses = {
export type PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses = {
200: MemberActionResponse
}
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse
= PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses[keyof PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses]
export type PatchWorkspacesByWorkspaceIdMembersByMemberIdResponse
= PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses[keyof PatchWorkspacesByWorkspaceIdMembersByMemberIdResponses]
export type PostWorkspacesByWorkspaceIdSwitchData = {
body?: never
@ -1151,7 +1148,7 @@ export type PostWorkspacesByWorkspaceIdSwitchData = {
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/switch'
url: '/workspaces/{workspace_id}:switch'
}
export type PostWorkspacesByWorkspaceIdSwitchErrors = {

View File

@ -27,7 +27,7 @@ export const zAppDescribeInfo = z.object({
/**
* AppDescribeQuery
*
* `?fields=` allow-list for GET /apps/<id>/describe.
* `?fields=` allow-list for GET /apps/<id>.
*
* Empty / omitted all blocks. Unknown member ValidationError 422.
*/
@ -47,7 +47,7 @@ export const zAppDescribeResponse = z.object({
/**
* AppDslExportQuery
*
* Query parameters for GET /apps/<app_id>/export.
* Query parameters for GET /apps/<app_id>/dsl.
*/
export const zAppDslExportQuery = z.object({
include_secret: z.boolean().optional().default(false),
@ -254,7 +254,7 @@ export const zFileResponse = z.object({
/**
* FormSubmitResponse
*
* 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.
*/
@ -430,6 +430,7 @@ export const zOpenApiErrorCode = z.enum([
'unknown',
'unsupported_file_type',
'unsupported_media_type',
'upgrade_required',
])
/**
@ -581,7 +582,7 @@ export const zPermittedExternalAppsListQuery = z.object({
/**
* TaskStopResponse
*
* 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.
*/
@ -740,33 +741,33 @@ export const zGetAppsQuery = z.object({
*/
export const zGetAppsResponse = zAppListResponse
export const zGetAppsByAppIdCheckDependenciesPath = z.object({
export const zGetAppsByAppIdPath = z.object({
app_id: z.string(),
})
/**
* Dependencies checked
*/
export const zGetAppsByAppIdCheckDependenciesResponse = zCheckDependenciesResult
export const zGetAppsByAppIdDescribePath = z.object({
app_id: z.string(),
})
export const zGetAppsByAppIdDescribeQuery = z.object({
export const zGetAppsByAppIdQuery = z.object({
fields: z.string().optional(),
})
/**
* App description
*/
export const zGetAppsByAppIdDescribeResponse = zAppDescribeResponse
export const zGetAppsByAppIdResponse = zAppDescribeResponse
export const zGetAppsByAppIdExportPath = z.object({
export const zGetAppsByAppIdDependenciesCheckPath = z.object({
app_id: z.string(),
})
export const zGetAppsByAppIdExportQuery = z.object({
/**
* Dependencies checked
*/
export const zGetAppsByAppIdDependenciesCheckResponse = zCheckDependenciesResult
export const zGetAppsByAppIdDslPath = z.object({
app_id: z.string(),
})
export const zGetAppsByAppIdDslQuery = z.object({
include_secret: z.boolean().optional().default(false),
workflow_id: z.string().optional(),
})
@ -774,18 +775,18 @@ export const zGetAppsByAppIdExportQuery = z.object({
/**
* Export successful
*/
export const zGetAppsByAppIdExportResponse = zAppDslExportResponse
export const zGetAppsByAppIdDslResponse = zAppDslExportResponse
export const zPostAppsByAppIdFilesUploadPath = z.object({
export const zPostAppsByAppIdFilesPath = z.object({
app_id: z.string(),
})
/**
* File uploaded successfully
*/
export const zPostAppsByAppIdFilesUploadResponse = zFileResponse
export const zPostAppsByAppIdFilesResponse = zFileResponse
export const zGetAppsByAppIdFormHumanInputByFormTokenPath = z.object({
export const zGetAppsByAppIdHumanInputFormsByFormTokenPath = z.object({
app_id: z.string(),
form_token: z.string(),
})
@ -793,11 +794,11 @@ export const zGetAppsByAppIdFormHumanInputByFormTokenPath = z.object({
/**
* Form definition
*/
export const zGetAppsByAppIdFormHumanInputByFormTokenResponse = zHumanInputFormDefinitionResponse
export const zGetAppsByAppIdHumanInputFormsByFormTokenResponse = zHumanInputFormDefinitionResponse
export const zPostAppsByAppIdFormHumanInputByFormTokenBody = zHumanInputFormSubmitPayload
export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitBody = zHumanInputFormSubmitPayload
export const zPostAppsByAppIdFormHumanInputByFormTokenPath = z.object({
export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath = z.object({
app_id: z.string(),
form_token: z.string(),
})
@ -805,18 +806,7 @@ export const zPostAppsByAppIdFormHumanInputByFormTokenPath = z.object({
/**
* Form submitted
*/
export const zPostAppsByAppIdFormHumanInputByFormTokenResponse = zFormSubmitResponse
export const zPostAppsByAppIdRunBody = zAppRunRequest
export const zPostAppsByAppIdRunPath = z.object({
app_id: z.string(),
})
/**
* Run result (SSE stream)
*/
export const zPostAppsByAppIdRunResponse = zEventStreamResponse
export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse = zFormSubmitResponse
export const zGetAppsByAppIdTasksByTaskIdEventsPath = z.object({
app_id: z.string(),
@ -843,6 +833,17 @@ export const zPostAppsByAppIdTasksByTaskIdStopPath = z.object({
*/
export const zPostAppsByAppIdTasksByTaskIdStopResponse = zTaskStopResponse
export const zPostAppsByAppIdRunBody = zAppRunRequest
export const zPostAppsByAppIdRunPath = z.object({
app_id: z.string(),
})
/**
* Run result (SSE stream)
*/
export const zPostAppsByAppIdRunResponse = zEventStreamResponse
export const zPostOauthDeviceApproveBody = zDeviceMutateRequest
/**
@ -892,18 +893,18 @@ export const zGetPermittedExternalAppsQuery = z.object({
*/
export const zGetPermittedExternalAppsResponse = zPermittedExternalAppsListResponse
export const zGetPermittedExternalAppsByAppIdDescribePath = z.object({
export const zGetPermittedExternalAppsByAppIdPath = z.object({
app_id: z.string(),
})
export const zGetPermittedExternalAppsByAppIdDescribeQuery = z.object({
export const zGetPermittedExternalAppsByAppIdQuery = z.object({
fields: z.string().optional(),
})
/**
* Permitted external app description
*/
export const zGetPermittedExternalAppsByAppIdDescribeResponse = zAppDescribeResponse
export const zGetPermittedExternalAppsByAppIdResponse = zAppDescribeResponse
/**
* Workspace list
@ -975,9 +976,9 @@ export const zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath = z.object({
*/
export const zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse = zMemberActionResponse
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody = zMemberRoleUpdatePayload
export const zPatchWorkspacesByWorkspaceIdMembersByMemberIdBody = zMemberRoleUpdatePayload
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath = z.object({
export const zPatchWorkspacesByWorkspaceIdMembersByMemberIdPath = z.object({
member_id: z.string(),
workspace_id: z.string(),
})
@ -985,7 +986,7 @@ export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath = z.object({
/**
* Role updated
*/
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse = zMemberActionResponse
export const zPatchWorkspacesByWorkspaceIdMembersByMemberIdResponse = zMemberActionResponse
export const zPostWorkspacesByWorkspaceIdSwitchPath = z.object({
workspace_id: z.string(),

View File

@ -102,11 +102,11 @@ const segmentWords = (segment: string) => {
return toWords(segment)
}
// Split on `:` too so custom methods nest as their own node (apps.byAppId.run), not apps.appIdRun.
const routeNamingSegments = (routePath: string) => routePath.split(/[/:]/).filter(Boolean)
const routeWords = (routePath: string) => {
return routePath
.split('/')
.filter(Boolean)
.flatMap(segmentWords)
return routeNamingSegments(routePath).flatMap(segmentWords)
}
const operationId = (method: string, routePath: string) => {
@ -114,10 +114,7 @@ const operationId = (method: string, routePath: string) => {
}
const contractPathSegments = (operation: ApiContractOperation) => {
const segments = operation.path
.split('/')
.filter(Boolean)
.map(segment => toCamelCase(segmentWords(segment)))
const segments = routeNamingSegments(operation.path).map(segment => toCamelCase(segmentWords(segment)))
return [...(segments.length > 0 ? segments : ['root']), operation.method.toLowerCase()]
}