mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 02:28:30 +08:00
refactor(openapi): resource-oriented paths for /openapi/v1 + difyctl version gate (#38367)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
abd720146d
commit
915655683c
@ -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(),
|
||||
)
|
||||
|
||||
@ -2,11 +2,13 @@ from flask import Blueprint
|
||||
from flask_restx import Namespace
|
||||
|
||||
from controllers.openapi._errors import ErrorBody, OpenApiErrorCode, OpenApiErrorFormatter
|
||||
from controllers.openapi._version_gate import attach_version_gate
|
||||
from libs.device_flow_security import attach_anti_framing
|
||||
from libs.external_api import ExternalApi
|
||||
|
||||
bp = Blueprint("openapi", __name__, url_prefix="/openapi/v1")
|
||||
attach_anti_framing(bp)
|
||||
attach_version_gate(bp)
|
||||
|
||||
api = ExternalApi(
|
||||
bp,
|
||||
|
||||
@ -45,6 +45,7 @@ class OpenApiErrorCode(StrEnum):
|
||||
TOO_MANY_REQUESTS = "too_many_requests"
|
||||
INTERNAL_ERROR = "internal_server_error"
|
||||
BAD_GATEWAY = "bad_gateway"
|
||||
UPGRADE_REQUIRED = "upgrade_required"
|
||||
UNKNOWN = "unknown"
|
||||
# domain codes (must match the error_code attribute of the exception
|
||||
# classes raised on the openapi surface)
|
||||
|
||||
@ -279,7 +279,7 @@ def _csv_string_query_schema(schema: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
class AppDescribeQuery(BaseModel):
|
||||
"""`?fields=` allow-list for GET /apps/<id>/describe.
|
||||
"""`?fields=` allow-list for GET /apps/<id>.
|
||||
|
||||
Empty / omitted → all blocks. Unknown member → ValidationError → 422.
|
||||
"""
|
||||
@ -441,7 +441,7 @@ class MemberActionResponse(BaseModel):
|
||||
|
||||
|
||||
class TaskStopResponse(BaseModel):
|
||||
"""200 body for POST /apps/<id>/tasks/<task_id>/stop. The handler always returns
|
||||
"""200 body for POST /apps/<id>/tasks/<task_id>:stop. The handler always returns
|
||||
{"result": "success"}, so `result` is required (no default) — the generated contract
|
||||
types it as a required `'success'` rather than an optional field."""
|
||||
|
||||
@ -473,7 +473,7 @@ class AppDslImportPayload(BaseModel):
|
||||
|
||||
|
||||
class AppDslExportQuery(BaseModel):
|
||||
"""Query parameters for GET /apps/<app_id>/export."""
|
||||
"""Query parameters for GET /apps/<app_id>/dsl."""
|
||||
|
||||
include_secret: bool = Field(False, description="Include encrypted secret values in the exported DSL")
|
||||
workflow_id: UUIDStr | None = Field(
|
||||
@ -488,7 +488,7 @@ class AppDslExportResponse(BaseModel):
|
||||
|
||||
|
||||
class FormSubmitResponse(BaseModel):
|
||||
"""Empty 200 body for POST /apps/<id>/form/human_input/<token>. `extra='forbid'`
|
||||
"""Empty 200 body for POST /apps/<id>/human-input-forms/<token>:submit. `extra='forbid'`
|
||||
pins `additionalProperties: false` so the generated contract is an exact `{}` rather
|
||||
than an under-annotated open object."""
|
||||
|
||||
|
||||
69
api/controllers/openapi/_version_gate.py
Normal file
69
api/controllers/openapi/_version_gate.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""Version gate: reject outdated difyctl clients on /openapi/v1 with HTTP 426.
|
||||
|
||||
difyctl and the ``/openapi/v1`` surface ship in lockstep. A breaking path change
|
||||
(resource-oriented paths) means an outdated difyctl would call removed paths and
|
||||
get a bare 404; this gate returns ``426 Upgrade Required`` with an upgrade hint
|
||||
instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
from flask import Blueprint, Response, request
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.openapi._errors import ErrorBody, OpenApiErrorCode
|
||||
|
||||
_UPGRADE_HINT: Final = "Upgrade difyctl: https://docs.dify.ai/en/cli/install"
|
||||
|
||||
# difyctl sends `User-Agent: difyctl/<semver> (<os>; <arch>; <channel>)`.
|
||||
_DIFYCTL_UA_RE = re.compile(r"^difyctl/(\d+\.\d+\.\d+(?:-[\w.]+)?)")
|
||||
|
||||
_PREFIX: Final = "/openapi/v1/"
|
||||
|
||||
# Paths a too-old client must still reach to discover that it is outdated.
|
||||
_ALLOWLIST: Final = frozenset({"/openapi/v1/_version", "/openapi/v1/_health"})
|
||||
|
||||
|
||||
def _upgrade_required_response(client_version: str, min_version: str) -> Response:
|
||||
body = ErrorBody(
|
||||
code=OpenApiErrorCode.UPGRADE_REQUIRED,
|
||||
message=f"difyctl {client_version} is no longer supported; upgrade to >= {min_version}.",
|
||||
status=426,
|
||||
hint=_UPGRADE_HINT,
|
||||
)
|
||||
return Response(body.model_dump_json(exclude_none=True), status=426, mimetype="application/json")
|
||||
|
||||
|
||||
def attach_version_gate(bp: Blueprint) -> None:
|
||||
"""Reject difyctl clients older than ``[tool.dify] min_difyctl_version`` with 426.
|
||||
|
||||
Registered app-wide (``before_app_request``) rather than blueprint-scoped so it
|
||||
also fires for requests to *removed* paths — those no longer match an openapi
|
||||
route and would 404 before a blueprint-scoped ``before_request`` ever runs. The
|
||||
prefix guard scopes it back to ``/openapi/v1``. Fails open for non-difyctl or
|
||||
unparseable User-Agents (only a confidently-too-old difyctl is blocked).
|
||||
"""
|
||||
|
||||
@bp.before_app_request
|
||||
def _enforce_min_client_version() -> Response | None: # pyright: ignore[reportUnusedFunction]
|
||||
if not request.path.startswith(_PREFIX):
|
||||
return None
|
||||
if request.path in _ALLOWLIST:
|
||||
return None
|
||||
match = _DIFYCTL_UA_RE.match(request.headers.get("User-Agent", ""))
|
||||
if match is None:
|
||||
return None
|
||||
try:
|
||||
client_version = Version(match.group(1))
|
||||
except InvalidVersion:
|
||||
return None
|
||||
# Compare the numeric core (major.minor.patch) only — a pre-release build
|
||||
# like 0.2.0-rc.1 must not sort below the 0.2.0 floor.
|
||||
min_version = dify_config.tool.dify.min_difyctl_version
|
||||
if client_version.release[:3] < Version(min_version).release[:3]:
|
||||
return _upgrade_required_response(match.group(1), min_version)
|
||||
return None
|
||||
@ -30,7 +30,7 @@ class AppDslImportApi(Resource):
|
||||
a new app.
|
||||
|
||||
Returns 202 when the DSL version requires an explicit confirmation step
|
||||
(major version mismatch). Callers must then POST to the confirm endpoint.
|
||||
(major version mismatch). Callers must then POST to the imports :confirm method.
|
||||
Returns 400 when the import failed due to invalid DSL or a business error.
|
||||
"""
|
||||
|
||||
@ -79,7 +79,7 @@ class AppDslImportApi(Resource):
|
||||
return result, 200
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports/<string:import_id>/confirm")
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports/<string:import_id>:confirm")
|
||||
class AppDslImportConfirmApi(Resource):
|
||||
"""Confirm a pending DSL import identified by ``import_id``.
|
||||
|
||||
@ -119,7 +119,7 @@ class AppDslImportConfirmApi(Resource):
|
||||
return result, 200
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/export")
|
||||
@openapi_ns.route("/apps/<string:app_id>/dsl")
|
||||
class AppDslExportApi(Resource):
|
||||
"""Export an app's current draft configuration as a DSL YAML string.
|
||||
|
||||
@ -153,7 +153,7 @@ class AppDslExportApi(Resource):
|
||||
return AppDslExportResponse(data=data), 200
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/check-dependencies")
|
||||
@openapi_ns.route("/apps/<string:app_id>/dependencies:check")
|
||||
class AppDslCheckDependenciesApi(Resource):
|
||||
"""Check for leaked plugin dependencies after a DSL import.
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""POST /openapi/v1/apps/<app_id>/run — mode-agnostic runner."""
|
||||
"""POST /openapi/v1/apps/<app_id>:run — mode-agnostic runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -138,7 +138,7 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest, Session], Any]] = {
|
||||
}
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/run")
|
||||
@openapi_ns.route("/apps/<string:app_id>:run")
|
||||
class AppRunApi(Resource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_RUN,
|
||||
@ -174,7 +174,7 @@ class AppRunApi(Resource):
|
||||
return helper.compact_generate_response(stream_obj)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>/stop")
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>:stop")
|
||||
class AppRunTaskStopApi(Resource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_RUN,
|
||||
|
||||
@ -129,7 +129,7 @@ def build_app_describe_response(app: App, fields: set[str] | None) -> AppDescrib
|
||||
return AppDescribeResponse(info=info, parameters=parameters, input_schema=input_schema)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/describe")
|
||||
@openapi_ns.route("/apps/<string:app_id>")
|
||||
class AppDescribeApi(AppReadResource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_READ,
|
||||
|
||||
@ -87,7 +87,7 @@ class PermittedExternalAppsListApi(Resource):
|
||||
return env
|
||||
|
||||
|
||||
@openapi_ns.route("/permitted-external-apps/<string:app_id>/describe")
|
||||
@openapi_ns.route("/permitted-external-apps/<string:app_id>")
|
||||
class PermittedExternalAppDescribeApi(Resource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_READ_PERMITTED_EXTERNAL,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""POST /openapi/v1/apps/<app_id>/files/upload — upload a file for use in app inputs."""
|
||||
"""POST /openapi/v1/apps/<app_id>/files — upload a file for use in app inputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -26,7 +26,7 @@ from libs.oauth_bearer import Scope
|
||||
from services.file_service import FileService
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/files/upload")
|
||||
@openapi_ns.route("/apps/<string:app_id>/files")
|
||||
class AppFileUploadApi(Resource):
|
||||
@openapi_ns.doc("upload_file_for_app_input")
|
||||
@openapi_ns.doc(description="Upload a file to use as an input variable when running the app")
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"""
|
||||
OpenAPI bearer-authed human input form endpoints.
|
||||
|
||||
GET /apps/<app_id>/form/human_input/<form_token> — fetch paused form definition
|
||||
POST /apps/<app_id>/form/human_input/<form_token> — submit form response
|
||||
GET /apps/<app_id>/human-input-forms/<form_token> — fetch paused form definition
|
||||
POST /apps/<app_id>/human-input-forms/<form_token>:submit — submit form response
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -60,7 +60,7 @@ def _ensure_form_is_allowed_for_openapi(form) -> None:
|
||||
raise RecipientSurfaceMismatch()
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/form/human_input/<string:form_token>")
|
||||
@openapi_ns.route("/apps/<string:app_id>/human-input-forms/<string:form_token>")
|
||||
class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
@openapi_ns.response(200, "Form definition", openapi_ns.models[HumanInputFormDefinitionResponse.__name__])
|
||||
@auth_router.guard(
|
||||
@ -79,6 +79,9 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
service.ensure_form_active(form)
|
||||
return _jsonify_form_definition(form)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/human-input-forms/<string:form_token>:submit")
|
||||
class OpenApiWorkflowHumanInputFormSubmitApi(Resource):
|
||||
@auth_router.guard(
|
||||
scope=Scope.APPS_RUN,
|
||||
rbac=RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_TEST_AND_RUN),
|
||||
|
||||
@ -113,7 +113,7 @@ class WorkspaceByIdApi(Resource):
|
||||
return _workspace_detail(tenant, membership)
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/switch")
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>:switch")
|
||||
class WorkspaceSwitchApi(Resource):
|
||||
"""Server-side switch — equivalent to the console's POST /workspaces/switch.
|
||||
|
||||
@ -212,11 +212,12 @@ class WorkspaceMembersApi(Resource):
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>")
|
||||
class WorkspaceMemberApi(Resource):
|
||||
"""Remove a member.
|
||||
"""Remove a member (DELETE) or change a member's role (PATCH).
|
||||
|
||||
Self-removal and owner-removal are explicitly rejected by the service
|
||||
layer (CannotOperateSelfError, NoPermissionError) — both surface as
|
||||
400 per the spec, with the service's message preserved.
|
||||
400 per the spec, with the service's message preserved. Owner can never be
|
||||
assigned via PATCH (closed enum); admin cannot demote the standing owner.
|
||||
"""
|
||||
|
||||
@auth_router.guard_workspace(
|
||||
@ -243,15 +244,6 @@ class WorkspaceMemberApi(Resource):
|
||||
|
||||
return MemberActionResponse()
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>/role")
|
||||
class WorkspaceMemberRoleApi(Resource):
|
||||
"""Change a member's role.
|
||||
|
||||
Owner cannot be assigned here (closed enum). Admin cannot demote the
|
||||
standing owner (service NoPermissionError → 400, per spec).
|
||||
"""
|
||||
|
||||
@auth_router.guard_workspace(
|
||||
scope=Scope.WORKSPACE_WRITE,
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
|
||||
@ -259,7 +251,7 @@ class WorkspaceMemberRoleApi(Resource):
|
||||
)
|
||||
@returns(200, MemberActionResponse, description="Role updated")
|
||||
@accepts(body=MemberRoleUpdatePayload)
|
||||
def put(self, workspace_id: str, member_id: str, *, auth_data: AuthData, body: MemberRoleUpdatePayload):
|
||||
def patch(self, workspace_id: str, member_id: str, *, auth_data: AuthData, body: MemberRoleUpdatePayload):
|
||||
operator = _load_account(auth_data.account_id)
|
||||
tenant = _load_tenant(workspace_id)
|
||||
member = AccountService.get_account_by_id(db.session, member_id)
|
||||
|
||||
@ -93,21 +93,7 @@ User-scoped operations
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/check-dependencies
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Dependencies checked | **application/json**: [CheckDependenciesResult](#checkdependenciesresult)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/describe
|
||||
### [GET] /apps/{app_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -123,7 +109,21 @@ User-scoped operations
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/export
|
||||
### [GET] /apps/{app_id}/dependencies:check
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Dependencies checked | **application/json**: [CheckDependenciesResult](#checkdependenciesresult)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/dsl
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -140,7 +140,7 @@ User-scoped operations
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/files/upload
|
||||
### [POST] /apps/{app_id}/files
|
||||
Upload a file to use as an input variable when running the app
|
||||
|
||||
#### Parameters
|
||||
@ -160,7 +160,7 @@ Upload a file to use as an input variable when running the app
|
||||
| 415 | Unsupported file type or blocked extension | |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/form/human_input/{form_token}
|
||||
### [GET] /apps/{app_id}/human-input-forms/{form_token}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -174,7 +174,7 @@ Upload a file to use as an input variable when running the app
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Form definition | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/form/human_input/{form_token}
|
||||
### [POST] /apps/{app_id}/human-input-forms/{form_token}:submit
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -196,7 +196,38 @@ Upload a file to use as an input variable when running the app
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/run
|
||||
### [GET] /apps/{app_id}/tasks/{task_id}/events
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| continue_on_pause | query | Whether to keep the event stream open on pause | No | boolean |
|
||||
| include_state_snapshot | query | Whether to include workflow state snapshots | No | boolean |
|
||||
| app_id | path | | Yes | string |
|
||||
| task_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | SSE event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/tasks/{task_id}:stop
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| task_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Task stopped | **application/json**: [TaskStopResponse](#taskstopresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}:run
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -216,37 +247,6 @@ Upload a file to use as an input variable when running the app
|
||||
| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/tasks/{task_id}/events
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| continue_on_pause | query | Whether to keep the event stream open on pause | No | boolean |
|
||||
| include_state_snapshot | query | Whether to include workflow state snapshots | No | boolean |
|
||||
| app_id | path | | Yes | string |
|
||||
| task_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | SSE event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/tasks/{task_id}/stop
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| task_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Task stopped | **application/json**: [TaskStopResponse](#taskstopresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /oauth/device/approve
|
||||
#### Request Body
|
||||
|
||||
@ -330,7 +330,7 @@ Upload a file to use as an input variable when running the app
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /permitted-external-apps/{app_id}/describe
|
||||
### [GET] /permitted-external-apps/{app_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -391,7 +391,7 @@ Upload a file to use as an input variable when running the app
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /workspaces/{workspace_id}/apps/imports/{import_id}/confirm
|
||||
### [POST] /workspaces/{workspace_id}/apps/imports/{import_id}:confirm
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -460,7 +460,7 @@ Upload a file to use as an input variable when running the app
|
||||
| 200 | Member removed | **application/json**: [MemberActionResponse](#memberactionresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [PUT] /workspaces/{workspace_id}/members/{member_id}/role
|
||||
### [PATCH] /workspaces/{workspace_id}/members/{member_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -482,7 +482,7 @@ Upload a file to use as an input variable when running the app
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /workspaces/{workspace_id}/switch
|
||||
### [POST] /workspaces/{workspace_id}:switch
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -532,7 +532,7 @@ Upload a file to use as an input variable when running the app
|
||||
|
||||
#### AppDescribeQuery
|
||||
|
||||
`?fields=` allow-list for GET /apps/<id>/describe.
|
||||
`?fields=` allow-list for GET /apps/<id>.
|
||||
|
||||
Empty / omitted → all blocks. Unknown member → ValidationError → 422.
|
||||
|
||||
@ -550,7 +550,7 @@ Empty / omitted → all blocks. Unknown member → ValidationError → 422.
|
||||
|
||||
#### AppDslExportQuery
|
||||
|
||||
Query parameters for GET /apps/<app_id>/export.
|
||||
Query parameters for GET /apps/<app_id>/dsl.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
@ -762,7 +762,7 @@ future server adds a code. Formatter tests pin emitted values to the enum.
|
||||
|
||||
#### FormSubmitResponse
|
||||
|
||||
Empty 200 body for POST /apps/<id>/form/human_input/<token>. `extra='forbid'`
|
||||
Empty 200 body for POST /apps/<id>/human-input-forms/<token>:submit. `extra='forbid'`
|
||||
pins `additionalProperties: false` so the generated contract is an exact `{}` rather
|
||||
than an under-annotated open object.
|
||||
|
||||
@ -1022,7 +1022,7 @@ generated CLI whitelist all derive from it.
|
||||
|
||||
#### TaskStopResponse
|
||||
|
||||
200 body for POST /apps/<id>/tasks/<task_id>/stop. The handler always returns
|
||||
200 body for POST /apps/<id>/tasks/<task_id>:stop. The handler always returns
|
||||
{"result": "success"}, so `result` is required (no default) — the generated contract
|
||||
types it as a required `'success'` rather than an optional field.
|
||||
|
||||
|
||||
@ -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 = []
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Integration tests for POST /openapi/v1/apps/<id>/run."""
|
||||
"""Integration tests for POST /openapi/v1/apps/<id>:run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -36,7 +36,7 @@ def test_run_chat_dispatches_to_chat_handler(
|
||||
monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate))
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}:run",
|
||||
json={"inputs": {}, "query": "hi", "response_mode": "blocking", "user": "spoof@x.com"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -85,7 +85,7 @@ def test_run_chat_without_query_returns_422(
|
||||
):
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}:run",
|
||||
json={"inputs": {}, "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -116,7 +116,7 @@ def test_run_completion_dispatches_to_completion_handler(
|
||||
monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate))
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app.id}/run",
|
||||
f"/openapi/v1/apps/{app.id}:run",
|
||||
json={"inputs": {}, "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -131,7 +131,7 @@ def test_run_workflow_with_query_returns_422(
|
||||
app = app_with_mode("workflow")
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app.id}/run",
|
||||
f"/openapi/v1/apps/{app.id}:run",
|
||||
json={"inputs": {}, "query": "hi", "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -154,7 +154,7 @@ def test_run_workflow_no_query_dispatches_to_workflow_handler(
|
||||
monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate))
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app.id}/run",
|
||||
f"/openapi/v1/apps/{app.id}:run",
|
||||
json={"inputs": {}, "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -170,7 +170,7 @@ def test_run_unsupported_mode_returns_422(
|
||||
app = app_with_mode("channel")
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app.id}/run",
|
||||
f"/openapi/v1/apps/{app.id}:run",
|
||||
json={"inputs": {}, "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -181,7 +181,7 @@ def test_run_unsupported_mode_returns_422(
|
||||
def test_run_without_bearer_returns_401(flask_app: Flask, app_in_workspace):
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}:run",
|
||||
json={"inputs": {}, "query": "hi"},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
@ -205,7 +205,7 @@ def test_run_with_insufficient_scope_returns_403(
|
||||
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}:run",
|
||||
json={"inputs": {}, "query": "hi"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -215,7 +215,7 @@ def test_run_with_insufficient_scope_returns_403(
|
||||
def test_run_with_unknown_app_returns_404(flask_app: Flask, account_token):
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{uuid.uuid4()}/run",
|
||||
f"/openapi/v1/apps/{uuid.uuid4()}:run",
|
||||
json={"inputs": {}, "query": "hi"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -235,7 +235,7 @@ def test_run_streaming_returns_event_stream(
|
||||
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}:run",
|
||||
json={"inputs": {}, "query": "hi", "response_mode": "streaming"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
@ -247,7 +247,7 @@ def test_run_streaming_returns_event_stream(
|
||||
def test_run_without_inputs_returns_422(flask_app: Flask, account_token, app_in_workspace):
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}:run",
|
||||
json={"query": "hi"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -72,7 +72,7 @@ def test_run_chat_always_calls_generate_with_streaming_true(
|
||||
"AppGenerateService",
|
||||
GenerateService,
|
||||
)
|
||||
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/run", method="POST"):
|
||||
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}:run", method="POST"):
|
||||
_run_chat(
|
||||
_make_app(),
|
||||
_make_account(),
|
||||
@ -84,9 +84,9 @@ def test_run_chat_always_calls_generate_with_streaming_true(
|
||||
|
||||
|
||||
def test_stop_task_endpoint_registered(openapi_app):
|
||||
"""POST /openapi/v1/apps/<id>/tasks/<task_id>/stop must be registered."""
|
||||
"""POST /openapi/v1/apps/<id>/tasks/<task_id>:stop must be registered."""
|
||||
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
|
||||
assert "/openapi/v1/apps/<string:app_id>/tasks/<string:task_id>/stop" in rules
|
||||
assert "/openapi/v1/apps/<string:app_id>/tasks/<string:task_id>:stop" in rules
|
||||
|
||||
|
||||
def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch):
|
||||
@ -117,7 +117,7 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipel
|
||||
)
|
||||
|
||||
api = AppRunTaskStopApi()
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/tasks/task-1/stop", method="POST"):
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/tasks/task-1:stop", method="POST"):
|
||||
result = api.post.__wrapped__(
|
||||
api,
|
||||
app_id="app-1",
|
||||
|
||||
@ -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"
|
||||
):
|
||||
|
||||
100
api/tests/unit_tests/controllers/openapi/test_version_gate.py
Normal file
100
api/tests/unit_tests/controllers/openapi/test_version_gate.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""Tests for the difyctl version gate on /openapi/v1 (HTTP 426 Upgrade Required).
|
||||
|
||||
The gate is an app-level ``before_app_request`` hook: it must fire before routing,
|
||||
so requests to *removed* paths (which no longer match a route) become 426 rather
|
||||
than a bare 404. It reads the difyctl version from the User-Agent and fails open
|
||||
for anything it can't confidently identify as an outdated difyctl.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
# Floor is [tool.dify] min_difyctl_version = "0.2.0". Comparison is on the numeric
|
||||
# core (major.minor.patch), so 0.2.0-alpha passes (core 0.2.0 == floor) while
|
||||
# 0.1.0 (core 0.1.0 < 0.2.0) is blocked.
|
||||
OLD_UA = "difyctl/0.1.0 (darwin; arm64; stable)"
|
||||
CURRENT_UA = "difyctl/0.2.0-alpha (darwin; arm64; stable)"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(openapi_app: Flask):
|
||||
return openapi_app.test_client()
|
||||
|
||||
|
||||
def _gated_path() -> str:
|
||||
"""An existing, auth-guarded route on the surface (GET /apps/<id>)."""
|
||||
return f"/openapi/v1/apps/{uuid.uuid4()}"
|
||||
|
||||
|
||||
class TestVersionGate:
|
||||
def test_old_client_gets_426_with_upgrade_body(self, client):
|
||||
res = client.get(_gated_path(), headers={"User-Agent": OLD_UA})
|
||||
|
||||
assert res.status_code == 426
|
||||
body = res.get_json()
|
||||
assert body["code"] == "upgrade_required"
|
||||
assert body["status"] == 426
|
||||
assert "0.1.0" in body["message"]
|
||||
assert "0.2.0" in body["message"]
|
||||
assert "docs.dify.ai" in body["hint"]
|
||||
|
||||
def test_removed_old_path_gets_426_not_404(self, client):
|
||||
# /apps/<id>/run was renamed to /apps/<id>:run — the old path matches no
|
||||
# route. The app-level gate must still turn it into 426, not a bare 404.
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{uuid.uuid4()}/run",
|
||||
headers={"User-Agent": OLD_UA},
|
||||
json={"inputs": {}},
|
||||
)
|
||||
|
||||
assert res.status_code == 426
|
||||
assert res.get_json()["code"] == "upgrade_required"
|
||||
|
||||
def test_current_client_passes_gate(self, client):
|
||||
# Gate passes → normal dispatch (auth rejects, never the gate's 426).
|
||||
# 0.2.0-alpha == floor on the numeric core, so it passes despite the suffix.
|
||||
res = client.get(_gated_path(), headers={"User-Agent": CURRENT_UA})
|
||||
|
||||
assert res.status_code != 426
|
||||
|
||||
def test_prerelease_at_floor_passes(self, client):
|
||||
# Numeric-core comparison: a pre-release of the floor version (0.2.0-rc.1,
|
||||
# core 0.2.0) passes, even though 0.2.0-rc.1 < 0.2.0 under naive ordering.
|
||||
res = client.get(_gated_path(), headers={"User-Agent": "difyctl/0.2.0-rc.1 (darwin; arm64; rc)"})
|
||||
|
||||
assert res.status_code != 426
|
||||
|
||||
def test_prerelease_below_floor_gets_426(self, client):
|
||||
# 0.1.9-rc.1 has core 0.1.9 < 0.2.0 floor → still blocked.
|
||||
res = client.get(_gated_path(), headers={"User-Agent": "difyctl/0.1.9-rc.1 (darwin; arm64; rc)"})
|
||||
|
||||
assert res.status_code == 426
|
||||
|
||||
def test_non_difyctl_ua_passes(self, client):
|
||||
res = client.get(_gated_path(), headers={"User-Agent": "curl/8.4.0"})
|
||||
|
||||
assert res.status_code != 426
|
||||
|
||||
def test_missing_ua_passes(self, client):
|
||||
res = client.get(_gated_path())
|
||||
|
||||
assert res.status_code != 426
|
||||
|
||||
def test_unparseable_version_passes(self, client):
|
||||
res = client.get(_gated_path(), headers={"User-Agent": "difyctl/notaversion (x; y; z)"})
|
||||
|
||||
assert res.status_code != 426
|
||||
|
||||
def test_version_probe_allowlisted(self, client):
|
||||
res = client.get("/openapi/v1/_version", headers={"User-Agent": OLD_UA})
|
||||
|
||||
assert res.status_code == 200
|
||||
|
||||
def test_health_allowlisted(self, client):
|
||||
res = client.get("/openapi/v1/_health", headers={"User-Agent": OLD_UA})
|
||||
|
||||
assert res.status_code == 200
|
||||
@ -1,7 +1,7 @@
|
||||
"""Member endpoints under /openapi/v1/workspaces/<id>/...
|
||||
|
||||
Coverage:
|
||||
- Route registration (5 endpoints across 4 URL patterns)
|
||||
- Route registration (5 endpoints across 3 URL patterns)
|
||||
- Body validation lands at 400 (per spec — not Pydantic's default 422)
|
||||
- Domain exception → HTTP code mapping is preserved with the service's
|
||||
original message (so CLI users see what the console user sees)
|
||||
@ -37,7 +37,6 @@ from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePay
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from controllers.openapi.workspaces import (
|
||||
WorkspaceMemberApi,
|
||||
WorkspaceMemberRoleApi,
|
||||
WorkspaceMembersApi,
|
||||
WorkspaceSwitchApi,
|
||||
)
|
||||
@ -175,7 +174,7 @@ def _account_service(**overrides) -> SimpleNamespace:
|
||||
|
||||
|
||||
def test_switch_route_registered(openapi_app: Flask):
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/switch")
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>:switch")
|
||||
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceSwitchApi
|
||||
assert "POST" in rule.methods
|
||||
|
||||
@ -191,12 +190,7 @@ def test_member_by_id_route_registered(openapi_app: Flask):
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/members/<string:member_id>")
|
||||
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMemberApi
|
||||
assert "DELETE" in rule.methods
|
||||
|
||||
|
||||
def test_member_role_route_registered(openapi_app: Flask):
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/members/<string:member_id>/role")
|
||||
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMemberRoleApi
|
||||
assert "PUT" in rule.methods
|
||||
assert "PATCH" in rule.methods
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -250,17 +244,17 @@ def test_update_role_rejects_invalid_body_with_422(app: Flask, bypass_pipeline):
|
||||
"""Invalid role-update body surfaces as 422 through @accepts (was 400)."""
|
||||
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMemberRoleApi()
|
||||
api = WorkspaceMemberApi()
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
|
||||
method="PUT",
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
|
||||
method="PATCH",
|
||||
data=json.dumps({"role": "owner"}), # closed enum rejects owner
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(UnprocessableEntity):
|
||||
api.put.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id))
|
||||
api.patch.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -291,7 +285,7 @@ def test_switch_returns_workspace_detail_with_current_true(
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}:switch", method="POST"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
@ -320,7 +314,7 @@ def test_switch_404s_when_service_raises_account_not_link_tenant(
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}:switch", method="POST"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(NotFound):
|
||||
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
@ -767,7 +761,7 @@ def test_delete_member_404_when_member_missing(app: Flask, bypass_pipeline, monk
|
||||
def test_update_role_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch):
|
||||
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMemberRoleApi()
|
||||
api = WorkspaceMemberApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [
|
||||
@ -785,13 +779,15 @@ def test_update_role_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
|
||||
method="PUT",
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
|
||||
method="PATCH",
|
||||
data=json.dumps({"role": "admin"}),
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.put.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id))
|
||||
body, status = api.patch.__wrapped__(
|
||||
api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id)
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
assert body == {"result": "success"}
|
||||
@ -811,7 +807,7 @@ def test_update_role_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest
|
||||
def test_update_role_exception_mapping(app: Flask, bypass_pipeline, monkeypatch, exc, expected):
|
||||
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceMemberRoleApi()
|
||||
api = WorkspaceMemberApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.get.side_effect = [
|
||||
@ -828,14 +824,14 @@ def test_update_role_exception_mapping(app: Flask, bypass_pipeline, monkeypatch,
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
|
||||
method="PUT",
|
||||
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
|
||||
method="PATCH",
|
||||
data=json.dumps({"role": "admin"}),
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(expected):
|
||||
api.put.__wrapped__(
|
||||
api.patch.__wrapped__(
|
||||
api,
|
||||
workspace_id=ws_id,
|
||||
member_id=member_id,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -15,41 +15,41 @@ function run(args: string[]): { code: number, stdout: string, stderr: string } {
|
||||
}
|
||||
}
|
||||
|
||||
describe('release-naming compat-check (compat 1.15.0..1.15.0)', () => {
|
||||
describe('release-naming compat-check (compat 1.16.0..1.16.0)', () => {
|
||||
it('accepts a version inside the window', () => {
|
||||
expect(run(['compat-check', '1.15.0']).code).toBe(0)
|
||||
expect(run(['compat-check', '1.16.0']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive lower bound', () => {
|
||||
expect(run(['compat-check', '1.15.0']).code).toBe(0)
|
||||
expect(run(['compat-check', '1.16.0']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive upper bound', () => {
|
||||
expect(run(['compat-check', '1.15.0']).code).toBe(0)
|
||||
expect(run(['compat-check', '1.16.0']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts a v-prefixed tag', () => {
|
||||
expect(run(['compat-check', 'v1.15.0']).code).toBe(0)
|
||||
expect(run(['compat-check', 'v1.16.0']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version below the lower bound', () => {
|
||||
expect(run(['compat-check', '1.14.9']).code).not.toBe(0)
|
||||
expect(run(['compat-check', '1.15.9']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version above the upper bound', () => {
|
||||
expect(run(['compat-check', '1.15.1']).code).not.toBe(0)
|
||||
expect(run(['compat-check', '1.16.1']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('treats a prerelease of the bound as below it (1.15.0-rc1 < 1.15.0)', () => {
|
||||
expect(run(['compat-check', '1.15.0-rc1']).code).not.toBe(0)
|
||||
it('treats a prerelease of the bound as below it (1.16.0-rc1 < 1.16.0)', () => {
|
||||
expect(run(['compat-check', '1.16.0-rc1']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata on the bound (1.15.0+build == 1.15.0)', () => {
|
||||
expect(run(['compat-check', '1.15.0+build123']).code).toBe(0)
|
||||
it('ignores build metadata on the bound (1.16.0+build == 1.16.0)', () => {
|
||||
expect(run(['compat-check', '1.16.0+build123']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata when out of range (1.15.1+build still rejected)', () => {
|
||||
expect(run(['compat-check', '1.15.1+build123']).code).not.toBe(0)
|
||||
it('ignores build metadata when out of range (1.16.1+build still rejected)', () => {
|
||||
expect(run(['compat-check', '1.16.1+build123']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('requires a version argument', () => {
|
||||
@ -60,7 +60,7 @@ describe('release-naming compat-check (compat 1.15.0..1.15.0)', () => {
|
||||
describe('release-naming github-env', () => {
|
||||
it('emits difyctlTag = tagPrefix + version', () => {
|
||||
const { stdout } = run(['github-env'])
|
||||
expect(stdout).toMatch(/^difyctlTag=difyctl-v0\.1\.0-alpha$/m)
|
||||
expect(stdout).toMatch(/^difyctlTag=difyctl-v0\.2\.0-alpha$/m)
|
||||
})
|
||||
|
||||
it('still emits the existing trace fields', () => {
|
||||
@ -75,14 +75,14 @@ describe('release-naming edge channel', () => {
|
||||
expect(run(['channels']).stdout).toMatch(/^edge$/m)
|
||||
})
|
||||
|
||||
it('edge-version derives <pkgcore>-edge.<sha> stripping the alpha prerelease', () => {
|
||||
// package.json version is 0.1.0-alpha -> core 0.1.0
|
||||
expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe('0.1.0-edge.2fd7b82')
|
||||
it('edge-version derives <pkgcore>-edge.<sha> from the package version', () => {
|
||||
// package.json version is 0.2.0-alpha -> core 0.2.0
|
||||
expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe('0.2.0-edge.2fd7b82')
|
||||
})
|
||||
|
||||
it('edge-version accepts a 40-char sha', () => {
|
||||
const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000'
|
||||
expect(run(['edge-version', sha]).stdout.trim()).toBe(`0.1.0-edge.${sha}`)
|
||||
expect(run(['edge-version', sha]).stdout.trim()).toBe(`0.2.0-edge.${sha}`)
|
||||
})
|
||||
|
||||
it('edge-version rejects a non-hex sha', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -33,7 +33,7 @@ export class AppDslClient {
|
||||
}
|
||||
|
||||
async exportDsl(appId: string, query?: ExportQuery): Promise<string> {
|
||||
const resp = await this.orpc.apps.byAppId.export.get({
|
||||
const resp = await this.orpc.apps.byAppId.dsl.get({
|
||||
params: { app_id: appId },
|
||||
query: query !== undefined
|
||||
? {
|
||||
@ -52,7 +52,7 @@ export class AppDslClient {
|
||||
}
|
||||
|
||||
async checkDependencies(appId: string): Promise<CheckDependenciesResult> {
|
||||
return this.orpc.apps.byAppId.checkDependencies.get({
|
||||
return this.orpc.apps.byAppId.dependencies.check.get({
|
||||
params: { app_id: appId },
|
||||
})
|
||||
}
|
||||
|
||||
@ -54,7 +54,7 @@ export class AppRunClient {
|
||||
body: Record<string, unknown>,
|
||||
opts: StreamOptions = {},
|
||||
): Promise<AsyncIterable<SseEvent>> {
|
||||
const res = await this.http.stream(`apps/${encodeURIComponent(appId)}/run`, {
|
||||
const res = await this.http.stream(`apps/${encodeURIComponent(appId)}:run`, {
|
||||
method: 'POST',
|
||||
json: body,
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
@ -79,7 +79,7 @@ export class AppRunClient {
|
||||
action: string,
|
||||
inputs: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.orpc.apps.byAppId.form.humanInput.byFormToken.post({
|
||||
await this.orpc.apps.byAppId.humanInputForms.byFormToken.submit.post({
|
||||
params: { app_id: appId, form_token: formToken },
|
||||
body: { action, inputs },
|
||||
})
|
||||
|
||||
@ -82,12 +82,12 @@ describe('AppsClient.describe', () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('hits /apps/<id>/describe, omits workspace_id and fields when not given', async () => {
|
||||
it('hits /apps/<id>, omits workspace_id and fields when not given', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
|
||||
const res = await makeClient(stub.url).describe('app-1')
|
||||
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/describe')
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1')
|
||||
const q = queryOf(stub.captured.url)
|
||||
expect(q.has('workspace_id')).toBe(false)
|
||||
expect(q.has('fields')).toBe(false)
|
||||
@ -107,6 +107,6 @@ describe('AppsClient.describe', () => {
|
||||
|
||||
await makeClient(stub.url).describe('app/with space')
|
||||
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app%2Fwith%20space/describe')
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app%2Fwith%20space')
|
||||
})
|
||||
})
|
||||
|
||||
@ -37,7 +37,7 @@ export class AppsClient implements AppReader {
|
||||
}
|
||||
|
||||
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
|
||||
return this.orpc.apps.byAppId.describe.get({
|
||||
return this.orpc.apps.byAppId.get({
|
||||
params: { app_id: appId },
|
||||
query: {
|
||||
fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined,
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -65,7 +65,7 @@ export class FileUploadClient {
|
||||
form.append('file', blob, filename)
|
||||
|
||||
return this.http.post<UploadedFile>(
|
||||
`apps/${encodeURIComponent(appId)}/files/upload`,
|
||||
`apps/${encodeURIComponent(appId)}/files`,
|
||||
{ body: form, timeoutMs: 60_000 },
|
||||
)
|
||||
}
|
||||
|
||||
@ -154,13 +154,13 @@ describe('MembersClient.updateRole', () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('PUTs role payload to /role subresource', async () => {
|
||||
it('PATCHes role payload to the member resource', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap))
|
||||
|
||||
const result = await makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' })
|
||||
|
||||
expect(stub.captured.method).toBe('PUT')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1/role')
|
||||
expect(stub.captured.method).toBe('PATCH')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1')
|
||||
expect(JSON.parse(stub.captured.body ?? '{}')).toEqual({ role: 'admin' })
|
||||
expect(result.result).toBe('success')
|
||||
})
|
||||
@ -181,7 +181,7 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('POSTs /workspaces/<id>/switch and returns workspace detail', async () => {
|
||||
it('POSTs /workspaces/<id>:switch and returns workspace detail', async () => {
|
||||
stub = await startStubServer(cap =>
|
||||
jsonResponder(
|
||||
200,
|
||||
@ -200,7 +200,7 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
|
||||
const result = await client.switch('ws-1')
|
||||
|
||||
expect(stub.captured.method).toBe('POST')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/switch')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1:switch')
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@ -47,7 +47,7 @@ export class MembersClient {
|
||||
memberId: string,
|
||||
payload: MemberRoleUpdatePayload,
|
||||
): Promise<MemberActionResponse> {
|
||||
return this.orpc.workspaces.byWorkspaceId.members.byMemberId.role.put({
|
||||
return this.orpc.workspaces.byWorkspaceId.members.byMemberId.patch({
|
||||
params: { workspace_id: workspaceId, member_id: memberId },
|
||||
body: payload,
|
||||
})
|
||||
|
||||
@ -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' } })
|
||||
})
|
||||
|
||||
@ -26,7 +26,7 @@ export class PermittedExternalAppsClient implements AppReader {
|
||||
}
|
||||
|
||||
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
|
||||
return this.orpc.permittedExternalApps.byAppId.describe.get({
|
||||
return this.orpc.permittedExternalApps.byAppId.get({
|
||||
params: { app_id: appId },
|
||||
query: { fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined },
|
||||
})
|
||||
|
||||
@ -19,7 +19,7 @@ export class WorkspacesClient {
|
||||
|
||||
/**
|
||||
* Server-side workspace switch via OpenAPI POST
|
||||
* `/workspaces/{id}/switch` — the bearer-authed equivalent of the
|
||||
* `/workspaces/{id}:switch` — the bearer-authed equivalent of the
|
||||
* console's POST `/workspaces/switch`. The server updates the caller's
|
||||
* `current` tenant_account_join row. Callers MUST refresh their local
|
||||
* `hosts.yml` only after this resolves — never fall back to a local
|
||||
|
||||
57
cli/src/cache/compat-store.test.ts
vendored
Normal file
57
cli/src/cache/compat-store.test.ts
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
import { CACHE_COMPAT, getCache } from '@/store/manager'
|
||||
import { loadCompatStore } from './compat-store'
|
||||
|
||||
const HOST = 'https://cloud.dify.ai'
|
||||
const NOW = new Date('2026-05-20T12:00:00.000Z')
|
||||
|
||||
describe('compat-store', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-compat-'))
|
||||
prev = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
process.env[ENV_CACHE_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const store = (now: Date = NOW) => loadCompatStore({ store: getCache(CACHE_COMPAT), now: () => now })
|
||||
|
||||
it('is not fresh before anything is marked', async () => {
|
||||
expect((await store()).isFreshCompatible(HOST)).toBe(false)
|
||||
})
|
||||
|
||||
it('is fresh right after markCompatible, and persists across loads', async () => {
|
||||
await (await store()).markCompatible(HOST)
|
||||
expect((await store()).isFreshCompatible(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('stays fresh within the 1h TTL', async () => {
|
||||
const past = new Date(NOW.getTime() - 30 * 60 * 1000)
|
||||
await (await store(past)).markCompatible(HOST)
|
||||
expect((await store(NOW)).isFreshCompatible(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('expires after the 1h TTL', async () => {
|
||||
const past = new Date(NOW.getTime() - 61 * 60 * 1000)
|
||||
await (await store(past)).markCompatible(HOST)
|
||||
expect((await store(NOW)).isFreshCompatible(HOST)).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks hosts independently', async () => {
|
||||
const s = await store()
|
||||
await s.markCompatible(HOST)
|
||||
expect(s.isFreshCompatible('https://other.dify.ai')).toBe(false)
|
||||
})
|
||||
})
|
||||
71
cli/src/cache/compat-store.ts
vendored
Normal file
71
cli/src/cache/compat-store.ts
vendored
Normal file
@ -0,0 +1,71 @@
|
||||
import type { Store } from '@/store/store'
|
||||
import { CACHE_COMPAT, getCache } from '@/store/manager'
|
||||
|
||||
// How long a host stays "known compatible" before difyctl re-probes /_version.
|
||||
export const COMPAT_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
// Only *positive* (compatible) verdicts are cached — never "too old". A host that
|
||||
// was too old is re-probed every time, so a just-upgraded server clears a previous
|
||||
// block immediately instead of staying locked out for the whole TTL.
|
||||
const COMPATIBLE_KEY = { key: 'compatible', default: {} as Record<string, string> } as const
|
||||
|
||||
export type CompatStore = {
|
||||
readonly isFreshCompatible: (host: string, now?: Date) => boolean
|
||||
readonly markCompatible: (host: string, now?: Date) => Promise<void>
|
||||
}
|
||||
|
||||
export type CompatStoreOptions = {
|
||||
readonly store?: Store
|
||||
readonly now?: () => Date
|
||||
readonly ttlMs?: number
|
||||
}
|
||||
|
||||
export async function loadCompatStore(opts: CompatStoreOptions = {}): Promise<CompatStore> {
|
||||
const store = opts.store ?? getCache(CACHE_COMPAT)
|
||||
const ttlMs = opts.ttlMs ?? COMPAT_TTL_MS
|
||||
const clock = opts.now ?? (() => new Date())
|
||||
const memory = await readCompatible(store)
|
||||
|
||||
return {
|
||||
isFreshCompatible: (host, now) => {
|
||||
const last = memory.get(host)
|
||||
if (last === undefined)
|
||||
return false
|
||||
const elapsed = Math.max(0, (now ?? clock()).getTime() - last)
|
||||
return elapsed < ttlMs
|
||||
},
|
||||
markCompatible: async (host, now) => {
|
||||
const stamp = (now ?? clock()).getTime()
|
||||
memory.set(host, stamp)
|
||||
// Re-read disk inside the write cycle so concurrent processes touching
|
||||
// different hosts don't clobber each other's stamps.
|
||||
const onDisk = await readCompatible(store)
|
||||
onDisk.set(host, stamp)
|
||||
await writeCompatible(store, onDisk)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function readCompatible(store: Store): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>()
|
||||
let raw: Record<string, string>
|
||||
try {
|
||||
raw = await store.get(COMPATIBLE_KEY)
|
||||
}
|
||||
catch {
|
||||
return out
|
||||
}
|
||||
for (const [host, iso] of Object.entries(raw)) {
|
||||
const t = Date.parse(iso)
|
||||
if (!Number.isNaN(t))
|
||||
out.set(host, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function writeCompatible(store: Store, state: Map<string, number>): Promise<void> {
|
||||
const compatible: Record<string, string> = {}
|
||||
for (const [host, t] of state)
|
||||
compatible[host] = new Date(t).toISOString()
|
||||
await store.set(COMPATIBLE_KEY, compatible)
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,10 @@ export type LoginOptions = {
|
||||
readonly browserEnv?: BrowserEnv
|
||||
readonly browserOpener?: BrowserOpener
|
||||
readonly clock?: Clock
|
||||
// Version guard for the freshly-authenticated host; wired to enforceDifyVersion
|
||||
// at the command boundary. Runs before the session is persisted so we never
|
||||
// save credentials for a server too old for this difyctl. Defaults to a no-op.
|
||||
readonly verifyServer?: (host: string) => Promise<void>
|
||||
}
|
||||
|
||||
export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
@ -70,6 +74,9 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
spinner.stop()
|
||||
}
|
||||
|
||||
// Refuse to persist a session to a server too old for this difyctl.
|
||||
await (opts.verifyServer ?? (async () => {}))(host)
|
||||
|
||||
const storeBundle = opts.store ?? await detectTokenStore()
|
||||
const display = bareHost(host)
|
||||
const email = accountEmail(success)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ?? ''
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -27,7 +27,7 @@ export type UseWorkspaceDeps = {
|
||||
* workspace list and let the caller pick one interactively (TTY only).
|
||||
*
|
||||
* The server-side switch is the source of truth: if POST
|
||||
* `/workspaces/<id>/switch` fails we abort before touching `hosts.yml`, so
|
||||
* `/workspaces/<id>:switch` fails we abort before touching `hosts.yml`, so
|
||||
* local state never diverges from the server.
|
||||
*/
|
||||
export async function runUseWorkspace(
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -74,7 +74,7 @@ describe('classifyResponse — canonical ErrorBody', () => {
|
||||
|
||||
describe('classifyResponse 403', () => {
|
||||
it('maps 403 to AccessDenied (exit 4 bucket)', async () => {
|
||||
const req403 = new Request('https://x/openapi/v1/apps/abc/export')
|
||||
const req403 = new Request('https://x/openapi/v1/apps/abc/dsl')
|
||||
const res403 = new Response(
|
||||
JSON.stringify({ code: 'unsupported_token_type', message: 'unsupported_token_type', status: 403 }),
|
||||
{ status: 403, headers: { 'content-type': 'application/json' } },
|
||||
@ -91,6 +91,31 @@ describe('classifyResponse 403', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyResponse 426', () => {
|
||||
it('maps 426 to VersionSkew (exit 6) and surfaces the server upgrade message', async () => {
|
||||
const body = {
|
||||
code: 'upgrade_required',
|
||||
message: 'difyctl 0.1.0 is no longer supported; upgrade to >= 0.2.0.',
|
||||
status: 426,
|
||||
hint: 'Upgrade difyctl: https://docs.dify.ai/en/cli/install',
|
||||
}
|
||||
|
||||
const err = await classified(426, body)
|
||||
|
||||
expect(err.code).toBe(ErrorCode.VersionSkew)
|
||||
expect(err.exit()).toBe(6)
|
||||
expect(err.message).toBe('difyctl 0.1.0 is no longer supported; upgrade to >= 0.2.0.')
|
||||
expect(err.serverError?.code).toBe('upgrade_required')
|
||||
})
|
||||
|
||||
it('426 with no parseable ErrorBody falls back to a version message', async () => {
|
||||
const err = await classified(426, 'not json')
|
||||
|
||||
expect(err.code).toBe(ErrorCode.VersionSkew)
|
||||
expect(err.message).toBe('client version no longer supported by the server')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyResponse — non-conforming bodies (no fallback by design)', () => {
|
||||
it('non-JSON body yields no serverError, classification by status', async () => {
|
||||
const err = await classified(502, '<html>bad gateway</html>')
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}]` }
|
||||
}
|
||||
|
||||
86
cli/src/version/enforce.test.ts
Normal file
86
cli/src/version/enforce.test.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { CompatStore } from '@/cache/compat-store'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { enforceDifyVersion } from './enforce'
|
||||
|
||||
// Injected build range in tests is __DIFYCTL_MIN_DIFY__=1.6.0 / MAX=1.7.0 (test/setup.ts):
|
||||
// 1.5.0 → too_old, 1.6.4 → compatible, 99.0.0 → too_new, '' → unknown.
|
||||
const HOST = 'https://cloud.dify.ai'
|
||||
|
||||
function fakeStore(fresh = false): CompatStore & { readonly marked: string[] } {
|
||||
const marked: string[] = []
|
||||
return {
|
||||
marked,
|
||||
isFreshCompatible: () => fresh,
|
||||
markCompatible: async (host) => {
|
||||
marked.push(host)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const server = (version: string): ServerVersionResponse => ({ version, edition: 'SELF_HOSTED' })
|
||||
|
||||
describe('enforceDifyVersion', () => {
|
||||
it('throws version_skew (exit 6) when the server is too old, and never caches it', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => server('1.5.0'))
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).rejects.toMatchObject({ code: ErrorCode.VersionSkew })
|
||||
expect(store.marked).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('passes and caches when the server is compatible', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => server('1.6.4'))
|
||||
|
||||
const res = await enforceDifyVersion(HOST, { store, probe })
|
||||
|
||||
expect(res?.version).toBe('1.6.4')
|
||||
expect(store.marked).toEqual([HOST])
|
||||
})
|
||||
|
||||
it('passes (soft, no throw) and caches when the server is too new', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => server('99.0.0'))
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeDefined()
|
||||
expect(store.marked).toEqual([HOST])
|
||||
})
|
||||
|
||||
it('skips the probe entirely when the host is fresh-compatible', async () => {
|
||||
const store = fakeStore(true)
|
||||
const probe = vi.fn(async () => server('1.5.0')) // would throw if it ran
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeUndefined()
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-probes despite a fresh cache when forceFresh is set', async () => {
|
||||
const store = fakeStore(true)
|
||||
const probe = vi.fn(async () => server('1.5.0'))
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe, forceFresh: true }))
|
||||
.rejects
|
||||
.toMatchObject({ code: ErrorCode.VersionSkew })
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails open (never blocks, never caches) when the probe errors', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => {
|
||||
throw new Error('net down')
|
||||
})
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeUndefined()
|
||||
expect(store.marked).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not block or cache on an unknown server version', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => server(''))
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeDefined()
|
||||
expect(store.marked).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
69
cli/src/version/enforce.ts
Normal file
69
cli/src/version/enforce.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { CompatStore } from '@/cache/compat-store'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta'
|
||||
import { loadCompatStore } from '@/cache/compat-store'
|
||||
import { newError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { createHttpClient } from '@/http/client'
|
||||
import { openAPIBase } from '@/util/host'
|
||||
import { difyCompat, evaluateCompat } from './compat'
|
||||
import { versionInfo } from './info'
|
||||
|
||||
export type ServerVersionProbe = (host: string) => Promise<ServerVersionResponse>
|
||||
|
||||
const UPGRADE_HINT
|
||||
= `upgrade the Dify server to >= ${difyCompat.minDify} `
|
||||
+ '(https://docs.dify.ai/en/getting-started/install-self-hosted)'
|
||||
|
||||
// /_version is unauthenticated; same timeout/no-retry budget as the auto-nudge probe.
|
||||
const defaultProbe: ServerVersionProbe = async (host) => {
|
||||
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
|
||||
return new MetaClient(http).serverVersion()
|
||||
}
|
||||
|
||||
export type EnforceOptions = {
|
||||
readonly probe?: ServerVersionProbe
|
||||
readonly store?: CompatStore
|
||||
readonly forceFresh?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard version gate for the client → server direction: refuse a Dify server older
|
||||
* than this difyctl requires (its removed paths would only 404 otherwise).
|
||||
*
|
||||
* Cached: a host recently confirmed compatible is not re-probed for COMPAT_TTL_MS.
|
||||
* Only "compatible" is cached, so a just-upgraded server clears a previous block at
|
||||
* once. Fails open on any probe error — a flaky network never blocks a command.
|
||||
* Returns the probed server version when it actually probed (skipped/failed → undefined),
|
||||
* so the caller can reuse it.
|
||||
*/
|
||||
export async function enforceDifyVersion(
|
||||
host: string,
|
||||
opts: EnforceOptions = {},
|
||||
): Promise<ServerVersionResponse | undefined> {
|
||||
const store = opts.store ?? await loadCompatStore()
|
||||
if (opts.forceFresh !== true && store.isFreshCompatible(host))
|
||||
return undefined
|
||||
|
||||
const probe = opts.probe ?? defaultProbe
|
||||
let server: ServerVersionResponse
|
||||
try {
|
||||
server = await probe(host)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const verdict = evaluateCompat(server.version)
|
||||
if (verdict.status === 'too_old') {
|
||||
throw newError(
|
||||
ErrorCode.VersionSkew,
|
||||
`Dify server ${server.version} is too old for difyctl ${versionInfo.version}: ${verdict.detail}`,
|
||||
).withHint(UPGRADE_HINT)
|
||||
}
|
||||
|
||||
if (verdict.status === 'compatible' || verdict.status === 'too_new')
|
||||
await store.markCompatible(host)
|
||||
|
||||
return server
|
||||
}
|
||||
@ -44,7 +44,9 @@ export async function maybeNudgeCompat(host: string, deps: NudgeDeps): Promise<v
|
||||
}
|
||||
|
||||
const verdict = evaluateCompat(server.version)
|
||||
if (verdict.status !== 'unsupported')
|
||||
// Only "too new" is a soft nudge here; "too old" is hard-failed up front by
|
||||
// enforceDifyVersion, so the command never reaches this path for it.
|
||||
if (verdict.status !== 'too_new')
|
||||
return
|
||||
|
||||
deps.emit(formatBanner(deps.clientVersion, server.version, deps.color === true))
|
||||
|
||||
@ -105,7 +105,7 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.status).toBe('compatible')
|
||||
})
|
||||
|
||||
it('returns unsupported when server version is out of range', async () => {
|
||||
it('returns too_new when server version is above range', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active(),
|
||||
@ -113,7 +113,7 @@ describe('runVersionProbe', () => {
|
||||
})
|
||||
|
||||
expect(report.server.reachable).toBe(true)
|
||||
expect(report.compat.status).toBe('unsupported')
|
||||
expect(report.compat.status).toBe('too_new')
|
||||
})
|
||||
|
||||
it('returns unknown when server returns an empty version string', async () => {
|
||||
|
||||
@ -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]',
|
||||
},
|
||||
}
|
||||
|
||||
@ -15,7 +15,8 @@ export type RenderOptions = {
|
||||
|
||||
const COMPAT_LABEL: Record<VersionReport['compat']['status'], string> = {
|
||||
compatible: 'ok',
|
||||
unsupported: 'incompatible',
|
||||
too_old: 'incompatible (server too old)',
|
||||
too_new: 'incompatible (server too new)',
|
||||
unknown: 'unknown',
|
||||
}
|
||||
|
||||
@ -50,7 +51,8 @@ export function renderVersionText(report: VersionReport, opts: RenderOptions = {
|
||||
lines.push('')
|
||||
|
||||
const verdictText = `Compatibility: ${COMPAT_LABEL[compat.status]} — ${compat.detail}`
|
||||
lines.push(compat.status === 'unsupported' ? c.yellow(verdictText) : verdictText)
|
||||
const incompatible = compat.status === 'too_old' || compat.status === 'too_new'
|
||||
lines.push(incompatible ? c.yellow(verdictText) : verdictText)
|
||||
|
||||
if (client.channel !== 'stable') {
|
||||
lines.push('')
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Single App Query (22 cases)
|
||||
*
|
||||
* Note: difyctl get app <id> queries a single app via GET /apps/<id>/describe?fields=info.
|
||||
* Note: difyctl get app <id> queries a single app via GET /apps/<id>?fields=info.
|
||||
* The response is returned in list-envelope format {page,limit,total,data:[...]}.
|
||||
*/
|
||||
|
||||
|
||||
24
cli/test/fixtures/dify-mock/server.test.ts
vendored
24
cli/test/fixtures/dify-mock/server.test.ts
vendored
@ -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)
|
||||
|
||||
25
cli/test/fixtures/dify-mock/server.ts
vendored
25
cli/test/fixtures/dify-mock/server.ts
vendored
@ -15,9 +15,9 @@ export type DifyMock = {
|
||||
scenario: Scenario
|
||||
setScenario: (s: Scenario) => void
|
||||
stop: () => Promise<void>
|
||||
/** Body of the most recent POST to /apps/:id/run */
|
||||
/** Body of the most recent POST to /apps/:id:run */
|
||||
lastRunBody: Record<string, unknown> | null
|
||||
/** Number of times POST /apps/:id/files/upload was called */
|
||||
/** Number of times POST /apps/:id/files was called */
|
||||
uploadCallCount: number
|
||||
/** Body of the most recent POST to /workspaces/:id/apps/imports */
|
||||
lastImportBody: Record<string, unknown> | null
|
||||
@ -251,7 +251,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/openapi/v1/apps/:id/describe', (c) => {
|
||||
app.get('/openapi/v1/apps/:id', (c) => {
|
||||
const id = c.req.param('id')
|
||||
const wsId = c.req.query('workspace_id')
|
||||
const fieldsRaw = c.req.query('fields') ?? ''
|
||||
@ -279,7 +279,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/openapi/v1/permitted-external-apps/:id/describe', (c) => {
|
||||
app.get('/openapi/v1/permitted-external-apps/:id', (c) => {
|
||||
const id = c.req.param('id')
|
||||
const fieldsRaw = c.req.query('fields') ?? ''
|
||||
const fields = fieldsRaw === '' ? [] : fieldsRaw.split(',').map(s => s.trim()).filter(s => s !== '')
|
||||
@ -307,7 +307,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/openapi/v1/apps/:id/export', (c) => {
|
||||
app.get('/openapi/v1/apps/:id/dsl', (c) => {
|
||||
const id = c.req.param('id')
|
||||
const found = APPS.find(a => a.id === id)
|
||||
if (found === undefined)
|
||||
@ -315,7 +315,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
return c.json({ data: DSL_YAML })
|
||||
})
|
||||
|
||||
app.get('/openapi/v1/apps/:id/check-dependencies', (c) => {
|
||||
app.get('/openapi/v1/apps/:id/dependencies:check', (c) => {
|
||||
const id = c.req.param('id')
|
||||
const found = APPS.find(a => a.id === id)
|
||||
if (found === undefined)
|
||||
@ -335,12 +335,13 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 })
|
||||
})
|
||||
|
||||
app.post('/openapi/v1/workspaces/:wsId/apps/imports/:importId/confirm', (c) => {
|
||||
app.post('/openapi/v1/workspaces/:wsId/apps/imports/:importId:confirm', (c) => {
|
||||
return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 })
|
||||
})
|
||||
|
||||
app.post('/openapi/v1/apps/:id/run', async (c) => {
|
||||
const id = c.req.param('id')
|
||||
app.post('/openapi/v1/apps/:id:run', async (c) => {
|
||||
// Hono drops the param adjacent to the `:run` literal; recover the app id from the path.
|
||||
const id = c.req.path.replace(/^.*\/apps\//, '').replace(/:run$/, '')
|
||||
const body = await c.req.json() as { query?: string, inputs?: unknown }
|
||||
if (state !== undefined)
|
||||
state.lastRunBody = body as Record<string, unknown>
|
||||
@ -400,7 +401,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
||||
})
|
||||
|
||||
app.post('/openapi/v1/apps/:id/files/upload', async (c) => {
|
||||
app.post('/openapi/v1/apps/:id/files', async (c) => {
|
||||
if (state !== undefined)
|
||||
state.uploadCallCount++
|
||||
const form = await c.req.formData()
|
||||
@ -421,11 +422,11 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
)
|
||||
})
|
||||
|
||||
app.post('/openapi/v1/apps/:id/tasks/:taskId/stop', (c) => {
|
||||
app.post('/openapi/v1/apps/:id/tasks/:taskId:stop', (c) => {
|
||||
return c.json({ result: 'success' })
|
||||
})
|
||||
|
||||
app.post('/openapi/v1/apps/:id/form/human_input/:formToken', (c) => {
|
||||
app.post('/openapi/v1/apps/:id/human-input-forms/:formToken:submit', (c) => {
|
||||
return c.json({})
|
||||
})
|
||||
|
||||
|
||||
@ -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 }))
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -27,7 +27,7 @@ export const zAppDescribeInfo = z.object({
|
||||
/**
|
||||
* AppDescribeQuery
|
||||
*
|
||||
* `?fields=` allow-list for GET /apps/<id>/describe.
|
||||
* `?fields=` allow-list for GET /apps/<id>.
|
||||
*
|
||||
* Empty / omitted → all blocks. Unknown member → ValidationError → 422.
|
||||
*/
|
||||
@ -47,7 +47,7 @@ export const zAppDescribeResponse = z.object({
|
||||
/**
|
||||
* AppDslExportQuery
|
||||
*
|
||||
* Query parameters for GET /apps/<app_id>/export.
|
||||
* Query parameters for GET /apps/<app_id>/dsl.
|
||||
*/
|
||||
export const zAppDslExportQuery = z.object({
|
||||
include_secret: z.boolean().optional().default(false),
|
||||
@ -254,7 +254,7 @@ export const zFileResponse = z.object({
|
||||
/**
|
||||
* FormSubmitResponse
|
||||
*
|
||||
* Empty 200 body for POST /apps/<id>/form/human_input/<token>. `extra='forbid'`
|
||||
* Empty 200 body for POST /apps/<id>/human-input-forms/<token>:submit. `extra='forbid'`
|
||||
* pins `additionalProperties: false` so the generated contract is an exact `{}` rather
|
||||
* than an under-annotated open object.
|
||||
*/
|
||||
@ -430,6 +430,7 @@ export const zOpenApiErrorCode = z.enum([
|
||||
'unknown',
|
||||
'unsupported_file_type',
|
||||
'unsupported_media_type',
|
||||
'upgrade_required',
|
||||
])
|
||||
|
||||
/**
|
||||
@ -581,7 +582,7 @@ export const zPermittedExternalAppsListQuery = z.object({
|
||||
/**
|
||||
* TaskStopResponse
|
||||
*
|
||||
* 200 body for POST /apps/<id>/tasks/<task_id>/stop. The handler always returns
|
||||
* 200 body for POST /apps/<id>/tasks/<task_id>:stop. The handler always returns
|
||||
* {"result": "success"}, so `result` is required (no default) — the generated contract
|
||||
* types it as a required `'success'` rather than an optional field.
|
||||
*/
|
||||
@ -740,33 +741,33 @@ export const zGetAppsQuery = z.object({
|
||||
*/
|
||||
export const zGetAppsResponse = zAppListResponse
|
||||
|
||||
export const zGetAppsByAppIdCheckDependenciesPath = z.object({
|
||||
export const zGetAppsByAppIdPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Dependencies checked
|
||||
*/
|
||||
export const zGetAppsByAppIdCheckDependenciesResponse = zCheckDependenciesResult
|
||||
|
||||
export const zGetAppsByAppIdDescribePath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetAppsByAppIdDescribeQuery = z.object({
|
||||
export const zGetAppsByAppIdQuery = z.object({
|
||||
fields: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* App description
|
||||
*/
|
||||
export const zGetAppsByAppIdDescribeResponse = zAppDescribeResponse
|
||||
export const zGetAppsByAppIdResponse = zAppDescribeResponse
|
||||
|
||||
export const zGetAppsByAppIdExportPath = z.object({
|
||||
export const zGetAppsByAppIdDependenciesCheckPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetAppsByAppIdExportQuery = z.object({
|
||||
/**
|
||||
* Dependencies checked
|
||||
*/
|
||||
export const zGetAppsByAppIdDependenciesCheckResponse = zCheckDependenciesResult
|
||||
|
||||
export const zGetAppsByAppIdDslPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetAppsByAppIdDslQuery = z.object({
|
||||
include_secret: z.boolean().optional().default(false),
|
||||
workflow_id: z.string().optional(),
|
||||
})
|
||||
@ -774,18 +775,18 @@ export const zGetAppsByAppIdExportQuery = z.object({
|
||||
/**
|
||||
* Export successful
|
||||
*/
|
||||
export const zGetAppsByAppIdExportResponse = zAppDslExportResponse
|
||||
export const zGetAppsByAppIdDslResponse = zAppDslExportResponse
|
||||
|
||||
export const zPostAppsByAppIdFilesUploadPath = z.object({
|
||||
export const zPostAppsByAppIdFilesPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* File uploaded successfully
|
||||
*/
|
||||
export const zPostAppsByAppIdFilesUploadResponse = zFileResponse
|
||||
export const zPostAppsByAppIdFilesResponse = zFileResponse
|
||||
|
||||
export const zGetAppsByAppIdFormHumanInputByFormTokenPath = z.object({
|
||||
export const zGetAppsByAppIdHumanInputFormsByFormTokenPath = z.object({
|
||||
app_id: z.string(),
|
||||
form_token: z.string(),
|
||||
})
|
||||
@ -793,11 +794,11 @@ export const zGetAppsByAppIdFormHumanInputByFormTokenPath = z.object({
|
||||
/**
|
||||
* Form definition
|
||||
*/
|
||||
export const zGetAppsByAppIdFormHumanInputByFormTokenResponse = zHumanInputFormDefinitionResponse
|
||||
export const zGetAppsByAppIdHumanInputFormsByFormTokenResponse = zHumanInputFormDefinitionResponse
|
||||
|
||||
export const zPostAppsByAppIdFormHumanInputByFormTokenBody = zHumanInputFormSubmitPayload
|
||||
export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitBody = zHumanInputFormSubmitPayload
|
||||
|
||||
export const zPostAppsByAppIdFormHumanInputByFormTokenPath = z.object({
|
||||
export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath = z.object({
|
||||
app_id: z.string(),
|
||||
form_token: z.string(),
|
||||
})
|
||||
@ -805,18 +806,7 @@ export const zPostAppsByAppIdFormHumanInputByFormTokenPath = z.object({
|
||||
/**
|
||||
* Form submitted
|
||||
*/
|
||||
export const zPostAppsByAppIdFormHumanInputByFormTokenResponse = zFormSubmitResponse
|
||||
|
||||
export const zPostAppsByAppIdRunBody = zAppRunRequest
|
||||
|
||||
export const zPostAppsByAppIdRunPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Run result (SSE stream)
|
||||
*/
|
||||
export const zPostAppsByAppIdRunResponse = zEventStreamResponse
|
||||
export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse = zFormSubmitResponse
|
||||
|
||||
export const zGetAppsByAppIdTasksByTaskIdEventsPath = z.object({
|
||||
app_id: z.string(),
|
||||
@ -843,6 +833,17 @@ export const zPostAppsByAppIdTasksByTaskIdStopPath = z.object({
|
||||
*/
|
||||
export const zPostAppsByAppIdTasksByTaskIdStopResponse = zTaskStopResponse
|
||||
|
||||
export const zPostAppsByAppIdRunBody = zAppRunRequest
|
||||
|
||||
export const zPostAppsByAppIdRunPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Run result (SSE stream)
|
||||
*/
|
||||
export const zPostAppsByAppIdRunResponse = zEventStreamResponse
|
||||
|
||||
export const zPostOauthDeviceApproveBody = zDeviceMutateRequest
|
||||
|
||||
/**
|
||||
@ -892,18 +893,18 @@ export const zGetPermittedExternalAppsQuery = z.object({
|
||||
*/
|
||||
export const zGetPermittedExternalAppsResponse = zPermittedExternalAppsListResponse
|
||||
|
||||
export const zGetPermittedExternalAppsByAppIdDescribePath = z.object({
|
||||
export const zGetPermittedExternalAppsByAppIdPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetPermittedExternalAppsByAppIdDescribeQuery = z.object({
|
||||
export const zGetPermittedExternalAppsByAppIdQuery = z.object({
|
||||
fields: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Permitted external app description
|
||||
*/
|
||||
export const zGetPermittedExternalAppsByAppIdDescribeResponse = zAppDescribeResponse
|
||||
export const zGetPermittedExternalAppsByAppIdResponse = zAppDescribeResponse
|
||||
|
||||
/**
|
||||
* Workspace list
|
||||
@ -975,9 +976,9 @@ export const zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath = z.object({
|
||||
*/
|
||||
export const zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse = zMemberActionResponse
|
||||
|
||||
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody = zMemberRoleUpdatePayload
|
||||
export const zPatchWorkspacesByWorkspaceIdMembersByMemberIdBody = zMemberRoleUpdatePayload
|
||||
|
||||
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath = z.object({
|
||||
export const zPatchWorkspacesByWorkspaceIdMembersByMemberIdPath = z.object({
|
||||
member_id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
})
|
||||
@ -985,7 +986,7 @@ export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath = z.object({
|
||||
/**
|
||||
* Role updated
|
||||
*/
|
||||
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse = zMemberActionResponse
|
||||
export const zPatchWorkspacesByWorkspaceIdMembersByMemberIdResponse = zMemberActionResponse
|
||||
|
||||
export const zPostWorkspacesByWorkspaceIdSwitchPath = z.object({
|
||||
workspace_id: z.string(),
|
||||
|
||||
@ -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()]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user