diff --git a/api/configs/packaging/pyproject.py b/api/configs/packaging/pyproject.py index 90b1ecba065..c21c02082c5 100644 --- a/api/configs/packaging/pyproject.py +++ b/api/configs/packaging/pyproject.py @@ -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(), + ) diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index 81c65ca03be..0260422ec1c 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -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, diff --git a/api/controllers/openapi/_errors.py b/api/controllers/openapi/_errors.py index 31d577665a3..92884dfcd50 100644 --- a/api/controllers/openapi/_errors.py +++ b/api/controllers/openapi/_errors.py @@ -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) diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index 6e8a9c9d439..5337612e7b6 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -279,7 +279,7 @@ def _csv_string_query_schema(schema: dict[str, Any]) -> None: class AppDescribeQuery(BaseModel): - """`?fields=` allow-list for GET /apps//describe. + """`?fields=` allow-list for GET /apps/. Empty / omitted → all blocks. Unknown member → ValidationError → 422. """ @@ -441,7 +441,7 @@ class MemberActionResponse(BaseModel): class TaskStopResponse(BaseModel): - """200 body for POST /apps//tasks//stop. The handler always returns + """200 body for POST /apps//tasks/: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//export.""" + """Query parameters for GET /apps//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//form/human_input/. `extra='forbid'` + """Empty 200 body for POST /apps//human-input-forms/:submit. `extra='forbid'` pins `additionalProperties: false` so the generated contract is an exact `{}` rather than an under-annotated open object.""" diff --git a/api/controllers/openapi/_version_gate.py b/api/controllers/openapi/_version_gate.py new file mode 100644 index 00000000000..785617fd950 --- /dev/null +++ b/api/controllers/openapi/_version_gate.py @@ -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/ (; ; )`. +_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 diff --git a/api/controllers/openapi/app_dsl.py b/api/controllers/openapi/app_dsl.py index 9b1abd24bac..cea7127bd07 100644 --- a/api/controllers/openapi/app_dsl.py +++ b/api/controllers/openapi/app_dsl.py @@ -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//apps/imports//confirm") +@openapi_ns.route("/workspaces//apps/imports/: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//export") +@openapi_ns.route("/apps//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//check-dependencies") +@openapi_ns.route("/apps//dependencies:check") class AppDslCheckDependenciesApi(Resource): """Check for leaked plugin dependencies after a DSL import. diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py index 6074c7c0e02..772513ad417 100644 --- a/api/controllers/openapi/app_run.py +++ b/api/controllers/openapi/app_run.py @@ -1,4 +1,4 @@ -"""POST /openapi/v1/apps//run — mode-agnostic runner.""" +"""POST /openapi/v1/apps/: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//run") +@openapi_ns.route("/apps/: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//tasks//stop") +@openapi_ns.route("/apps//tasks/:stop") class AppRunTaskStopApi(Resource): @auth_router.guard( scope=Scope.APPS_RUN, diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index 8d5c9670e77..d4cb175ba5e 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -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//describe") +@openapi_ns.route("/apps/") class AppDescribeApi(AppReadResource): @auth_router.guard( scope=Scope.APPS_READ, diff --git a/api/controllers/openapi/apps_permitted_external.py b/api/controllers/openapi/apps_permitted_external.py index 718d3dbd169..5c6fdce5141 100644 --- a/api/controllers/openapi/apps_permitted_external.py +++ b/api/controllers/openapi/apps_permitted_external.py @@ -87,7 +87,7 @@ class PermittedExternalAppsListApi(Resource): return env -@openapi_ns.route("/permitted-external-apps//describe") +@openapi_ns.route("/permitted-external-apps/") class PermittedExternalAppDescribeApi(Resource): @auth_router.guard( scope=Scope.APPS_READ_PERMITTED_EXTERNAL, diff --git a/api/controllers/openapi/files.py b/api/controllers/openapi/files.py index 7326a4a922e..3b3f68fa36b 100644 --- a/api/controllers/openapi/files.py +++ b/api/controllers/openapi/files.py @@ -1,4 +1,4 @@ -"""POST /openapi/v1/apps//files/upload — upload a file for use in app inputs.""" +"""POST /openapi/v1/apps//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//files/upload") +@openapi_ns.route("/apps//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") diff --git a/api/controllers/openapi/human_input_form.py b/api/controllers/openapi/human_input_form.py index 998dd669836..593887ffcfc 100644 --- a/api/controllers/openapi/human_input_form.py +++ b/api/controllers/openapi/human_input_form.py @@ -1,8 +1,8 @@ """ OpenAPI bearer-authed human input form endpoints. -GET /apps//form/human_input/ — fetch paused form definition -POST /apps//form/human_input/ — submit form response +GET /apps//human-input-forms/ — fetch paused form definition +POST /apps//human-input-forms/: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//form/human_input/") +@openapi_ns.route("/apps//human-input-forms/") 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//human-input-forms/:submit") +class OpenApiWorkflowHumanInputFormSubmitApi(Resource): @auth_router.guard( scope=Scope.APPS_RUN, rbac=RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_TEST_AND_RUN), diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py index 49f8fb9656f..c45c02e54d3 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -113,7 +113,7 @@ class WorkspaceByIdApi(Resource): return _workspace_detail(tenant, membership) -@openapi_ns.route("/workspaces//switch") +@openapi_ns.route("/workspaces/: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//members/") 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//members//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) diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index a649c2fa74f..c5ec6c3a7db 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -93,21 +93,7 @@ User-scoped operations | 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| -### [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)
| -| default | Error | **application/json**: [ErrorBody](#errorbody)
| - -### [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)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| -### [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)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [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)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| -### [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)
| -### [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)
| -### [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)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| -### [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)
| + +### [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)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [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)
| | 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| -### [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)
| - -### [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)
| -| default | Error | **application/json**: [ErrorBody](#errorbody)
| - ### [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)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| -### [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)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| -### [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)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| -### [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)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| -### [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//describe. +`?fields=` allow-list for GET /apps/. 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//export. +Query parameters for GET /apps//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//form/human_input/. `extra='forbid'` +Empty 200 body for POST /apps//human-input-forms/: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//tasks//stop. The handler always returns +200 body for POST /apps//tasks/: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. diff --git a/api/pyproject.toml b/api/pyproject.toml index d3fcb59d694..519e400c15f 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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 = [] diff --git a/api/tests/integration_tests/controllers/openapi/test_app_run.py b/api/tests/integration_tests/controllers/openapi/test_app_run.py index b4f383a7cea..fbfedd0f269 100644 --- a/api/tests/integration_tests/controllers/openapi/test_app_run.py +++ b/api/tests/integration_tests/controllers/openapi/test_app_run.py @@ -1,4 +1,4 @@ -"""Integration tests for POST /openapi/v1/apps//run.""" +"""Integration tests for POST /openapi/v1/apps/: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}"}, ) diff --git a/api/tests/integration_tests/controllers/openapi/test_apps.py b/api/tests/integration_tests/controllers/openapi/test_apps.py index 20ac46fbbde..ab992d1c5eb 100644 --- a/api/tests/integration_tests/controllers/openapi/test_apps.py +++ b/api/tests/integration_tests/controllers/openapi/test_apps.py @@ -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 diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py index 93e8927cfef..2b9feeede14 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py @@ -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 diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py index c6fde623677..8e9278ad244 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py @@ -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, diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py index 22f812e125b..24580ae0a0e 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py @@ -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()) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py index b90d5ab907c..86cf70613c9 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py @@ -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", diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py index 18075704325..5e794ae1982 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py @@ -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)) diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py index b82ab254d45..7672e6414fd 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py @@ -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//tasks//stop must be registered.""" + """POST /openapi/v1/apps//tasks/:stop must be registered.""" rules = {r.rule for r in openapi_app.url_map.iter_rules()} - assert "/openapi/v1/apps//tasks//stop" in rules + assert "/openapi/v1/apps//tasks/: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", diff --git a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py index 5659cd6eeff..c4d3d21d84a 100644 --- a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py @@ -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" ): diff --git a/api/tests/unit_tests/controllers/openapi/test_version_gate.py b/api/tests/unit_tests/controllers/openapi/test_version_gate.py new file mode 100644 index 00000000000..e2b16259952 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_version_gate.py @@ -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/).""" + 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//run was renamed to /apps/: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 diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py index cf9fa671987..b78473fadda 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py @@ -1,7 +1,7 @@ """Member endpoints under /openapi/v1/workspaces//... 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//switch") + rule = _rule(openapi_app, "/openapi/v1/workspaces/: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//members/") 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//members//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, diff --git a/cli/package.json b/cli/package.json index 5121daf3624..0e87f58ef2f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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", diff --git a/cli/scripts/release-naming.test.ts b/cli/scripts/release-naming.test.ts index 559d15ad759..7641b531282 100644 --- a/cli/scripts/release-naming.test.ts +++ b/cli/scripts/release-naming.test.ts @@ -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 -edge. 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 -edge. 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', () => { diff --git a/cli/scripts/release-r2-edge.test.ts b/cli/scripts/release-r2-edge.test.ts index 12e3ee1822c..9db0c0ad2cb 100644 --- a/cli/scripts/release-r2-edge.test.ts +++ b/cli/scripts/release-r2-edge.test.ts @@ -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', () => { diff --git a/cli/src/api/app-dsl.test.ts b/cli/src/api/app-dsl.test.ts index 66d4507317e..1afff1fcbce 100644 --- a/cli/src/api/app-dsl.test.ts +++ b/cli/src/api/app-dsl.test.ts @@ -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([]) }) }) diff --git a/cli/src/api/app-dsl.ts b/cli/src/api/app-dsl.ts index acb10a95351..19c26f5d7fb 100644 --- a/cli/src/api/app-dsl.ts +++ b/cli/src/api/app-dsl.ts @@ -33,7 +33,7 @@ export class AppDslClient { } async exportDsl(appId: string, query?: ExportQuery): Promise { - 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 { - return this.orpc.apps.byAppId.checkDependencies.get({ + return this.orpc.apps.byAppId.dependencies.check.get({ params: { app_id: appId }, }) } diff --git a/cli/src/api/app-run.ts b/cli/src/api/app-run.ts index cbd36de2049..a75b3f0c63a 100644 --- a/cli/src/api/app-run.ts +++ b/cli/src/api/app-run.ts @@ -54,7 +54,7 @@ export class AppRunClient { body: Record, opts: StreamOptions = {}, ): Promise> { - 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, ): Promise { - 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 }, }) diff --git a/cli/src/api/apps.test.ts b/cli/src/api/apps.test.ts index 861f60feb26..ba9126b7b98 100644 --- a/cli/src/api/apps.test.ts +++ b/cli/src/api/apps.test.ts @@ -82,12 +82,12 @@ describe('AppsClient.describe', () => { await stub?.stop() }) - it('hits /apps//describe, omits workspace_id and fields when not given', async () => { + it('hits /apps/, 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') }) }) diff --git a/cli/src/api/apps.ts b/cli/src/api/apps.ts index 1189fdeaa06..fa29d66fd14 100644 --- a/cli/src/api/apps.ts +++ b/cli/src/api/apps.ts @@ -37,7 +37,7 @@ export class AppsClient implements AppReader { } async describe(appId: string, fields?: readonly string[]): Promise { - 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, diff --git a/cli/src/api/file-upload.test.ts b/cli/src/api/file-upload.test.ts index 018389916b0..602cf351e60 100644 --- a/cli/src/api/file-upload.test.ts +++ b/cli/src/api/file-upload.test.ts @@ -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 () => { diff --git a/cli/src/api/file-upload.ts b/cli/src/api/file-upload.ts index 7a032737a59..011f898c74e 100644 --- a/cli/src/api/file-upload.ts +++ b/cli/src/api/file-upload.ts @@ -65,7 +65,7 @@ export class FileUploadClient { form.append('file', blob, filename) return this.http.post( - `apps/${encodeURIComponent(appId)}/files/upload`, + `apps/${encodeURIComponent(appId)}/files`, { body: form, timeoutMs: 60_000 }, ) } diff --git a/cli/src/api/members.test.ts b/cli/src/api/members.test.ts index b4e01b76b24..a8fdc633f77 100644 --- a/cli/src/api/members.test.ts +++ b/cli/src/api/members.test.ts @@ -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//switch and returns workspace detail', async () => { + it('POSTs /workspaces/: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) }) diff --git a/cli/src/api/members.ts b/cli/src/api/members.ts index 7b1f80c08ff..8a9bc13081f 100644 --- a/cli/src/api/members.ts +++ b/cli/src/api/members.ts @@ -47,7 +47,7 @@ export class MembersClient { memberId: string, payload: MemberRoleUpdatePayload, ): Promise { - 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, }) diff --git a/cli/src/api/permitted-external-apps.test.ts b/cli/src/api/permitted-external-apps.test.ts index f6fa38cb3eb..58f47b4566f 100644 --- a/cli/src/api/permitted-external-apps.test.ts +++ b/cli/src/api/permitted-external-apps.test.ts @@ -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' } }) }) diff --git a/cli/src/api/permitted-external-apps.ts b/cli/src/api/permitted-external-apps.ts index 497c398d0ba..c0164d3c536 100644 --- a/cli/src/api/permitted-external-apps.ts +++ b/cli/src/api/permitted-external-apps.ts @@ -26,7 +26,7 @@ export class PermittedExternalAppsClient implements AppReader { } async describe(appId: string, fields?: readonly string[]): Promise { - 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 }, }) diff --git a/cli/src/api/workspaces.ts b/cli/src/api/workspaces.ts index 3ef587b574d..08495a01fce 100644 --- a/cli/src/api/workspaces.ts +++ b/cli/src/api/workspaces.ts @@ -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 diff --git a/cli/src/cache/compat-store.test.ts b/cli/src/cache/compat-store.test.ts new file mode 100644 index 00000000000..25ad11db06f --- /dev/null +++ b/cli/src/cache/compat-store.test.ts @@ -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) + }) +}) diff --git a/cli/src/cache/compat-store.ts b/cli/src/cache/compat-store.ts new file mode 100644 index 00000000000..2df6dcf3377 --- /dev/null +++ b/cli/src/cache/compat-store.ts @@ -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 } as const + +export type CompatStore = { + readonly isFreshCompatible: (host: string, now?: Date) => boolean + readonly markCompatible: (host: string, now?: Date) => Promise +} + +export type CompatStoreOptions = { + readonly store?: Store + readonly now?: () => Date + readonly ttlMs?: number +} + +export async function loadCompatStore(opts: CompatStoreOptions = {}): Promise { + 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> { + const out = new Map() + let raw: Record + 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): Promise { + const compatible: Record = {} + for (const [host, t] of state) + compatible[host] = new Date(t).toISOString() + await store.set(COMPATIBLE_KEY, compatible) +} diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts index 8ef0b381e9e..1b0d6803180 100644 --- a/cli/src/commands/_shared/authed-command.ts +++ b/cli/src/commands/_shared/authed-command.ts @@ -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 } diff --git a/cli/src/commands/auth/login/index.ts b/cli/src/commands/auth/login/index.ts index d9b6dd27fc3..214f38c3697 100644 --- a/cli/src/commands/auth/login/index.ts +++ b/cli/src/commands/auth/login/index.ts @@ -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 }) + }, }) } diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts index 2c1ba5b95a9..f0eb14dc8e1 100644 --- a/cli/src/commands/auth/login/login.ts +++ b/cli/src/commands/auth/login/login.ts @@ -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 } export async function runLogin(opts: LoginOptions): Promise { @@ -70,6 +74,9 @@ export async function runLogin(opts: LoginOptions): Promise { 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) diff --git a/cli/src/commands/resume/app/run.test.ts b/cli/src/commands/resume/app/run.test.ts index a72b0a93b25..8e1882906ae 100644 --- a/cli/src/commands/resume/app/run.test.ts +++ b/cli/src/commands/resume/app/run.test.ts @@ -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 diff --git a/cli/src/commands/resume/app/run.ts b/cli/src/commands/resume/app/run.ts index 1dc2855a118..0610f68d5e6 100644 --- a/cli/src/commands/resume/app/run.ts +++ b/cli/src/commands/resume/app/run.ts @@ -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 ?? '' diff --git a/cli/src/commands/run/app/run.ts b/cli/src/commands/run/app/run.ts index ab468678a72..c0598b0eab0 100644 --- a/cli/src/commands/run/app/run.ts +++ b/cli/src/commands/run/app/run.ts @@ -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({ diff --git a/cli/src/commands/use/workspace/use.ts b/cli/src/commands/use/workspace/use.ts index 3070a76aa3e..94f335c940e 100644 --- a/cli/src/commands/use/workspace/use.ts +++ b/cli/src/commands/use/workspace/use.ts @@ -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//switch` fails we abort before touching `hosts.yml`, so + * `/workspaces/:switch` fails we abort before touching `hosts.yml`, so * local state never diverges from the server. */ export async function runUseWorkspace( diff --git a/cli/src/commands/version/version.test.ts b/cli/src/commands/version/version.test.ts index 33b7c17b769..be1e0f48917 100644 --- a/cli/src/commands/version/version.test.ts +++ b/cli/src/commands/version/version.test.ts @@ -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) }) diff --git a/cli/src/http/client.test.ts b/cli/src/http/client.test.ts index ae4448843a4..fa397852c98 100644 --- a/cli/src/http/client.test.ts +++ b/cli/src/http/client.test.ts @@ -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() diff --git a/cli/src/http/error-mapper.test.ts b/cli/src/http/error-mapper.test.ts index 3244222da07..c08a3a62e48 100644 --- a/cli/src/http/error-mapper.test.ts +++ b/cli/src/http/error-mapper.test.ts @@ -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, 'bad gateway') diff --git a/cli/src/http/error-mapper.ts b/cli/src/http/error-mapper.ts index 34d7637d4e0..2e29985037c 100644 --- a/cli/src/http/error-mapper.ts +++ b/cli/src/http/error-mapper.ts @@ -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) diff --git a/cli/src/store/manager.ts b/cli/src/store/manager.ts index 37962681cec..832aba6196b 100644 --- a/cli/src/store/manager.ts +++ b/cli/src/store/manager.ts @@ -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' diff --git a/cli/src/version/compat.test.ts b/cli/src/version/compat.test.ts index dcf3f08b259..c47b25e280f 100644 --- a/cli/src/version/compat.test.ts +++ b/cli/src/version/compat.test.ts @@ -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', () => { diff --git a/cli/src/version/compat.ts b/cli/src/version/compat.ts index b373ad0906b..5a1caf68cb1 100644 --- a/cli/src/version/compat.ts +++ b/cli/src/version/compat.ts @@ -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}]` } } diff --git a/cli/src/version/enforce.test.ts b/cli/src/version/enforce.test.ts new file mode 100644 index 00000000000..4c3395530c2 --- /dev/null +++ b/cli/src/version/enforce.test.ts @@ -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) + }) +}) diff --git a/cli/src/version/enforce.ts b/cli/src/version/enforce.ts new file mode 100644 index 00000000000..d1b9fe87da9 --- /dev/null +++ b/cli/src/version/enforce.ts @@ -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 + +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 { + 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 +} diff --git a/cli/src/version/nudge.ts b/cli/src/version/nudge.ts index a6d9fe96d4b..f8d368ee066 100644 --- a/cli/src/version/nudge.ts +++ b/cli/src/version/nudge.ts @@ -44,7 +44,9 @@ export async function maybeNudgeCompat(host: string, deps: NudgeDeps): Promise { 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 () => { diff --git a/cli/src/version/render.test.ts b/cli/src/version/render.test.ts index 2543ebf7748..0ca2f39616d 100644 --- a/cli/src/version/render.test.ts +++ b/cli/src/version/render.test.ts @@ -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]', }, } diff --git a/cli/src/version/render.ts b/cli/src/version/render.ts index 44dded18e28..70b725c80e3 100644 --- a/cli/src/version/render.ts +++ b/cli/src/version/render.ts @@ -15,7 +15,8 @@ export type RenderOptions = { const COMPAT_LABEL: Record = { 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('') diff --git a/cli/test/e2e/suites/discovery/get-app-single.e2e.ts b/cli/test/e2e/suites/discovery/get-app-single.e2e.ts index b620eb383ef..528c08c9fa0 100644 --- a/cli/test/e2e/suites/discovery/get-app-single.e2e.ts +++ b/cli/test/e2e/suites/discovery/get-app-single.e2e.ts @@ -3,7 +3,7 @@ * * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Single App Query (22 cases) * - * Note: difyctl get app queries a single app via GET /apps//describe?fields=info. + * Note: difyctl get app queries a single app via GET /apps/?fields=info. * The response is returned in list-envelope format {page,limit,total,data:[...]}. */ diff --git a/cli/test/fixtures/dify-mock/server.test.ts b/cli/test/fixtures/dify-mock/server.test.ts index 7233f2a23b3..e80e20643c7 100644 --- a/cli/test/fixtures/dify-mock/server.test.ts +++ b/cli/test/fixtures/dify-mock/server.test.ts @@ -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) diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts index 766963cd0d5..d38e723988f 100644 --- a/cli/test/fixtures/dify-mock/server.ts +++ b/cli/test/fixtures/dify-mock/server.ts @@ -15,9 +15,9 @@ export type DifyMock = { scenario: Scenario setScenario: (s: Scenario) => void stop: () => Promise - /** Body of the most recent POST to /apps/:id/run */ + /** Body of the most recent POST to /apps/:id:run */ lastRunBody: Record | 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 | 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 @@ -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({}) }) diff --git a/packages/contracts/generated/api/openapi/orpc.gen.ts b/packages/contracts/generated/api/openapi/orpc.gen.ts index 47aa1b90d6a..13ac22da962 100644 --- a/packages/contracts/generated/api/openapi/orpc.gen.ts +++ b/packages/contracts/generated/api/openapi/orpc.gen.ts @@ -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 })) diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index cd422cd1f92..677f1101f0b 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -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 = { diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 70ece880a68..b271648f8b2 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -27,7 +27,7 @@ export const zAppDescribeInfo = z.object({ /** * AppDescribeQuery * - * `?fields=` allow-list for GET /apps//describe. + * `?fields=` allow-list for GET /apps/. * * Empty / omitted → all blocks. Unknown member → ValidationError → 422. */ @@ -47,7 +47,7 @@ export const zAppDescribeResponse = z.object({ /** * AppDslExportQuery * - * Query parameters for GET /apps//export. + * Query parameters for GET /apps//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//form/human_input/. `extra='forbid'` + * Empty 200 body for POST /apps//human-input-forms/: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//tasks//stop. The handler always returns + * 200 body for POST /apps//tasks/: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(), diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index efb1a226f1e..0fb424bcfb5 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -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()] }