From dfc3bbe51356b0374a815edea5b4adfddda106bb Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:44:50 +0000 Subject: [PATCH] feat(openapi): publish the op catalog, per-mode run ops and server-built hints on /openapi/v1 (#42487) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/configs/packaging/pyproject.py | 16 - api/constants/__init__.py | 1 + api/controllers/API_SCHEMA_GUIDE.md | 14 + api/controllers/openapi/__init__.py | 38 ++- api/controllers/openapi/_catalog.py | 173 ++++++++++ api/controllers/openapi/_contract.py | 80 ++++- api/controllers/openapi/_errors.py | 15 +- api/controllers/openapi/_files.py | 89 +++++ api/controllers/openapi/_hints.py | 92 ++++++ api/controllers/openapi/_models.py | 150 +++++++-- api/controllers/openapi/_multipart.py | 68 ++++ api/controllers/openapi/_upload.py | 95 ++++++ api/controllers/openapi/_version_gate.py | 69 ---- api/controllers/openapi/account.py | 18 +- api/controllers/openapi/app_dsl.py | 71 +++- api/controllers/openapi/app_run.py | 311 +++++++++++++----- api/controllers/openapi/apps.py | 22 +- .../openapi/apps_permitted_external.py | 10 +- api/controllers/openapi/auth/pipelines.py | 24 +- api/controllers/openapi/auth/router.py | 2 +- api/controllers/openapi/auth/spec.py | 61 +++- api/controllers/openapi/files.py | 57 +--- api/controllers/openapi/human_input_form.py | 93 +++++- api/controllers/openapi/index.py | 8 + api/controllers/openapi/workflow_events.py | 26 +- api/controllers/openapi/workspaces.py | 57 +++- api/extensions/ext_blueprints.py | 7 +- api/libs/external_api.py | 5 + api/libs/flask_restx_compat.py | 19 +- api/openapi/markdown/openapi-openapi.md | 224 +++++++++++-- api/pyproject.toml | 6 +- .../controllers/openapi/conftest.py | 7 + .../controllers/openapi/test_app_run.py | 107 +++--- .../controllers/openapi/test_apps.py | 60 ++-- .../controllers/openapi/test_files.py | 14 +- .../controllers/openapi/test_workspaces.py | 21 +- .../openapi/auth/test_pipelines.py | 52 ++- .../controllers/openapi/auth/test_router.py | 21 +- .../controllers/openapi/conftest.py | 3 +- .../controllers/openapi/test_app_dsl.py | 45 ++- .../openapi/test_app_list_query.py | 6 + .../openapi/test_app_run_dispatch.py | 32 -- .../openapi/test_app_run_rate_limit.py | 12 +- .../openapi/test_app_run_streaming.py | 149 +++++++-- .../controllers/openapi/test_auth_matrix.py | 60 +++- .../controllers/openapi/test_catalog.py | 192 +++++++++++ .../controllers/openapi/test_contract.py | 25 +- .../controllers/openapi/test_cors.py | 31 +- .../openapi/test_error_contract.py | 6 +- .../controllers/openapi/test_files.py | 15 +- .../controllers/openapi/test_files_merge.py | 56 ++++ .../controllers/openapi/test_hints.py | 89 +++++ .../openapi/test_human_input_form.py | 56 +++- .../controllers/openapi/test_multipart.py | 68 ++++ .../controllers/openapi/test_version_gate.py | 100 ------ .../openapi/test_workflow_events_openapi.py | 41 ++- .../openapi/test_workspaces_members.py | 37 ++- api/tests/unit_tests/pyrefly.toml | 1 - api/uv.lock | 14 + cli/src/api/workspaces.test.ts | 12 +- cli/src/api/workspaces.ts | 7 +- .../commands/auth/devices/_shared/devices.ts | 14 +- cli/src/commands/get/app/run.ts | 6 +- .../commands/get/workspace/handlers.test.ts | 8 +- cli/src/commands/get/workspace/run.test.ts | 14 +- cli/src/commands/get/workspace/run.ts | 4 +- cli/src/commands/use/workspace/use.test.ts | 6 +- cli/src/commands/use/workspace/use.ts | 2 +- cli/src/http/client.test.ts | 16 +- cli/src/limit/limit.ts | 17 + cli/test/fixtures/dify-mock/server.test.ts | 16 +- cli/test/fixtures/dify-mock/server.ts | 26 +- .../generated/api/openapi/orpc.gen.ts | 238 ++++++++++---- .../generated/api/openapi/types.gen.ts | 236 +++++++++++-- .../generated/api/openapi/zod.gen.ts | 264 ++++++++++++--- 75 files changed, 3324 insertions(+), 803 deletions(-) create mode 100644 api/controllers/openapi/_catalog.py create mode 100644 api/controllers/openapi/_files.py create mode 100644 api/controllers/openapi/_hints.py create mode 100644 api/controllers/openapi/_multipart.py create mode 100644 api/controllers/openapi/_upload.py delete mode 100644 api/controllers/openapi/_version_gate.py delete mode 100644 api/tests/unit_tests/controllers/openapi/test_app_run_dispatch.py create mode 100644 api/tests/unit_tests/controllers/openapi/test_catalog.py create mode 100644 api/tests/unit_tests/controllers/openapi/test_files_merge.py create mode 100644 api/tests/unit_tests/controllers/openapi/test_hints.py create mode 100644 api/tests/unit_tests/controllers/openapi/test_multipart.py delete mode 100644 api/tests/unit_tests/controllers/openapi/test_version_gate.py diff --git a/api/configs/packaging/pyproject.py b/api/configs/packaging/pyproject.py index c21c02082c5..90b1ecba065 100644 --- a/api/configs/packaging/pyproject.py +++ b/api/configs/packaging/pyproject.py @@ -6,17 +6,6 @@ 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 @@ -26,8 +15,3 @@ class PyProjectTomlConfig(BaseSettings): description="configs in the project section of pyproject.toml", default=PyProjectConfig(), ) - - tool: ToolConfig = Field( - description="configs in the [tool.*] section of pyproject.toml", - default=ToolConfig(), - ) diff --git a/api/constants/__init__.py b/api/constants/__init__.py index 095cfbb8610..277e544d3cd 100644 --- a/api/constants/__init__.py +++ b/api/constants/__init__.py @@ -79,3 +79,4 @@ COOKIE_NAME_PASSPORT = "passport" HEADER_NAME_CSRF_TOKEN = "X-CSRF-Token" HEADER_NAME_APP_CODE = "X-App-Code" HEADER_NAME_PASSPORT = "X-App-Passport" +HEADER_NAME_CATALOG = "X-Dify-Catalog" diff --git a/api/controllers/API_SCHEMA_GUIDE.md b/api/controllers/API_SCHEMA_GUIDE.md index b9587b9f8f8..f772cf8eaee 100644 --- a/api/controllers/API_SCHEMA_GUIDE.md +++ b/api/controllers/API_SCHEMA_GUIDE.md @@ -267,3 +267,17 @@ also provide the source patch used to produce it; replace that patch with a clea The manual import, annotation review, build, and validation commands live in `tools/api-pipeline/README.md` in `dify-docs`. Regenerate this repository's OpenAPI Markdown and TypeScript/Zod contracts whenever their inputs change. + +## Catalog metadata on /openapi/v1 + +Every `@endpoint` on `/openapi/v1` declares `op` (dotted lowercase id), `kind` +(`object|list|sse|text|file`) and a one-line `summary`; they live on the route's +`EndpointSpec`. `controllers/openapi/_catalog.py` derives the rest (input schema, bind, +tags) from the route and its Pydantic models. Files are declared in the body model with +`UploadPart`; never add a separate files decorator or parse `request.files` in a handler. +Run ops are per app mode (`console_app..run`), each a self-contained route with the exact +body that mode takes. +Next steps for the caller go in a `hints` field (see `_hints.py`); `hints` is reserved. +Frozen after release: kind names, bind values, the pagination envelope, and any field the +CLI's fold table reads from an SSE event. Breaking input changes get a new op id; the old +one stays and is marked `deprecated=True`. diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index 681f93e49ba..7a0c3270362 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -1,16 +1,28 @@ +from typing import override + from flask import Blueprint from flask_restx import Namespace +from controllers.openapi._catalog import attach_catalog from controllers.openapi._errors import ErrorBody, OpenApiErrorCode, OpenApiErrorFormatter -from controllers.openapi._version_gate import attach_version_gate +from controllers.openapi._upload import describe_multipart_bodies 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) +attach_catalog(bp) -api = ExternalApi( + +class _OpenApi(ExternalApi): + """The surface's document also names the multipart form its file-bearing bodies accept.""" + + @override + def finish_document(self, document: dict[str, object]) -> dict[str, object]: + return describe_multipart_bodies(document) + + +api = _OpenApi( bp, version="1.0", title="OpenAPI", @@ -27,17 +39,20 @@ from controllers.common.schema import register_enum_models, register_response_sc from controllers.openapi._models import ( AccountPayload, AccountResponse, + AdvancedChatRunPayload, AppDescribeInfo, AppDescribeQuery, AppDescribeResponse, AppDslExportQuery, AppDslExportResponse, AppDslImportPayload, + AppDslImportResponse, AppInfo, AppListQuery, AppListResponse, AppListRow, - AppRunRequest, + ChatRunPayload, + CompletionRunPayload, DeviceCodeRequest, DeviceCodeResponse, DeviceLookupQuery, @@ -46,8 +61,10 @@ from controllers.openapi._models import ( DeviceMutateResponse, DevicePollRequest, DeviceTokenResponse, + FileUploadPayload, FormSubmitResponse, HealthResponse, + Hint, HumanInputFormDefinitionResponse, MemberActionResponse, MemberInvitePayload, @@ -57,6 +74,7 @@ from controllers.openapi._models import ( MemberResponse, MemberRoleUpdatePayload, MessageMetadata, + OpenApiFormSubmitPayload, PermittedExternalAppsListQuery, PermittedExternalAppsListResponse, RevokeResponse, @@ -67,7 +85,9 @@ from controllers.openapi._models import ( TaskStopResponse, UsageInfo, WorkflowRunData, + WorkflowRunPayload, WorkspaceDetailResponse, + WorkspaceListQuery, WorkspaceListResponse, WorkspacePayload, WorkspaceSummaryResponse, @@ -78,20 +98,26 @@ from services.entities.dsl_entities import CheckDependenciesResult register_schema_models( openapi_ns, + AdvancedChatRunPayload, AppDescribeQuery, AppDslImportPayload, AppDslExportQuery, AppListQuery, - AppRunRequest, + ChatRunPayload, + CompletionRunPayload, DeviceCodeRequest, DevicePollRequest, DeviceLookupQuery, DeviceMutateRequest, + FileUploadPayload, MemberInvitePayload, MemberListQuery, MemberRoleUpdatePayload, + OpenApiFormSubmitPayload, PermittedExternalAppsListQuery, SessionListQuery, + WorkflowRunPayload, + WorkspaceListQuery, ) register_response_schema_models( openapi_ns, @@ -100,6 +126,7 @@ register_response_schema_models( SimpleResultResponse, UsageInfo, MessageMetadata, + Hint, AppListRow, AppListResponse, AppInfo, @@ -107,6 +134,7 @@ register_response_schema_models( AppDescribeResponse, AppDslExportResponse, Import, + AppDslImportResponse, CheckDependenciesResult, WorkflowRunData, AccountPayload, diff --git a/api/controllers/openapi/_catalog.py b/api/controllers/openapi/_catalog.py new file mode 100644 index 00000000000..de31dedb8ff --- /dev/null +++ b/api/controllers/openapi/_catalog.py @@ -0,0 +1,173 @@ +"""The catalog: one JSON document describing every op on ``/openapi/v1``. + +Built once per Flask app from the url map plus the ``EndpointSpec`` each guarded view +carries as ``__spec__``: the flat input schema (path + query + body, ``$ref``s inlined) +and the ``bind`` table are derived here and nowhere else. Frozen to canonical bytes, +fingerprinted with sha256, served at ``GET /openapi/v1/_catalog``, and the fingerprint +rides on every response in ``X-Dify-Catalog``. A guarded request carries the fingerprint +of the catalog it was built from in the same header; any other value, or none, is refused +with 412 before a handler runs (``auth/pipelines``), so a request only ever acts on the +server's current op table. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +import re +from collections.abc import Iterator, Mapping, Sequence +from enum import StrEnum +from typing import Any, Final + +from flask import Blueprint, Flask, Response, current_app, request +from pydantic import BaseModel +from werkzeug.routing import Rule + +from configs import dify_config +from constants import HEADER_NAME_CATALOG +from controllers.openapi._upload import has_binary_leaf +from controllers.openapi.auth.spec import spec_of + +CATALOG_HEADER: Final = HEADER_NAME_CATALOG +_SURFACE: Final = "/openapi/v1" +# Trailing slash: `/openapi/v1beta/x` is a different surface, not this one. +_PREFIX: Final = f"{_SURFACE}/" +CATALOG_ROUTE: Final = "/_catalog" +CATALOG_PATH: Final = f"{_SURFACE}{CATALOG_ROUTE}" +_VERBS: Final = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) +_EXT_KEY: Final = "openapi_catalog" +_PLACEHOLDER_RE: Final = re.compile(r"<(?:\w+:)?(\w+)>") +_DEFS: Final = "$defs" +_REF: Final = "$ref" +_REF_PREFIX: Final = "#/$defs/" +_QUERY_METHODS: Final = frozenset({"GET", "DELETE"}) + +CatalogOp = dict[str, Any] + + +class Bind(StrEnum): + PATH = "path" + QUERY = "query" + BODY = "body" + FILE = "file" + + +def inline_refs(schema: Mapping[str, Any]) -> dict[str, Any]: + defs = schema.get(_DEFS) or {} + + def walk(node: Any) -> Any: + if isinstance(node, list): + return [walk(item) for item in node] + if not isinstance(node, Mapping): + return node + ref = node.get(_REF) + if isinstance(ref, str) and ref.startswith(_REF_PREFIX): + target = walk(defs[ref[len(_REF_PREFIX) :]]) + rest = {k: v for k, v in node.items() if k != _REF} + return {**target, **walk(rest)} + return {k: walk(v) for k, v in node.items() if k != _DEFS} + + return walk(schema) + + +def _model_properties(model: type[BaseModel] | None) -> tuple[dict[str, Any], list[str]]: + if model is None: + return {}, [] + flat = inline_refs(model.model_json_schema(mode="validation")) + return dict(flat.get("properties", {})), list(flat.get("required", [])) + + +def op_input_schema( + *, path_params: Sequence[str], query: type[BaseModel] | None, body: type[BaseModel] | None +) -> dict[str, Any]: + properties: dict[str, Any] = {name: {"type": "string"} for name in path_params} + required: list[str] = list(path_params) + for model in (query, body): + props, req = _model_properties(model) + clash = set(props) & set(properties) + if clash: + raise ValueError(f"parameter name clash across path/query/body: {sorted(clash)}") + properties.update(copy.deepcopy(props)) + required.extend(req) + return {"type": "object", "properties": properties, "required": required} + + +def derive_bind(*, method: str, path_params: Sequence[str], schema: Mapping[str, Any]) -> dict[str, Bind]: + default = Bind.QUERY if method.upper() in _QUERY_METHODS else Bind.BODY + bind: dict[str, Bind] = {} + for name, prop in schema.get("properties", {}).items(): + if name in path_params: + bind[name] = Bind.PATH + elif has_binary_leaf(prop): + bind[name] = Bind.FILE + else: + bind[name] = default + return bind + + +def _catalog_path(rule: str) -> str: + return _PLACEHOLDER_RE.sub(r"{\1}", rule) + + +def iter_handlers(app: Flask) -> Iterator[tuple[Rule, str, Any]]: + for rule in app.url_map.iter_rules(): + if not rule.rule.startswith(_PREFIX): + continue + view = app.view_functions.get(rule.endpoint) + if view is None or not hasattr(view, "view_class"): + continue + cls = view.view_class + for verb in sorted((rule.methods or set()) & _VERBS): + handler = getattr(cls, verb.lower()) # guard-ignore: no-new-getattr -- HTTP verb selects the handler + yield rule, verb, handler + + +def build_catalog(app: Flask) -> dict[str, Any]: + ops: dict[str, CatalogOp] = {} + for rule, verb, handler in iter_handlers(app): + spec = spec_of(handler) + if spec is None or not spec.allows(dify_config.DEPLOYMENT_EDITION): + continue + meta = spec.catalog + path_params = sorted(rule.arguments) + schema = op_input_schema(path_params=path_params, query=meta.query, body=meta.body) + ops[meta.op] = { + "summary": meta.summary, + "method": verb, + "path": _catalog_path(rule.rule), + "kind": meta.kind.value, + "input": schema, + "bind": derive_bind(method=verb, path_params=path_params, schema=schema), + "internal": meta.internal, + "deprecated": meta.deprecated, + "examples": [{"title": example.title, "input": dict(example.input)} for example in meta.examples], + } + return {"ops": ops} + + +def catalog_for(app: Flask) -> tuple[bytes, str]: + cached = app.extensions.get(_EXT_KEY) + if cached is None: + raw = json.dumps(build_catalog(app), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + cached = app.extensions[_EXT_KEY] = (raw, hashlib.sha256(raw).hexdigest()) + return cached + + +def current_catalog() -> tuple[bytes, str]: + """The catalog and fingerprint of the app handling this request.""" + return catalog_for(current_app._get_current_object()) # type: ignore[attr-defined] + + +def catalog_response() -> Response: + raw, _ = current_catalog() + return Response(raw, status=200, mimetype="application/json") + + +def attach_catalog(bp: Blueprint) -> None: + @bp.after_app_request + def _stamp_fingerprint(response: Response) -> Response: # pyright: ignore[reportUnusedFunction] + if request.path.startswith(_PREFIX): + _, fingerprint = current_catalog() + response.headers[CATALOG_HEADER] = fingerprint + return response diff --git a/api/controllers/openapi/_contract.py b/api/controllers/openapi/_contract.py index eb17bacb085..97497b2e305 100644 --- a/api/controllers/openapi/_contract.py +++ b/api/controllers/openapi/_contract.py @@ -5,6 +5,12 @@ reference, so the advertised and enforced contracts can't drift. ``view.__handler__`` as the one documented test seam. ``returns`` is still used bare by the unauthenticated ``index.py`` probes, which have no auth layer to compose with. + +``endpoint()`` also builds the route's ``EndpointSpec`` (``auth/spec.py``), which +carries the catalog fields ``op``, ``kind``, ``summary``, ``internal`` and +``deprecated`` that ``_catalog.py`` reads to describe ``/openapi/v1``. A ``list`` +op gets ``paginated`` as well, which fills the next-page hint on a bare +``PaginationEnvelope`` result. """ from __future__ import annotations @@ -12,32 +18,64 @@ from __future__ import annotations import inspect from collections.abc import Callable, Sequence from functools import wraps -from typing import Any, cast +from typing import Any, Final, cast -from flask import request from flask_restx import abort from pydantic import BaseModel, ValidationError from controllers.common.schema import query_params_from_model, query_params_from_request from controllers.openapi import openapi_ns from controllers.openapi._errors import ErrorBody +from controllers.openapi._hints import next_page_hint +from controllers.openapi._models import PaginationEnvelope +from controllers.openapi._multipart import body_from_request +from controllers.openapi._upload import file_fields from controllers.openapi.auth.requirements import Requirement from controllers.openapi.auth.router import subject_router -from controllers.openapi.auth.spec import EndpointSpec +from controllers.openapi.auth.spec import CatalogMeta, EndpointSpec, Example, Kind from enums import DeploymentEdition +__all__ = ["Example", "Kind", "accepts", "endpoint", "op_of", "paginated", "returns"] + +_INJECTED_KWARGS: Final = frozenset({"ctx", "query", "body"}) + + +def paginated(op: str) -> Callable: + """Fill the ``Next page`` hint on a bare ``PaginationEnvelope`` result (one with no hints + of its own). Sits inside ``accepts`` so it sees the validated ``query`` and the path + kwargs the hint echoes; only this layer knows both those and the op id. + """ + + def decorator(view: Callable) -> Callable: + @wraps(view) + def wrapper(*args: Any, **kwargs: Any) -> Any: + result = view(*args, **kwargs) + if not isinstance(result, PaginationEnvelope) or result.hints: + return result + path_args = {name: value for name, value in kwargs.items() if name not in _INJECTED_KWARGS} + hint = next_page_hint(op=op, path_args=path_args, query=kwargs.get("query"), envelope=result) + if hint is not None: + result.hints = [hint] + return result + + return wrapper + + return decorator + def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | None = None) -> Callable: """Validate ``query``/``body`` against the models and inject them as keyword-only kwargs.""" def decorator(view: Callable) -> Callable: + body_file_fields = file_fields(body) if body is not None else frozenset() + @wraps(view) def wrapper(*args: Any, **kwargs: Any) -> Any: try: if query is not None: kwargs["query"] = query_params_from_request(query) if body is not None: - kwargs["body"] = body.model_validate(request.get_json(silent=True) or {}) + kwargs["body"] = body.model_validate(body_from_request(file_fields=body_file_fields)) except ValidationError as exc: # Sanitized 422 — no pydantic `url` (version) or `input` (user payload) leak. abort( @@ -100,13 +138,25 @@ def _normalize_returns(returns: ReturnSpec | Sequence[ReturnSpec] | None) -> tup return tuple(cast(Sequence[ReturnSpec], returns)) # pyrefly: ignore[redundant-cast] +def op_of(view: Any) -> str: + """The op id a route declared on ``endpoint``; a hint targeting that route reads it here.""" + + return view.__spec__.catalog.op + + def endpoint( *, + op: str, + kind: Kind, + summary: str, requirements: Sequence[Requirement] = (), query: type[BaseModel] | None = None, body: type[BaseModel] | None = None, returns: ReturnSpec | Sequence[ReturnSpec] | None = None, edition: frozenset[DeploymentEdition] | None = None, + internal: bool = False, + deprecated: bool = False, + examples: Sequence[Example] = (), ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """The one seam a route attaches to for auth, request validation and response serialisation — auth, then ``accepts``, then ``returns``. Exposes @@ -118,12 +168,30 @@ def endpoint( order and gets the identical nesting back — first entry outermost, last entry closest to the handler — including the N-times ``"default"`` error registration that stacking N ``@returns`` already produces. + + ``op``/``kind``/``summary`` feed the catalog (see ``_catalog.py``); ``internal`` + hides the op from the CLI's default listing; ``deprecated`` marks it as kept + for compatibility only; ``examples`` are complete inputs the catalog shows + next to the schema. """ requirements = tuple(requirements) for requirement in requirements: if not isinstance(requirement, Requirement): raise TypeError(f"requirements must be instances of Requirement, not {requirement!r}") - spec = EndpointSpec(requirements=requirements, edition=edition) + spec = EndpointSpec( + requirements=requirements, + edition=edition, + catalog=CatalogMeta( + op=op, + kind=kind, + summary=summary, + query=query, + body=body, + internal=internal, + deprecated=deprecated, + examples=tuple(examples), + ), + ) return_specs = _normalize_returns(returns) def decorator(view: Callable[..., Any]) -> Callable[..., Any]: @@ -131,6 +199,8 @@ def endpoint( raise TypeError(f"{view.__qualname__} must declare a 'ctx' parameter") decorated = view + if kind is Kind.LIST: + decorated = paginated(op)(decorated) if query is not None or body is not None: decorated = accepts(query=query, body=body)(decorated) for return_spec in reversed(return_specs): diff --git a/api/controllers/openapi/_errors.py b/api/controllers/openapi/_errors.py index a53a379d948..c1b1e54c188 100644 --- a/api/controllers/openapi/_errors.py +++ b/api/controllers/openapi/_errors.py @@ -45,7 +45,6 @@ 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) @@ -67,6 +66,7 @@ class OpenApiErrorCode(StrEnum): MEMBER_LICENSE_EXCEEDED = "member_license_exceeded" HUMAN_INPUT_FORM_NOT_FOUND = "form_not_found" RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch" + CATALOG_STALE = "catalog_stale" TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE = "trigger_workflow_service_mode_unavailable" @@ -257,3 +257,16 @@ class RecipientSurfaceMismatch(OpenApiError): # noqa: N818 error_code = OpenApiErrorCode.RECIPIENT_SURFACE_MISMATCH description = "This form's recipient can't be submitted via the OpenAPI surface." hint = "Action it through its channel (web app or console)." + + +class CatalogStale(OpenApiError): # noqa: N818 + code = 412 + error_code = OpenApiErrorCode.CATALOG_STALE + description = "The request was built from a catalog that is not this server's current catalog." + hint = "GET /openapi/v1/_catalog, rebuild the request from it, and send its sha256 in X-Dify-Catalog." + + +class InvalidFilePart(OpenApiError): # noqa: N818 + code = 422 + error_code = OpenApiErrorCode.INVALID_PARAM + description = "A request part could not be used" diff --git a/api/controllers/openapi/_files.py b/api/controllers/openapi/_files.py new file mode 100644 index 00000000000..f5481ec74de --- /dev/null +++ b/api/controllers/openapi/_files.py @@ -0,0 +1,89 @@ +"""Turn ``UploadPart`` values into the file mappings the core expects. + +The part name says where a file goes; nothing here reads the app. ``files[]`` +becomes ``inputs[]``, and the ``[]`` list marker on the part name is what makes +a list. The core validates the result against the variable's own config, exactly as +it does for a hand-written mapping. Remote URLs and reused upload ids are sent as +mappings inside ``inputs``, the same as on every other Dify API. + +Every mapping carries a ``type``: ``build_from_mapping`` validates ``mapping["type"] +or FileType.CUSTOM`` against the variable's upload config, and ``custom`` is refused +by every normally configured file variable. The type comes from the core's own +``standardize_file_type`` over the stored upload row. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from sqlalchemy.orm import Session +from werkzeug.datastructures import FileStorage +from werkzeug.exceptions import BadRequest + +import services +from controllers.common.errors import BlockedFileExtensionError, FileTooLargeError, UnsupportedFileTypeError +from controllers.openapi._errors import FilenameNotExists, InvalidFilePart +from extensions.ext_application_services import application_services +from graphon.file import FileTransferMethod, standardize_file_type +from models.model import UploadFile + +FileMapping = dict[str, Any] + + +def end_read_transaction(session: Session) -> None: + """Close the transaction the router's loaders opened before a handler writes to object storage. + + `api/AGENTS.md` keeps external I/O out of open transactions. The router's sessions do + not expire on commit, so the rows the requirements loaded stay readable afterwards, + and the service that runs next begins its own transaction on the same session. + """ + session.commit() + + +def upload(part: FileStorage, caller: Any) -> UploadFile: + if not part.mimetype: + raise UnsupportedFileTypeError() + if not part.filename: + raise FilenameNotExists() + try: + return application_services().files.upload_file( + filename=part.filename, + content=part.stream.read(), + mimetype=part.mimetype, + user=caller, + ) + except services.errors.file.FileTooLargeError as exc: + raise FileTooLargeError(exc.description) from exc + except services.errors.file.UnsupportedFileTypeError as exc: + raise UnsupportedFileTypeError() from exc + except services.errors.file.BlockedFileExtensionError as exc: + raise BlockedFileExtensionError(exc.description) from exc + except ValueError as exc: + raise BadRequest(str(exc)) from exc + + +def _mapping(part: FileStorage, caller: Any) -> FileMapping: + uploaded = upload(part, caller) + return { + "transfer_method": FileTransferMethod.LOCAL_FILE, + "upload_file_id": str(uploaded.id), + "type": standardize_file_type(extension="." + uploaded.extension, mime_type=uploaded.mime_type), + } + + +def materialize(value: FileStorage | list[FileStorage], caller: Any) -> FileMapping | list[FileMapping]: + if isinstance(value, list): + return [_mapping(part, caller) for part in value] + return _mapping(value, caller) + + +def merge_files( + inputs: Mapping[str, Any], files: Mapping[str, FileStorage | list[FileStorage]] | None, caller: Any +) -> dict[str, Any]: + merged = dict(inputs) + for name, value in (files or {}).items(): + if name in merged: + raise InvalidFilePart(f"files[{name}]: {name} is also given in inputs") + merged[name] = materialize(value, caller) + return merged diff --git a/api/controllers/openapi/_hints.py b/api/controllers/openapi/_hints.py new file mode 100644 index 00000000000..c31ccc506d7 --- /dev/null +++ b/api/controllers/openapi/_hints.py @@ -0,0 +1,92 @@ +"""Server-built next steps (`hints`) for the /openapi/v1 surface. + +A hint is a normal response field, and the handler that owns the target op composes it. +This module holds only the two mechanics a handler cannot do inline: the next page of a +list (the contract layer knows the op id and raw query) and adding hints to one kind of +SSE event in a stream the handler only holds as a generator. Neither knows any op id; +the caller passes it. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Callable, Generator, Iterable, Mapping +from typing import Any, Final, Protocol, runtime_checkable + +from pydantic import BaseModel, ValidationError + +from controllers.openapi._models import Hint, Hinted, PageQuery, PaginationEnvelope + +logger = logging.getLogger(__name__) + +# The core's SSE framing and its event key; it exports no name for either. +_DATA_PREFIX: Final = "data: " +_EVENT_FIELD: Final = "event" + +HintBuilder = Callable[[Mapping[str, Any]], list[Hint]] + + +@runtime_checkable +class _ClosableStream(Protocol): + def close(self) -> None: ... + + +def next_page_hint( + *, op: str, path_args: Mapping[str, Any], query: BaseModel | None, envelope: PaginationEnvelope[Any] +) -> Hint | None: + if not envelope.has_more: + return None + params: dict[str, Any] = dict(path_args) + if query is not None: + params |= query.model_dump(exclude_none=True) + params |= PageQuery(page=envelope.page + 1, limit=envelope.limit).model_dump() + return Hint(summary="Next page", op=op, input=params) + + +def _wanted_event(chunk: str, event: str) -> dict[str, Any] | None: + """The parsed event when `chunk` is a `data:` frame of that kind, else None. + + Only chunks that can be the wanted event are parsed — every other chunk of a long run + passes on a substring test. A frame that is not JSON, or not an object, is not the + wanted event either; the stream owns its bytes and this layer never breaks it. + """ + if not chunk.startswith(_DATA_PREFIX) or event not in chunk: + return None + try: + parsed = json.loads(chunk[len(_DATA_PREFIX) :]) + except ValueError: + return None + if not isinstance(parsed, dict) or parsed.get(_EVENT_FIELD) != event: + return None + return parsed + + +def attach_stream_hints(events: Iterable[str], *, event: str, build: HintBuilder) -> Generator[str, None, None]: + """Yield the source SSE chunks, adding a top-level `hints` list to every `event` that `build` hints. + + `event: ping` chunks, other event kinds, events `build` returns nothing for, and events + `build` cannot read are yielded as the original string so the wire bytes stay identical. + Closing this generator closes the source (the run stream is a `RateLimitGenerator` that + releases its slot on close). + """ + try: + for chunk in events: + parsed = _wanted_event(chunk, event) + if parsed is None: + yield chunk + continue + try: + hints = build(parsed) + except ValidationError: + logger.warning("%s event without the fields its hint needs; passed through.", event, exc_info=True) + yield chunk + continue + if not hints: + yield chunk + continue + parsed |= Hinted(hints=hints).model_dump(exclude_none=True) + yield f"{_DATA_PREFIX}{json.dumps(parsed, ensure_ascii=False)}\n\n" + finally: + if isinstance(events, _ClosableStream): + events.close() diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index b54bf239789..0cc37e77da8 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -7,13 +7,16 @@ from typing import Any, Final, Literal, Self from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from controllers.common.human_input import HumanInputFormSubmitPayload +from controllers.openapi._upload import UploadPart, UploadParts from enums import DeploymentEdition from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, uuid_value from libs.oauth_bearer import SubjectType from models.model import AppMode +from services.app_dsl_service import Import # Server-side cap on `limit` query param for /openapi/v1/* list endpoints. -MAX_PAGE_LIMIT = 200 +MAX_PAGE_LIMIT = 100 class SupportedAppType(StrEnum): @@ -51,7 +54,31 @@ class MessageMetadata(BaseModel): retriever_resources: list[dict[str, Any]] = [] -class PaginationEnvelope[T](BaseModel): +class Hint(BaseModel): + """A next step the caller can hand straight to `call --input `.""" + + summary: str + op: str + input: dict[str, Any] = Field(description="Ready-to-send input for `op`; unknown values are null") + form: list[dict[str, Any]] | None = Field( + default=None, description="Form fields behind `input.inputs`, copied from the pausing event" + ) + + +class Hinted(BaseModel): + """The one place a response carries server-built next steps; `hints` is reserved on every op input.""" + + hints: list[Hint] = Field(default_factory=list, description="Next steps the caller can take") + + +class PageQuery(BaseModel): + """The two query parameters every list op takes; the next-page hint is built from this model.""" + + page: int = Field(1, ge=1) + limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT) + + +class PaginationEnvelope[T](Hinted): """The one shape every paginated list on this surface answers with.""" page: int @@ -64,6 +91,12 @@ class PaginationEnvelope[T](BaseModel): def build(cls, *, page: int, limit: int, total: int, items: list[T]) -> Self: return cls(page=page, limit=limit, total=total, has_more=page * limit < total, data=items) + @classmethod + def page_of(cls, items: list[T], *, query: PageQuery) -> Self: + """The page `query` asks for, cut from a list the service returned whole.""" + start = (query.page - 1) * query.limit + return cls.build(page=query.page, limit=query.limit, total=len(items), items=items[start : start + query.limit]) + class AppListRow(BaseModel): id: str @@ -162,12 +195,11 @@ class SessionListResponse(PaginationEnvelope[SessionRow]): pass -class SessionListQuery(BaseModel): +class SessionListQuery(PageQuery): """Pagination for GET /account/sessions. Strict (extra='forbid').""" model_config = ConfigDict(extra="forbid") - page: int = Field(1, ge=1) limit: int = Field(100, ge=1, le=MAX_PAGE_LIMIT) @@ -183,8 +215,14 @@ class WorkspaceSummaryResponse(BaseModel): current: bool -class WorkspaceListResponse(BaseModel): - workspaces: list[WorkspaceSummaryResponse] +class WorkspaceListResponse(PaginationEnvelope[WorkspaceSummaryResponse]): + pass + + +class WorkspaceListQuery(PageQuery): + """Strict (extra='forbid').""" + + model_config = ConfigDict(extra="forbid") class WorkspaceDetailResponse(BaseModel): @@ -263,24 +301,17 @@ class AppDescribeQuery(BaseModel): return members -class AppListQuery(BaseModel): +class AppListQuery(PageQuery): """mode is a closed enum of listable app types.""" workspace_id: UUIDStr - page: int = Field(1, ge=1) - limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT) mode: SupportedAppType | None = None name: str | None = Field(None, max_length=200) -class AppRunRequest(BaseModel): - inputs: dict[str, Any] - query: str | None = None - files: list[dict[str, Any]] | None = Field(default=None) - conversation_id: UUIDStrOrEmpty | None = None - auto_generate_name: bool = True - workflow_id: str | None = None - workspace_id: UUIDStrOrEmpty | None = None +class _ConversationFields(BaseModel): + conversation_id: UUIDStrOrEmpty | None = Field(default=None, description="Continue an existing conversation") + auto_generate_name: bool = Field(default=True, description="Let the server name a new conversation") @field_validator("conversation_id", mode="before") @classmethod @@ -295,6 +326,69 @@ class AppRunRequest(BaseModel): raise ValueError("conversation_id must be a valid UUID") from exc +class _WorkflowVersionFields(BaseModel): + workflow_id: str | None = Field(default=None, description="Pin a published workflow version") + + +class RunPayloadBase(BaseModel): + """What every run takes; each mode's payload adds its own fields and forbids the rest.""" + + inputs: dict[str, Any] = Field( + description=( + "Variables declared by the app. The exact shape is per app: read `input_schema` from " + "console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, " + "or a local file in `files`, not both." + ) + ) + files: UploadParts | None = Field( + default=None, + description=( + "Local files keyed by the app's file variable name; the server uploads each one and sets " + "`inputs[]`. Send a list (part name `files[][]`) for a file-list variable" + ), + ) + attachments: list[UploadPart] | None = Field( + default=None, description="Local files attached to the run itself (the app's `sys.files`), not to a variable" + ) + workspace_id: UUIDStrOrEmpty | None = Field(default=None, description="Workspace that owns the app") + + +class WorkflowRunPayload(RunPayloadBase, _WorkflowVersionFields): + model_config = ConfigDict(extra="forbid") + + +class ChatRunPayload(RunPayloadBase, _ConversationFields): + model_config = ConfigDict(extra="forbid") + + query: str = Field(description="User message") + + @field_validator("query") + @classmethod + def _non_blank(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("query must not be blank") + return value + + +class AdvancedChatRunPayload(ChatRunPayload, _WorkflowVersionFields): + """A chat run against an advanced-chat (chatflow) app, which can also pin a workflow version.""" + + model_config = ConfigDict(extra="forbid") + + +class CompletionRunPayload(RunPayloadBase): + model_config = ConfigDict(extra="forbid") + + query: str = Field(default="", description="Prompt text; most completion apps take their input through `inputs`") + + +class FileUploadPayload(BaseModel): + file: UploadPart = Field( + description="The file to upload; its id can then be used in an app run's file variables", + ) + + class DeviceCodeRequest(BaseModel): client_id: str device_label: str @@ -313,13 +407,11 @@ class DeviceMutateRequest(BaseModel): user_code: str -class PermittedExternalAppsListQuery(BaseModel): +class PermittedExternalAppsListQuery(PageQuery): """Strict (extra='forbid').""" model_config = ConfigDict(extra="forbid") - page: int = Field(1, ge=1) - limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT) mode: SupportedAppType | None = None name: str | None = Field(None, max_length=200) @@ -361,14 +453,11 @@ class MemberListResponse(PaginationEnvelope[MemberResponse]): pass -class MemberListQuery(BaseModel): +class MemberListQuery(PageQuery): """Strict (extra='forbid').""" model_config = ConfigDict(extra="forbid") - page: int = Field(1, ge=1) - limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT) - class MemberInvitePayload(BaseModel): model_config = ConfigDict(extra="forbid") @@ -443,6 +532,10 @@ class AppDslExportResponse(BaseModel): data: str = Field(..., description="DSL YAML string") +class AppDslImportResponse(Import, Hinted): + """`Import` plus the server-built next step for a pending import.""" + + class FormSubmitResponse(BaseModel): """Empty 200 body for POST /apps//human-input-forms/:submit. `extra='forbid'` pins `additionalProperties: false` so the generated contract is an exact `{}` rather @@ -451,6 +544,15 @@ class FormSubmitResponse(BaseModel): model_config = ConfigDict(extra="forbid") +class OpenApiFormSubmitPayload(HumanInputFormSubmitPayload): + """The console payload plus local file parts; `_files.merge_files` sets them on `inputs`.""" + + files: UploadParts | None = Field( + default=None, + description="Local files keyed by the form's file input name, same convention as the run ops' `files`", + ) + + class HumanInputFormDefinitionResponse(BaseModel): form_content: str inputs: list[dict[str, Any]] = Field(default_factory=list) diff --git a/api/controllers/openapi/_multipart.py b/api/controllers/openapi/_multipart.py new file mode 100644 index 00000000000..b994e59a6ad --- /dev/null +++ b/api/controllers/openapi/_multipart.py @@ -0,0 +1,68 @@ +"""One parse layer for request bodies on ``/openapi/v1``. + +JSON requests validate as-is. Multipart requests become the same dict shape: every +non-file part is JSON text under its own name, every file part is named for the body +field it fills and lands there as a ``FileStorage``. The same Pydantic model validates +both; handlers never see the difference. + +File part names are the field, optionally one key, optionally a ``[]`` list marker:: + + file -> body["file"] = part + files[doc] -> body["files"]["doc"] = part + files[pages][] -> body["files"]["pages"] = [part, ...] + attachments[] -> body["attachments"] = [part, ...] + +Only the fields the model declares with ``UploadPart`` may carry file parts, so a +stray part can never land in a plain JSON field. +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Collection +from typing import Any, Final + +from flask import request +from werkzeug.datastructures import FileStorage + +from controllers.openapi._errors import InvalidFilePart + +LIST_SUFFIX: Final = "[]" +_PART_NAME_RE: Final = re.compile(r"^(?P[^\[\]]+)(?:\[(?P[^\[\]]+)\])?(?P\[\])?$") +_SHAPE_HINT: Final = "file parts are named field, field[], field[key] or field[key][]" + + +def _json_part(name: str, raw: str, *, file_fields: Collection[str]) -> Any: + if name in file_fields: + raise InvalidFilePart(f"{name}: takes file parts, not JSON text") + try: + return json.loads(raw) + except ValueError as exc: + raise InvalidFilePart(f"{name}: part must be JSON text") from exc + + +def _place(body: dict[str, Any], name: str, parts: list[FileStorage], *, file_fields: Collection[str]) -> None: + match = _PART_NAME_RE.fullmatch(name) + if match is None: + raise InvalidFilePart(f"{name}: {_SHAPE_HINT}") + field, key, many = match["field"], match["key"], match["many"] is not None + if field not in file_fields: + raise InvalidFilePart(f"{name}: {field} does not take a file") + if not many and len(parts) != 1: + raise InvalidFilePart(f"{name}: one part expected; name it {name}{LIST_SUFFIX} for a list") + value: FileStorage | list[FileStorage] = parts if many else parts[0] + target = body if key is None else body.setdefault(field, {}) + leaf = field if key is None else key + if leaf in target: + raise InvalidFilePart(f"{name}: given more than once") + target[leaf] = value + + +def body_from_request(*, file_fields: Collection[str] = ()) -> dict[str, Any]: + if request.mimetype != "multipart/form-data": + return request.get_json(silent=True) or {} + body: dict[str, Any] = {name: _json_part(name, raw, file_fields=file_fields) for name, raw in request.form.items()} + for name in request.files: + _place(body, name, request.files.getlist(name), file_fields=file_fields) + return body diff --git a/api/controllers/openapi/_upload.py b/api/controllers/openapi/_upload.py new file mode 100644 index 00000000000..bf363fea858 --- /dev/null +++ b/api/controllers/openapi/_upload.py @@ -0,0 +1,95 @@ +"""``UploadPart``: a file part as a Pydantic type, so request models stay the single +source of truth for files too. Validates to a werkzeug ``FileStorage`` at runtime and +renders as ``{"type": "string", "format": "binary"}`` in JSON Schema (the OpenAPI file +convention the CLI's bind derivation keys on). + +``file_fields`` reads that convention back off a model: the body fields a multipart +request may carry file parts for. The catalog's ``bind`` and the request parser agree +by construction because both ask this one question. ``describe_multipart_bodies`` asks +it of the exported OpenAPI document, so the contract advertises the multipart form +``_multipart.py`` accepts. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Annotated, Any, Final + +from pydantic import BaseModel, GetCoreSchemaHandler, GetJsonSchemaHandler +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import core_schema +from werkzeug.datastructures import FileStorage + + +class _UploadPartType: + @classmethod + def __get_pydantic_core_schema__(cls, source: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + return core_schema.is_instance_schema(FileStorage) + + @classmethod + def __get_pydantic_json_schema__( + cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + return {"type": "string", "format": "binary"} + + +UploadPart = Annotated[FileStorage, _UploadPartType()] +UploadParts = dict[str, UploadPart | list[UploadPart]] + + +def has_binary_leaf(node: Any) -> bool: + if isinstance(node, list): + return any(has_binary_leaf(item) for item in node) + if not isinstance(node, Mapping): + return False + if node.get("type") == "string" and node.get("format") == "binary": + return True + return any(has_binary_leaf(value) for value in node.values()) + + +def file_fields(model: type[BaseModel]) -> frozenset[str]: + schema = model.model_json_schema() + return frozenset(name for name, prop in schema.get("properties", {}).items() if has_binary_leaf(prop)) + + +_JSON_MEDIA: Final = "application/json" +_MULTIPART_MEDIA: Final = "multipart/form-data" + + +def _schemas_of(document: Mapping[str, Any]) -> Mapping[str, Any]: + return document.get("definitions") or document.get("components", {}).get("schemas", {}) + + +def describe_multipart_bodies(document: dict[str, Any]) -> dict[str, Any]: + """Add ``multipart/form-data`` to every request body whose schema carries file fields. + + Flask-RESTX documents a Pydantic body as JSON only. On this surface a body with + ``UploadPart`` fields is also accepted as multipart: file fields as file parts, every + other field as JSON text under its own part name. A body of nothing but files is + multipart only, since JSON cannot carry it at all. + """ + schemas = _schemas_of(document) + for item in document.get("paths", {}).values(): + for operation in item.values(): + if not isinstance(operation, Mapping): + continue + content: dict[str, Any] = operation.get("requestBody", {}).get("content", {}) + json_media = content.get(_JSON_MEDIA) + if not json_media: + continue + schema = schemas.get(json_media.get("schema", {}).get("$ref", "").rsplit("/", 1)[-1], {}) + properties = schema.get("properties", {}) + files = {name for name, prop in properties.items() if has_binary_leaf(prop)} + if not files: + continue + multipart: dict[str, Any] = {"schema": dict(json_media["schema"])} + encoding = {name: {"contentType": _JSON_MEDIA} for name in properties if name not in files} + if encoding: + multipart["encoding"] = encoding + else: + del content[_JSON_MEDIA] + content[_MULTIPART_MEDIA] = multipart + return document + + +__all__ = ["UploadPart", "UploadParts", "describe_multipart_bodies", "file_fields", "has_binary_leaf"] diff --git a/api/controllers/openapi/_version_gate.py b/api/controllers/openapi/_version_gate.py deleted file mode 100644 index 785617fd950..00000000000 --- a/api/controllers/openapi/_version_gate.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Version gate: reject outdated difyctl clients on /openapi/v1 with HTTP 426. - -difyctl and the ``/openapi/v1`` surface ship in lockstep. A breaking path change -(resource-oriented paths) means an outdated difyctl would call removed paths and -get a bare 404; this gate returns ``426 Upgrade Required`` with an upgrade hint -instead. -""" - -from __future__ import annotations - -import re -from typing import Final - -from flask import Blueprint, Response, request -from packaging.version import InvalidVersion, Version - -from configs import dify_config -from controllers.openapi._errors import ErrorBody, OpenApiErrorCode - -_UPGRADE_HINT: Final = "Upgrade difyctl: https://docs.dify.ai/en/cli/install" - -# difyctl sends `User-Agent: difyctl/ (; ; )`. -_DIFYCTL_UA_RE = re.compile(r"^difyctl/(\d+\.\d+\.\d+(?:-[\w.]+)?)") - -_PREFIX: Final = "/openapi/v1/" - -# Paths a too-old client must still reach to discover that it is outdated. -_ALLOWLIST: Final = frozenset({"/openapi/v1/_version", "/openapi/v1/_health"}) - - -def _upgrade_required_response(client_version: str, min_version: str) -> Response: - body = ErrorBody( - code=OpenApiErrorCode.UPGRADE_REQUIRED, - message=f"difyctl {client_version} is no longer supported; upgrade to >= {min_version}.", - status=426, - hint=_UPGRADE_HINT, - ) - return Response(body.model_dump_json(exclude_none=True), status=426, mimetype="application/json") - - -def attach_version_gate(bp: Blueprint) -> None: - """Reject difyctl clients older than ``[tool.dify] min_difyctl_version`` with 426. - - Registered app-wide (``before_app_request``) rather than blueprint-scoped so it - also fires for requests to *removed* paths — those no longer match an openapi - route and would 404 before a blueprint-scoped ``before_request`` ever runs. The - prefix guard scopes it back to ``/openapi/v1``. Fails open for non-difyctl or - unparseable User-Agents (only a confidently-too-old difyctl is blocked). - """ - - @bp.before_app_request - def _enforce_min_client_version() -> Response | None: # pyright: ignore[reportUnusedFunction] - if not request.path.startswith(_PREFIX): - return None - if request.path in _ALLOWLIST: - return None - match = _DIFYCTL_UA_RE.match(request.headers.get("User-Agent", "")) - if match is None: - return None - try: - client_version = Version(match.group(1)) - except InvalidVersion: - return None - # Compare the numeric core (major.minor.patch) only — a pre-release build - # like 0.2.0-rc.1 must not sort below the 0.2.0 floor. - min_version = dify_config.tool.dify.min_difyctl_version - if client_version.release[:3] < Version(min_version).release[:3]: - return _upgrade_required_response(match.group(1), min_version) - return None diff --git a/api/controllers/openapi/account.py b/api/controllers/openapi/account.py index d444c2bb20d..4e80879a556 100644 --- a/api/controllers/openapi/account.py +++ b/api/controllers/openapi/account.py @@ -8,7 +8,7 @@ from flask_restx import Resource from werkzeug.exceptions import NotFound, Unauthorized from controllers.openapi import openapi_ns -from controllers.openapi._contract import endpoint +from controllers.openapi._contract import Example, Kind, endpoint from controllers.openapi._models import ( AccountPayload, AccountResponse, @@ -34,6 +34,10 @@ from services.entities.account_entities import AccountSnapshot @openapi_ns.route("/account") class AccountApi(Resource): @endpoint( + op="account.get", + kind=Kind.OBJECT, + summary="Current account", + examples=(Example(title="Show the logged-in account and its workspaces", input={}),), requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.FULL)), returns=(200, AccountResponse, "Account info"), ) @@ -57,6 +61,10 @@ class AccountApi(Resource): @openapi_ns.route("/account/sessions/self") class AccountSessionsSelfApi(Resource): @endpoint( + op="account.sessions.revoke_current", + kind=Kind.OBJECT, + summary="Revoke the session behind this token", + examples=(Example(title="Log out the session behind this token", input={}),), requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.FULL)), returns=(200, RevokeResponse, "Session revoked"), ) @@ -68,6 +76,10 @@ class AccountSessionsSelfApi(Resource): @openapi_ns.route("/account/sessions") class AccountSessionsApi(Resource): @endpoint( + op="account.sessions.list", + kind=Kind.LIST, + summary="List login sessions of the current account", + examples=(Example(title="List login sessions, first page", input={"page": 1, "limit": 20}),), requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.FULL)), query=SessionListQuery, returns=(200, SessionListResponse, "Session list"), @@ -90,6 +102,10 @@ class AccountSessionsApi(Resource): @openapi_ns.route("/account/sessions/") class AccountSessionByIdApi(Resource): @endpoint( + op="account.sessions.revoke", + kind=Kind.OBJECT, + summary="Revoke one login session by id", + examples=(Example(title="Log out one device by session id", input={"session_id": ""}),), requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.FULL)), returns=(200, RevokeResponse, "Session revoked"), ) diff --git a/api/controllers/openapi/app_dsl.py b/api/controllers/openapi/app_dsl.py index 665f01b0999..e9c3bff44e2 100644 --- a/api/controllers/openapi/app_dsl.py +++ b/api/controllers/openapi/app_dsl.py @@ -6,8 +6,14 @@ from werkzeug.exceptions import Forbidden from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission, Workspace from controllers.openapi import openapi_ns -from controllers.openapi._contract import endpoint -from controllers.openapi._models import AppDslExportQuery, AppDslExportResponse, AppDslImportPayload +from controllers.openapi._contract import Example, Kind, endpoint, op_of +from controllers.openapi._models import ( + AppDslExportQuery, + AppDslExportResponse, + AppDslImportPayload, + AppDslImportResponse, + Hint, +) from controllers.openapi.auth.context import Context from controllers.openapi.auth.requirements import ( CheckAppApiEnabled, @@ -27,6 +33,19 @@ from services.errors.account import NoPermissionError from services.errors.app import WorkflowNotFoundError +def _import_response(result: Import, *, workspace_id: str) -> AppDslImportResponse: + hints: list[Hint] = [] + if result.status == ImportStatus.PENDING: + hints.append( + Hint( + summary="Confirm the pending import", + op=op_of(AppDslImportConfirmApi.post), + input={"workspace_id": workspace_id, "import_id": result.id}, + ) + ) + return AppDslImportResponse(**result.model_dump(), hints=hints) + + @openapi_ns.route("/workspaces//apps/imports") class AppDslImportApi(Resource): """Import a DSL YAML string into the specified workspace. @@ -42,6 +61,23 @@ class AppDslImportApi(Resource): """ @endpoint( + op="console_app.dsl.import", + kind=Kind.OBJECT, + summary="Import an app from DSL text or URL", + examples=( + Example( + title="Import an app from inline YAML", + input={"mode": "yaml-content", "yaml_content": ""}, + ), + Example( + title="Import an app from a URL", + input={"mode": "yaml-url", "yaml_url": "https://example.com/apps/release-summary.yml"}, + ), + Example( + title="Overwrite an existing workflow app from YAML", + input={"mode": "yaml-content", "yaml_content": "", "app_id": ""}, + ), + ), requirements=( CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_WRITE), @@ -51,9 +87,9 @@ class AppDslImportApi(Resource): ), body=AppDslImportPayload, returns=( - (200, Import, "Import completed"), - (202, Import, "Import pending confirmation"), - (400, Import, "Import failed"), + (200, AppDslImportResponse, "Import completed"), + (202, AppDslImportResponse, "Import pending confirmation"), + (400, AppDslImportResponse, "Import failed"), ), ) def post(self, ctx: Context, workspace_id: str, *, body: AppDslImportPayload): @@ -81,13 +117,14 @@ class AppDslImportApi(Resource): else: session.commit() + response = _import_response(result, workspace_id=workspace_id) match result.status: case ImportStatus.FAILED: - return result, 400 + return response, 400 case ImportStatus.PENDING: - return result, 202 + return response, 202 case _: - return result, 200 + return response, 200 @openapi_ns.route("/workspaces//apps/imports/:confirm") @@ -103,6 +140,10 @@ class AppDslImportConfirmApi(Resource): """ @endpoint( + op="console_app.dsl.import_confirm", + kind=Kind.OBJECT, + summary="Confirm a pending DSL import", + examples=(Example(title="Confirm an import that is pending confirmation", input={"import_id": ""}),), requirements=( CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_WRITE), @@ -145,6 +186,16 @@ class AppDslExportApi(Resource): """ @endpoint( + op="console_app.dsl.export", + kind=Kind.OBJECT, + summary="Export app DSL as YAML text inside a JSON object", + examples=( + Example(title="Export the current draft as YAML", input={"app_id": ""}), + Example( + title="Export a published version including secrets", + input={"app_id": "", "workflow_id": "", "include_secret": True}, + ), + ), requirements=( CheckSubject(allowed=(AccountSubject,)), CheckAppApiEnabled(), @@ -180,6 +231,10 @@ class AppDslCheckDependenciesApi(Resource): """ @endpoint( + op="console_app.dependencies.check", + kind=Kind.OBJECT, + summary="Check plugin dependencies of an app", + examples=(Example(title="Check which plugins an app needs", input={"app_id": ""}),), requirements=( CheckSubject(allowed=(AccountSubject,)), CheckAppApiEnabled(), diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py index 16315d685e5..ca10e0f61ea 100644 --- a/api/controllers/openapi/app_run.py +++ b/api/controllers/openapi/app_run.py @@ -1,11 +1,12 @@ -"""POST /openapi/v1/apps/:run — mode-agnostic runner.""" +"""Run routes on /openapi/v1: one per app mode.""" from __future__ import annotations import logging -from collections.abc import Callable, Generator +from collections.abc import Callable, Collection, Generator, Iterable, Mapping from contextlib import contextmanager -from typing import Any +from dataclasses import dataclass +from typing import Any, Final from flask_restx import Resource from sqlalchemy.orm import Session @@ -23,8 +24,18 @@ from controllers.common.fields import EventStreamResponse from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission from controllers.openapi import openapi_ns from controllers.openapi._audit import emit_app_run -from controllers.openapi._contract import endpoint -from controllers.openapi._models import AppRunRequest, TaskStopResponse +from controllers.openapi._contract import Example, Kind, endpoint +from controllers.openapi._files import end_read_transaction, materialize, merge_files +from controllers.openapi._hints import attach_stream_hints +from controllers.openapi._models import ( + AdvancedChatRunPayload, + ChatRunPayload, + CompletionRunPayload, + Hint, + RunPayloadBase, + TaskStopResponse, + WorkflowRunPayload, +) from controllers.openapi.auth.context import Context from controllers.openapi.auth.requirements import ( CheckAppAccess, @@ -35,6 +46,7 @@ from controllers.openapi.auth.requirements import ( CheckWorkspaceMember, ) from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject +from controllers.openapi.human_input_form import with_form_hints from controllers.service_api.app.error import ( AppUnavailableError, CompletionRequestError, @@ -47,6 +59,7 @@ from controllers.service_api.app.error import ( from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.entities.task_entities import MessageEndStreamResponse, StreamEvent from core.errors.error import ( AppInvokeQuotaExceededError, ModelCurrentlyNotSupportError, @@ -72,6 +85,10 @@ from services.errors.llm import InvokeRateLimitError logger = logging.getLogger(__name__) +# The service raises ValueError for a body the generator cannot run; its message names +# internals (variable ids, config keys), so the caller gets a fixed text and the log the detail. +_INVALID_RUN_INPUT: Final = "invalid run input" + @contextmanager def _translate_service_errors() -> Generator[None, None, None]: @@ -105,105 +122,251 @@ def _translate_service_errors() -> Generator[None, None, None]: raise InvokeRateLimitHttpError(ex.description) except InvokeError as e: raise CompletionRequestError(e.description) + except ValueError: + logger.warning("run input refused by the service.", exc_info=True) + raise BadRequest(_INVALID_RUN_INPUT) + except HTTPException: + raise + except Exception: + logger.exception("internal server error.") + raise InternalServerError() -def _generate(app: App, caller: Any, args: dict[str, Any], streaming: bool, session: Session): +_RUN_GUARDS: Final = ( + CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_RUN), + CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())), + CheckAppAccess(), +) +_STREAM_RESULT: Final = (200, EventStreamResponse, "Run result (SSE stream)") + + +def _generate(app: App, caller: Any, args: dict[str, Any], session: Session): return AppGenerateService.generate( session=session, app_model=app, user=caller, args=args, invoke_from=InvokeFrom.OPENAPI, - streaming=streaming, + streaming=True, ) -def _run_chat(app: App, caller: Any, payload: AppRunRequest, session: Session): - if not payload.query or not payload.query.strip(): - raise UnprocessableEntity("query_required_for_chat") - args = payload.model_dump(exclude_none=True) +def _generate_args(ctx: Context, payload: RunPayloadBase, *, exclude: Collection[str] = ()) -> dict[str, Any]: + args = payload.model_dump(exclude={"files", "attachments", *exclude}, exclude_none=True) + if payload.files or payload.attachments: + end_read_transaction(ctx.session) + args["inputs"] = merge_files(payload.inputs, payload.files, ctx.caller) + if payload.attachments: + args["files"] = materialize(payload.attachments, ctx.caller) + return args + + +def _stream(ctx: Context, args: dict[str, Any]): with _translate_service_errors(): - return _generate(app, caller, args, streaming=True, session=session) + return _generate(ctx.app, ctx.caller, args, ctx.session) -def _run_completion(app: App, caller: Any, payload: AppRunRequest, session: Session): - args = payload.model_dump(exclude_none=True) - args["auto_generate_name"] = False - args.setdefault("query", "") - with _translate_service_errors(): - return _generate(app, caller, args, streaming=True, session=session) +def _require_mode(app: App, *modes: AppMode) -> None: + if app.mode not in modes: + raise UnprocessableEntity("app_mode_mismatch") -def _run_workflow(app: App, caller: Any, payload: AppRunRequest, session: Session): - if payload.query is not None: - raise UnprocessableEntity("query_not_supported_for_workflow") - args = payload.model_dump(exclude={"query", "conversation_id", "auto_generate_name"}, exclude_none=True) - with _translate_service_errors(): - return _generate(app, caller, args, streaming=True, session=session) +def _respond(ctx: Context, stream: Any): + app_model = ctx.app + emit_app_run( + app_id=app_model.id, + tenant_id=app_model.tenant_id, + caller_kind=ctx.subject.caller_role, + mode=str(app_model.mode), + surface="apps", + ) + # response-contract:ignore compact_generate_response + return helper.compact_generate_response(stream) -_DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest, Session], Any]] = { - AppMode.CHAT: _run_chat, - AppMode.AGENT_CHAT: _run_chat, - AppMode.ADVANCED_CHAT: _run_chat, - AppMode.COMPLETION: _run_completion, - AppMode.WORKFLOW: _run_workflow, -} +class _ChatMessageEnd(MessageEndStreamResponse): + conversation_id: str -@openapi_ns.route("/apps/:run") -class AppRunApi(Resource): - @endpoint( - requirements=( - CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)), - CheckAppApiEnabled(), - CheckWorkspaceMember(), - CheckScope(Scope.APPS_RUN), - CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())), - CheckAppAccess(), +def with_reply_hints(events: Iterable[str], *, op: str, app_id: str) -> Generator[str, None, None]: + def build(event: Mapping[str, Any]) -> list[Hint]: + end = _ChatMessageEnd.model_validate(event) + return [ + Hint( + summary="Reply in this conversation", + op=op, + input={"app_id": app_id, "conversation_id": end.conversation_id, "query": None, "inputs": {}}, + ) + ] + + return attach_stream_hints(events, event=StreamEvent.MESSAGE_END.value, build=build) + + +HintLayer = Callable[[Iterable[str], str, str], Generator[str, None, None]] + + +def _reply_layer(events: Iterable[str], op: str, app_id: str) -> Generator[str, None, None]: + return with_reply_hints(events, op=op, app_id=app_id) + + +def _form_layer(events: Iterable[str], op: str, app_id: str) -> Generator[str, None, None]: + return with_form_hints(events, app_id=app_id) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class _RunRoute: + """What differs between the per-mode run routes; `_run_api` builds the Resource from it. + + `hints` apply innermost first, so a stream wrapped in (reply, form) has form hints outermost. + """ + + resource: str + segment: str + op: str + summary: str + payload: type[RunPayloadBase] + modes: tuple[AppMode, ...] + hints: tuple[HintLayer, ...] = () + examples: tuple[Example, ...] = () + + +_RUN_ROUTES: Final = ( + _RunRoute( + resource="WorkflowRunApi", + segment="workflow", + op="console_app.workflow.run", + summary="Run a workflow app; streams workflow events", + payload=WorkflowRunPayload, + modes=(AppMode.WORKFLOW,), + hints=(_form_layer,), + examples=( + Example( + title="Run a workflow with two variables", + input={"app_id": "", "inputs": {"topic": "quarterly report", "language": "en"}}, + ), + Example( + title="Run a workflow with a local file variable", + input={"app_id": "", "inputs": {}, "files": {"document": "./report.pdf"}}, + ), + Example( + title="Run a pinned published workflow version", + input={"app_id": "", "inputs": {}, "workflow_id": ""}, + ), ), - body=AppRunRequest, - returns=(200, EventStreamResponse, "Run result (SSE stream)"), + ), + _RunRoute( + resource="ChatRunApi", + segment="chat", + op="console_app.chat.run", + summary="Run a chat or agent app; streams message events", + payload=ChatRunPayload, + modes=(AppMode.CHAT, AppMode.AGENT_CHAT), + hints=(_reply_layer,), + examples=( + Example( + title="Start a new conversation", + input={"app_id": "", "query": "Summarise the latest release", "inputs": {}}, + ), + Example( + title="Reply in an existing conversation", + input={ + "app_id": "", + "query": "Make it shorter", + "inputs": {}, + "conversation_id": "", + }, + ), + Example( + title="Ask about a local file attached to the message", + input={ + "app_id": "", + "query": "What is in this file?", + "inputs": {}, + "attachments": ["./report.pdf"], + }, + ), + ), + ), + _RunRoute( + resource="AdvancedChatRunApi", + segment="advanced-chat", + op="console_app.advanced_chat.run", + summary="Run an advanced-chat (chatflow) app; streams message and workflow events", + payload=AdvancedChatRunPayload, + modes=(AppMode.ADVANCED_CHAT,), + hints=(_reply_layer, _form_layer), + examples=( + Example( + title="Start a new conversation", + input={"app_id": "", "query": "Summarise the latest release", "inputs": {}}, + ), + Example( + title="Reply in an existing conversation", + input={ + "app_id": "", + "query": "Make it shorter", + "inputs": {}, + "conversation_id": "", + }, + ), + ), + ), + _RunRoute( + resource="CompletionRunApi", + segment="completion", + op="console_app.completion.run", + summary="Run a completion app; streams message events", + payload=CompletionRunPayload, + modes=(AppMode.COMPLETION,), + examples=( + Example( + title="Run a completion app with its variables", + input={"app_id": "", "inputs": {"text": "The quick brown fox"}}, + ), + Example( + title="Run a completion app with prompt text", + input={"app_id": "", "inputs": {}, "query": "Write a haiku about the sea"}, + ), + ), + ), +) + + +def _run_api(route: _RunRoute) -> type[Resource]: + @endpoint( + op=route.op, + kind=Kind.SSE, + summary=route.summary, + requirements=_RUN_GUARDS, + body=route.payload, + examples=route.examples, + returns=_STREAM_RESULT, ) - def post(self, ctx: Context, app_id: str, *, body: AppRunRequest): - app_model = ctx.app - caller = ctx.caller + def post(self: Resource, ctx: Context, app_id: str, *, body: RunPayloadBase): + _require_mode(ctx.app, *route.modes) + stream = _stream(ctx, _generate_args(ctx, body)) + for layer in route.hints: + stream = layer(stream, route.op, ctx.app.id) + return _respond(ctx, stream) - handler = _DISPATCH.get(app_model.mode) - if handler is None: - raise UnprocessableEntity("mode_not_runnable") + resource = type(route.resource, (Resource,), {"post": post, "__module__": __name__}) + return openapi_ns.route(f"/apps//{route.segment}:run")(resource) - try: - stream_obj = handler(app_model, caller, body, ctx.session) - except HTTPException: - raise - except Exception: - logger.exception("internal server error.") - raise InternalServerError() - emit_app_run( - app_id=app_model.id, - tenant_id=app_model.tenant_id, - caller_kind=ctx.subject.caller_role, - mode=str(app_model.mode), - surface="apps", - ) - - # response-contract:ignore compact_generate_response - return helper.compact_generate_response(stream_obj) +WorkflowRunApi, ChatRunApi, AdvancedChatRunApi, CompletionRunApi = (_run_api(route) for route in _RUN_ROUTES) @openapi_ns.route("/apps//tasks/:stop") class AppRunTaskStopApi(Resource): @endpoint( - requirements=( - CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)), - CheckAppApiEnabled(), - CheckWorkspaceMember(), - CheckScope(Scope.APPS_RUN), - CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())), - CheckAppAccess(), - ), + op="run.stop", + kind=Kind.OBJECT, + summary="Stop a running task", + examples=(Example(title="Stop a running task", input={"app_id": "", "task_id": ""}),), + requirements=_RUN_GUARDS, returns=(200, TaskStopResponse, "Task stopped"), ) def post(self, ctx: Context, app_id: str, task_id: str): diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index 0195bec24c0..09f825f7178 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -13,7 +13,7 @@ from controllers.common.app_access import AppAccessFilter, resolve_app_access_fi from controllers.common.fields import Parameters from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission from controllers.openapi import openapi_ns -from controllers.openapi._contract import endpoint +from controllers.openapi._contract import Example, Kind, endpoint from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA, build_input_schema, resolve_app_config from controllers.openapi._models import ( SUPPORTED_APP_TYPES, @@ -103,6 +103,16 @@ def build_app_describe_response(app: App, fields: set[str] | None, *, session: S @openapi_ns.route("/apps/") class AppDescribeApi(Resource): @endpoint( + op="console_app.describe", + kind=Kind.OBJECT, + summary="App detail, parameters and runtime input_schema", + examples=( + Example(title="Describe an app: info, parameters and input_schema", input={"app_id": ""}), + Example( + title="Only the runtime input_schema of an app", + input={"app_id": "", "fields": "input_schema"}, + ), + ), requirements=( CheckSubject(allowed=(AccountSubject,)), CheckAppApiEnabled(), @@ -121,6 +131,16 @@ class AppDescribeApi(Resource): @openapi_ns.route("/apps") class AppListApi(Resource): @endpoint( + op="console_app.list", + kind=Kind.LIST, + summary="List apps in a workspace", + examples=( + Example(title="List apps in the pinned workspace, first page", input={"page": 1, "limit": 20}), + Example( + title="Find workflow apps whose name contains a word", + input={"mode": "workflow", "name": "summary"}, + ), + ), requirements=( CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.APPS_READ), diff --git a/api/controllers/openapi/apps_permitted_external.py b/api/controllers/openapi/apps_permitted_external.py index 6d1a82f9ebc..2087eb79d89 100644 --- a/api/controllers/openapi/apps_permitted_external.py +++ b/api/controllers/openapi/apps_permitted_external.py @@ -10,7 +10,7 @@ from __future__ import annotations from flask_restx import Resource from controllers.openapi import openapi_ns -from controllers.openapi._contract import endpoint +from controllers.openapi._contract import Example, Kind, endpoint from controllers.openapi._models import ( AppDescribeQuery, AppDescribeResponse, @@ -41,6 +41,10 @@ _ENTERPRISE_ONLY = frozenset({DeploymentEdition.ENTERPRISE}) @openapi_ns.route("/permitted-external-apps") class PermittedExternalAppsListApi(Resource): @endpoint( + op="console_app.external.list", + kind=Kind.LIST, + summary="List apps an external SSO subject may run", + examples=(Example(title="List the apps this SSO subject may run, first page", input={"page": 1, "limit": 20}),), requirements=( CheckSubject(allowed=(ExternalSsoSubject,)), CheckScope(Scope.APPS_READ_PERMITTED_EXTERNAL), @@ -95,6 +99,10 @@ class PermittedExternalAppsListApi(Resource): @openapi_ns.route("/permitted-external-apps/") class PermittedExternalAppDescribeApi(Resource): @endpoint( + op="console_app.external.describe", + kind=Kind.OBJECT, + summary="External-subject app detail", + examples=(Example(title="Describe a permitted app with its input_schema", input={"app_id": ""}),), requirements=( CheckSubject(allowed=(ExternalSsoSubject,)), CheckAppApiEnabled(), diff --git a/api/controllers/openapi/auth/pipelines.py b/api/controllers/openapi/auth/pipelines.py index 32faec7176a..516de2f4c0a 100644 --- a/api/controllers/openapi/auth/pipelines.py +++ b/api/controllers/openapi/auth/pipelines.py @@ -4,12 +4,14 @@ from collections.abc import Callable, Generator from contextlib import contextmanager from typing import Any, ClassVar, override -from flask import current_app +from flask import current_app, request from flask_login import user_logged_in from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden from configs import dify_config +from controllers.openapi._catalog import CATALOG_HEADER, current_catalog +from controllers.openapi._errors import CatalogStale from controllers.openapi.auth.context import Context from controllers.openapi.auth.loaders import load_caller from controllers.openapi.auth.requirements import ( @@ -59,8 +61,25 @@ def pipeline_for_subject(subject: Subject) -> Pipeline: return _PIPELINES[type(subject)] +class _RequiresCurrentCatalog(Requirement): + """The client names the catalog it built the request from, and a request + built from any other catalog - or from none - is refused before a handler + runs, so a tampered or stale local copy can never pick the route. Fixed on + every pipeline rather than declared per route, so no endpoint can leave it + out; `_catalog` and `_version` are unguarded and stay reachable to recover. + """ + + rank = Rank.FIRST + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + _, fingerprint = current_catalog() + if request.headers.get(CATALOG_HEADER) != fingerprint: + raise CatalogStale() + + class AccountPipeline(Pipeline, serves=AccountSubject): - fixed = (ResolveCaller(),) + fixed = (_RequiresCurrentCatalog(), ResolveCaller()) class _RequiresEnterprise(Requirement): @@ -80,6 +99,7 @@ class _RequiresEnterprise(Requirement): class ExternalSsoPipeline(Pipeline, serves=ExternalSsoSubject): fixed = ( + _RequiresCurrentCatalog(), _RequiresEnterprise(), ResolveCaller(), ) diff --git a/api/controllers/openapi/auth/router.py b/api/controllers/openapi/auth/router.py index a2c3714f41d..2a6f56b23bd 100644 --- a/api/controllers/openapi/auth/router.py +++ b/api/controllers/openapi/auth/router.py @@ -37,7 +37,7 @@ class AuthRouter: caller, so this is the one place it is checked. The bearer feature flag is the same kind of fact and answers 503 next. """ - if spec.edition is not None and dify_config.DEPLOYMENT_EDITION not in spec.edition: + if not spec.allows(dify_config.DEPLOYMENT_EDITION): raise NotFound() if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE: assert_license_valid() diff --git a/api/controllers/openapi/auth/spec.py b/api/controllers/openapi/auth/spec.py index 1fa69fd482e..adf0dc399a1 100644 --- a/api/controllers/openapi/auth/spec.py +++ b/api/controllers/openapi/auth/spec.py @@ -1,17 +1,62 @@ """`@endpoint` builds one at decoration time and attaches it as `view.__spec__`; the router reads nothing else off the view. It lives in `auth/` so the dependency runs `_contract.py` -> `auth/` and never back. + +The catalog's view of the route rides along as `CatalogMeta`, so a route is +still described in one place while each reader has its own object. """ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel from controllers.openapi.auth.requirements import Requirement from enums import DeploymentEdition -@dataclass(frozen=True, slots=True) +class Kind(StrEnum): + """Response body kinds. Frozen protocol names; the CLI has one handler per value.""" + + OBJECT = "object" + LIST = "list" + SSE = "sse" + TEXT = "text" + FILE = "file" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Example: + """One complete input for an op, shown by the CLI next to the schema.""" + + title: str + input: Mapping[str, Any] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class CatalogMeta: + """What the catalog (`_catalog.py`) says about a route; the router never reads it. + + `internal` hides the op from the CLI's default listing; `deprecated` marks it + as kept for compatibility only. `examples` ride into the catalog as complete + inputs; a catalog test checks each one against the op's input schema. + """ + + op: str + kind: Kind + summary: str + query: type[BaseModel] | None = None + body: type[BaseModel] | None = None + internal: bool = False + deprecated: bool = False + examples: tuple[Example, ...] = () + + +@dataclass(frozen=True, slots=True, kw_only=True) class EndpointSpec: """`edition` is the endpoint-level gate — a 404 raised before any bearer is read, because the route is not exposed on this edition at all. It is @@ -20,4 +65,18 @@ class EndpointSpec: """ requirements: tuple[Requirement, ...] + catalog: CatalogMeta edition: frozenset[DeploymentEdition] | None = None + + def allows(self, edition: DeploymentEdition) -> bool: + """Whether this deployment exposes the route at all. `edition is None` + means every edition does. The router turns a `False` into a 404 and the + catalog leaves the op out, so both read the gate from here. + """ + return self.edition is None or edition in self.edition + + +def spec_of(view: Any) -> EndpointSpec | None: + """The spec `@endpoint` attached to `view`, or None for anything else.""" + spec = view.__spec__ if hasattr(view, "__spec__") else None + return spec if isinstance(spec, EndpointSpec) else None diff --git a/api/controllers/openapi/files.py b/api/controllers/openapi/files.py index 53bed421ca6..8808835dbf9 100644 --- a/api/controllers/openapi/files.py +++ b/api/controllers/openapi/files.py @@ -2,21 +2,12 @@ from __future__ import annotations -from flask import request from flask_restx import Resource -from werkzeug.exceptions import BadRequest -import services -from controllers.common.errors import ( - BlockedFileExtensionError, - FileTooLargeError, - NoFileUploadedError, - TooManyFilesError, - UnsupportedFileTypeError, -) from controllers.openapi import openapi_ns -from controllers.openapi._contract import endpoint -from controllers.openapi._errors import FilenameNotExists +from controllers.openapi._contract import Example, Kind, endpoint +from controllers.openapi._files import end_read_transaction, upload +from controllers.openapi._models import FileUploadPayload from controllers.openapi.auth.context import Context from controllers.openapi.auth.requirements import ( CheckAppAccess, @@ -26,7 +17,6 @@ from controllers.openapi.auth.requirements import ( CheckWorkspaceMember, ) from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject -from extensions.ext_application_services import application_services from fields.file_fields import FileResponse from libs.oauth_bearer import Scope @@ -38,13 +28,19 @@ class AppFileUploadApi(Resource): @openapi_ns.doc( responses={ 201: "File uploaded successfully", - 400: "Bad request — no file, multiple files, invalid filename, or blocked extension", + 400: "Bad request — invalid filename or blocked extension", 401: "Unauthorized — invalid or expired bearer token", 413: "File too large", 415: "Unsupported file type", } ) @endpoint( + op="console_app.file.upload", + kind=Kind.OBJECT, + summary="Upload a file and get a file id for later runs", + examples=( + Example(title="Upload a local PDF and get a file id", input={"app_id": "", "file": "./report.pdf"}), + ), requirements=( CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)), CheckAppApiEnabled(), @@ -52,34 +48,9 @@ class AppFileUploadApi(Resource): CheckScope(Scope.APPS_RUN), CheckAppAccess(), ), + body=FileUploadPayload, returns=(201, FileResponse, "File uploaded"), ) - def post(self, ctx: Context, app_id: str): - if "file" not in request.files: - raise NoFileUploadedError() - if len(request.files) > 1: - raise TooManyFilesError() - - file = request.files["file"] - if not file.mimetype: - raise UnsupportedFileTypeError() - if not file.filename: - raise FilenameNotExists() - - try: - upload_file = application_services().files.upload_file( - filename=file.filename, - content=file.stream.read(), - mimetype=file.mimetype, - user=ctx.caller, - ) - except services.errors.file.FileTooLargeError as exc: - raise FileTooLargeError(exc.description) from exc - except services.errors.file.UnsupportedFileTypeError as exc: - raise UnsupportedFileTypeError() from exc - except services.errors.file.BlockedFileExtensionError as exc: - raise BlockedFileExtensionError(exc.description) from exc - except ValueError as exc: - raise BadRequest(str(exc)) from exc - - return FileResponse.model_validate(upload_file, from_attributes=True) + def post(self, ctx: Context, app_id: str, *, body: FileUploadPayload): + end_read_transaction(ctx.session) + return FileResponse.model_validate(upload(body.file, ctx.caller), from_attributes=True) diff --git a/api/controllers/openapi/human_input_form.py b/api/controllers/openapi/human_input_form.py index 58eb3c80ce3..e71d4cd88cc 100644 --- a/api/controllers/openapi/human_input_form.py +++ b/api/controllers/openapi/human_input_form.py @@ -2,20 +2,27 @@ from __future__ import annotations import json import logging -from typing import override +from collections.abc import Generator, Iterable, Mapping +from typing import Any, override from flask import Response from flask_restx import Resource from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest -from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values +from controllers.common.human_input import stringify_form_default_values from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission -from controllers.common.schema import register_schema_models from controllers.openapi import openapi_ns -from controllers.openapi._contract import endpoint +from controllers.openapi._contract import Example, Kind, endpoint, op_of from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch -from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse +from controllers.openapi._files import end_read_transaction, merge_files +from controllers.openapi._hints import attach_stream_hints +from controllers.openapi._models import ( + FormSubmitResponse, + Hint, + HumanInputFormDefinitionResponse, + OpenApiFormSubmitPayload, +) from controllers.openapi.auth.context import Context from controllers.openapi.auth.loaders import PathParam, load_app from controllers.openapi.auth.requirements import ( @@ -28,6 +35,7 @@ from controllers.openapi.auth.requirements import ( Requirement, ) from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, Subject +from core.app.entities.task_entities import HumanInputRequiredResponse, StreamEvent from core.db.session_factory import session_factory from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface from extensions.ext_database import db @@ -39,8 +47,6 @@ from services.human_input_service import FormNotFoundError, HumanInputService logger = logging.getLogger(__name__) -register_schema_models(openapi_ns, HumanInputFormSubmitPayload) - class CheckFormSurface(Requirement): """`/openapi/v1` is allowed only the recipient types `human_input_policy` lists @@ -86,6 +92,15 @@ def _ensure_form_belongs_to_app(form, app_model: App) -> None: @openapi_ns.route("/apps//human-input-forms/") class OpenApiWorkflowHumanInputFormApi(Resource): @endpoint( + op="run.form.get", + kind=Kind.OBJECT, + summary="Read a human-input form", + examples=( + Example( + title="Read the form a paused run is waiting on", + input={"app_id": "", "form_token": ""}, + ), + ), requirements=( CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)), CheckAppApiEnabled(), @@ -111,6 +126,30 @@ class OpenApiWorkflowHumanInputFormApi(Resource): @openapi_ns.route("/apps//human-input-forms/:submit") class OpenApiWorkflowHumanInputFormSubmitApi(Resource): @endpoint( + op="run.form.submit", + kind=Kind.OBJECT, + summary="Submit a human-input form", + examples=( + Example( + title="Approve a paused run with a written comment", + input={ + "app_id": "", + "form_token": "", + "action": "approve", + "inputs": {"comment": "Looks good"}, + }, + ), + Example( + title="Submit a form answer that includes a local file", + input={ + "app_id": "", + "form_token": "", + "action": "submit", + "inputs": {}, + "files": {"evidence": "./scan.png"}, + }, + ), + ), requirements=( CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)), CheckAppApiEnabled(), @@ -120,10 +159,10 @@ class OpenApiWorkflowHumanInputFormSubmitApi(Resource): CheckAppAccess(), CheckFormSurface(), ), - body=HumanInputFormSubmitPayload, + body=OpenApiFormSubmitPayload, returns=(200, FormSubmitResponse, "Form submitted"), ) - def post(self, ctx: Context, app_id: str, form_token: str, *, body: HumanInputFormSubmitPayload): + def post(self, ctx: Context, app_id: str, form_token: str, *, body: OpenApiFormSubmitPayload): service = HumanInputService(db.engine) form = service.get_form_by_token(form_token) if form is None: @@ -142,12 +181,16 @@ class OpenApiWorkflowHumanInputFormSubmitApi(Resource): logger.warning("Recipient type is None for form, form_token=%s", form_token) raise BadRequest("Form recipient type is invalid") + if body.files: + end_read_transaction(ctx.session) + inputs = merge_files(body.inputs, body.files, ctx.caller) + try: service.submit_form_by_token( recipient_type=form.recipient_type, form_token=form_token, selected_action_id=body.action, - form_data=body.inputs, + form_data=inputs, submission_user_id=submission_user_id, submission_end_user_id=submission_end_user_id, ) @@ -155,3 +198,33 @@ class OpenApiWorkflowHumanInputFormSubmitApi(Resource): raise HumanInputFormNotFound() return FormSubmitResponse() + + +def form_hints(*, op: str, app_id: str, response: HumanInputRequiredResponse) -> list[Hint]: + """One hint per action button of a paused form, its `inputs` blanked to the form's fields.""" + + data = response.data + if not data.form_token: + return [] + fields = [field.model_dump(mode="json") for field in data.inputs] + blank = {field.output_variable_name: None for field in data.inputs} + return [ + Hint( + summary=action.title or action.id, + op=op, + input={"app_id": app_id, "form_token": data.form_token, "action": action.id, "inputs": dict(blank)}, + form=fields, + ) + for action in data.actions + ] + + +def with_form_hints(events: Iterable[str], *, app_id: str) -> Generator[str, None, None]: + """A run stream whose `human_input_required` events carry hints that target the submit route above.""" + + op = op_of(OpenApiWorkflowHumanInputFormSubmitApi.post) + + def build(event: Mapping[str, Any]) -> list[Hint]: + return form_hints(op=op, app_id=app_id, response=HumanInputRequiredResponse.model_validate(event)) + + return attach_stream_hints(events, event=StreamEvent.HUMAN_INPUT_REQUIRED.value, build=build) diff --git a/api/controllers/openapi/index.py b/api/controllers/openapi/index.py index 6f63232ad58..bf000d5fe17 100644 --- a/api/controllers/openapi/index.py +++ b/api/controllers/openapi/index.py @@ -4,6 +4,7 @@ from flask_restx import Resource from configs import dify_config from controllers.openapi import openapi_ns +from controllers.openapi._catalog import CATALOG_ROUTE, catalog_response from controllers.openapi._contract import returns from controllers.openapi._models import HealthResponse, ServerVersionResponse @@ -23,3 +24,10 @@ class VersionApi(Resource): version=dify_config.project.version, edition=dify_config.DEPLOYMENT_EDITION, ) + + +@openapi_ns.route(CATALOG_ROUTE) +class CatalogApi(Resource): + @openapi_ns.doc("catalog", description="Machine-readable catalog of every op on this surface") + def get(self): + return catalog_response() diff --git a/api/controllers/openapi/workflow_events.py b/api/controllers/openapi/workflow_events.py index 67286a820a7..4377844d476 100644 --- a/api/controllers/openapi/workflow_events.py +++ b/api/controllers/openapi/workflow_events.py @@ -11,16 +11,15 @@ from __future__ import annotations import json from collections.abc import Generator -from flask import Response, request +from flask import Response from flask_restx import Resource from pydantic import BaseModel, Field from werkzeug.exceptions import NotFound, UnprocessableEntity from controllers.common.fields import EventStreamResponse from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission -from controllers.common.schema import query_params_from_model from controllers.openapi import openapi_ns -from controllers.openapi._contract import endpoint +from controllers.openapi._contract import Example, Kind, endpoint from controllers.openapi.auth.context import Context from controllers.openapi.auth.requirements import ( CheckAppAccess, @@ -31,6 +30,7 @@ from controllers.openapi.auth.requirements import ( CheckWorkspaceMember, ) from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject +from controllers.openapi.human_input_form import with_form_hints from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator from core.app.apps.base_app_generator import BaseAppGenerator from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter @@ -52,8 +52,17 @@ class WorkflowEventsQuery(BaseModel): @openapi_ns.route("/apps//tasks//events") class OpenApiWorkflowEventsApi(Resource): - @openapi_ns.doc(params=query_params_from_model(WorkflowEventsQuery)) @endpoint( + op="run.events", + kind=Kind.SSE, + summary="Stream the events of a workflow run", + examples=( + Example(title="Follow the events of a running task", input={"app_id": "", "task_id": ""}), + Example( + title="Keep the stream open across a human-input pause", + input={"app_id": "", "task_id": "", "continue_on_pause": True}, + ), + ), requirements=( CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)), CheckAppApiEnabled(), @@ -62,9 +71,10 @@ class OpenApiWorkflowEventsApi(Resource): CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())), CheckAppAccess(), ), + query=WorkflowEventsQuery, returns=(200, EventStreamResponse, "SSE event stream"), ) - def get(self, ctx: Context, app_id: str, task_id: str): + def get(self, ctx: Context, app_id: str, task_id: str, *, query: WorkflowEventsQuery): # The router's session closes as soon as this returns, so everything the SSE # body needs is read off `ctx` here and the generators below close over plain # values only. @@ -120,8 +130,8 @@ class OpenApiWorkflowEventsApi(Resource): else: generator = WorkflowAppGenerator() - include_state_snapshot = request.args.get("include_state_snapshot", "false").lower() == "true" - continue_on_pause = request.args.get("continue_on_pause", "false").lower() == "true" + include_state_snapshot = query.include_state_snapshot + continue_on_pause = query.continue_on_pause terminal_events: list[StreamEvent] | None = [] if continue_on_pause else None def _generate_stream_events(): @@ -148,7 +158,7 @@ class OpenApiWorkflowEventsApi(Resource): event_generator = _generate_stream_events return Response( - event_generator(), + with_form_hints(event_generator(), app_id=owning_app_id), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, ) diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py index c2436a5a3c6..d0aba47e73d 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -21,7 +21,7 @@ from werkzeug.exceptions import BadRequest, NotFound from configs import dify_config from controllers.common.rbac import RBACCheck, RBACPermission, Workspace from controllers.openapi import openapi_ns -from controllers.openapi._contract import endpoint +from controllers.openapi._contract import Example, Kind, endpoint from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded from controllers.openapi._models import ( MemberActionResponse, @@ -32,6 +32,7 @@ from controllers.openapi._models import ( MemberResponse, MemberRoleUpdatePayload, WorkspaceDetailResponse, + WorkspaceListQuery, WorkspaceListResponse, WorkspaceSummaryResponse, ) @@ -88,18 +89,29 @@ def _check_member_invite_quota(tenant_id: str) -> None: @openapi_ns.route("/workspaces") class WorkspacesApi(Resource): @endpoint( + op="workspace.list", + kind=Kind.LIST, + summary="List workspaces of the current account", + examples=(Example(title="List my workspaces, first page", input={"page": 1, "limit": 20}),), requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_READ)), + query=WorkspaceListQuery, returns=(200, WorkspaceListResponse, "Workspace list"), ) - def get(self, ctx: Context): + def get(self, ctx: Context, *, query: WorkspaceListQuery): rows = TenantService.get_workspaces_for_account(str(ctx.subject.account_id), session=ctx.session) - - return WorkspaceListResponse(workspaces=list(starmap(_workspace_summary, rows))) + return WorkspaceListResponse.page_of(list(starmap(_workspace_summary, rows)), query=query) @openapi_ns.route("/workspaces/") class WorkspaceByIdApi(Resource): @endpoint( + op="workspace.get", + kind=Kind.OBJECT, + summary="Workspace detail", + examples=( + Example(title="Show the pinned workspace", input={}), + Example(title="Show another workspace by id", input={"workspace_id": ""}), + ), requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_READ)), returns=(200, WorkspaceDetailResponse, "Workspace detail"), ) @@ -122,6 +134,11 @@ class WorkspaceSwitchApi(Resource): """ @endpoint( + op="workspace.switch", + kind=Kind.OBJECT, + summary="Server-side current workspace switch (shared with the web console)", + examples=(Example(title="Make a workspace current on the server", input={"workspace_id": ""}),), + internal=True, requirements=( CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_READ), @@ -151,6 +168,10 @@ class WorkspaceMembersApi(Resource): """ @endpoint( + op="workspace.members.list", + kind=Kind.LIST, + summary="List workspace members", + examples=(Example(title="List members of the pinned workspace, first page", input={"page": 1, "limit": 20}),), requirements=( CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_READ), @@ -161,17 +182,16 @@ class WorkspaceMembersApi(Resource): ) def get(self, ctx: Context, workspace_id: str, *, query: MemberListQuery): members = TenantService.get_tenant_members(ctx.workspace, session=ctx.session) - total = len(members) - start = (query.page - 1) * query.limit - page_items = members[start : start + query.limit] - return MemberListResponse.build( - page=query.page, - limit=query.limit, - total=total, - items=[_member_response(m) for m in page_items], - ) + return MemberListResponse.page_of([_member_response(m) for m in members], query=query) @endpoint( + op="workspace.members.invite", + kind=Kind.OBJECT, + summary="Invite a member by email", + examples=( + Example(title="Invite a member as a normal user", input={"email": "ada@example.com", "role": "normal"}), + Example(title="Invite a member as an admin", input={"email": "grace@example.com", "role": "admin"}), + ), requirements=( CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_WRITE), @@ -232,6 +252,10 @@ class WorkspaceMemberApi(Resource): """ @endpoint( + op="workspace.members.remove", + kind=Kind.OBJECT, + summary="Remove a member", + examples=(Example(title="Remove a member by account id", input={"member_id": ""}),), requirements=( CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_WRITE), @@ -258,6 +282,13 @@ class WorkspaceMemberApi(Resource): return MemberActionResponse() @endpoint( + op="workspace.members.set_role", + kind=Kind.OBJECT, + summary="Change a member's role", + examples=( + Example(title="Promote a member to admin", input={"member_id": "", "role": "admin"}), + Example(title="Demote an admin to a normal member", input={"member_id": "", "role": "normal"}), + ), requirements=( CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_WRITE), diff --git a/api/extensions/ext_blueprints.py b/api/extensions/ext_blueprints.py index 7b1481b3535..44c95601d27 100644 --- a/api/extensions/ext_blueprints.py +++ b/api/extensions/ext_blueprints.py @@ -1,5 +1,5 @@ from configs import dify_config -from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN, HEADER_NAME_PASSPORT +from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CATALOG, HEADER_NAME_CSRF_TOKEN, HEADER_NAME_PASSPORT from dify_app import DifyApp BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEADER_NAME_PASSPORT) @@ -7,8 +7,9 @@ SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization") AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN) FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN) EMBED_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE) -EXPOSED_HEADERS: tuple[str, ...] = ("X-Version", "X-Env", "X-Trace-Id") -OPENAPI_HEADERS: tuple[str, ...] = ("Authorization", "Content-Type", HEADER_NAME_CSRF_TOKEN) +EXPOSED_HEADERS: tuple[str, ...] = ("X-Version", "X-Env", "X-Trace-Id", HEADER_NAME_CATALOG) +# A guarded request names the catalog it was built from, so a browser client must be able to send it. +OPENAPI_HEADERS: tuple[str, ...] = ("Authorization", "Content-Type", HEADER_NAME_CSRF_TOKEN, HEADER_NAME_CATALOG) OPENAPI_MAX_AGE_SECONDS: int = 600 diff --git a/api/libs/external_api.py b/api/libs/external_api.py index 6843101c6f7..8472d98d35c 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -179,6 +179,11 @@ class ExternalApi(Api): self.init_app(app, **kwargs) register_external_error_handlers(self, body_formatter=error_body_formatter) + def finish_document(self, document: dict[str, object]) -> dict[str, object]: + """A surface overrides this to rewrite its finished OpenAPI document (see + ``DocumentFinisher`` in ``flask_restx_compat``); the default says nothing extra.""" + return document + @override def _register_doc(self, app_or_blueprint: Blueprint | Flask) -> None: if self._add_specs and self._doc: diff --git a/api/libs/flask_restx_compat.py b/api/libs/flask_restx_compat.py index 8e5b166d6d7..162b528bdf4 100644 --- a/api/libs/flask_restx_compat.py +++ b/api/libs/flask_restx_compat.py @@ -9,7 +9,7 @@ spec export fail or succeed in the same way. import hashlib import json from collections.abc import Mapping -from typing import TypeGuard, cast +from typing import Protocol, TypeGuard, cast, runtime_checkable from flask import current_app from flask_restx import fields @@ -264,6 +264,17 @@ def _inline_model_name(nested_fields: dict[object, object]) -> str: return f"_AnonymousInlineModel_{digest}" +@runtime_checkable +class DocumentFinisher(Protocol): + """An Api that rewrites its finished OpenAPI document to say what Flask-RESTX cannot. + + Runs last in ``Swagger.as_dict``, so the live ``openapi.json``, the spec generator + and every test that reads the document see the same result. + """ + + def finish_document(self, document: dict[str, object]) -> dict[str, object]: ... + + def install_swagger_compatibility() -> None: """Install Dify's Flask-RESTX OpenAPI compatibility hooks. @@ -428,8 +439,10 @@ def install_swagger_compatibility() -> None: include_all_models = current_app.config.get("RESTX_INCLUDE_ALL_MODELS", False) current_app.config["RESTX_INCLUDE_ALL_MODELS"] = False try: - payload = original_as_dict(self) - return finalize_openapi_payload(payload, registered_models=self.api.models) + payload = finalize_openapi_payload(original_as_dict(self), registered_models=self.api.models) + if isinstance(self.api, DocumentFinisher): + payload = self.api.finish_document(payload) + return payload finally: current_app.config["RESTX_INCLUDE_ALL_MODELS"] = include_all_models diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index cbb8b6faa84..385be3dc5dc 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -12,6 +12,15 @@ Bearer format: API_KEY ## openapi User-scoped operations +### [GET] /_catalog +Machine-readable catalog of every op on this surface + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + ### [GET] /_health #### Responses @@ -109,6 +118,69 @@ User-scoped operations | 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| +### [POST] /apps/{app_id}/advanced-chat:run +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AdvancedChatRunPayload](#advancedchatrunpayload)
**multipart/form-data**: [AdvancedChatRunPayload](#advancedchatrunpayload)
| **application/json**: [AdvancedChatRunPayload](#advancedchatrunpayload)
**multipart/form-data**: [AdvancedChatRunPayload](#advancedchatrunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /apps/{app_id}/chat:run +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatRunPayload](#chatrunpayload)
**multipart/form-data**: [ChatRunPayload](#chatrunpayload)
| **application/json**: [ChatRunPayload](#chatrunpayload)
**multipart/form-data**: [ChatRunPayload](#chatrunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /apps/{app_id}/completion:run +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CompletionRunPayload](#completionrunpayload)
**multipart/form-data**: [CompletionRunPayload](#completionrunpayload)
| **application/json**: [CompletionRunPayload](#completionrunpayload)
**multipart/form-data**: [CompletionRunPayload](#completionrunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + ### [GET] /apps/{app_id}/dependencies:check #### Parameters @@ -149,15 +221,22 @@ Upload a file to use as an input variable when running the app | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: [FileUploadPayload](#fileuploadpayload)
| + #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | | 201 | File uploaded successfully | **application/json**: [FileResponse](#fileresponse)
| -| 400 | Bad request — no file, multiple files, invalid filename, or blocked extension | | +| 400 | Bad request — invalid filename or blocked extension | | | 401 | Unauthorized — invalid or expired bearer token | | | 413 | File too large | | | 415 | Unsupported file type | | +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| ### [GET] /apps/{app_id}/human-input-forms/{form_token} @@ -187,7 +266,7 @@ Upload a file to use as an input variable when running the app | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [HumanInputFormSubmitPayload](#humaninputformsubmitpayload)
| +| Yes | **application/json**: [OpenApiFormSubmitPayload](#openapiformsubmitpayload)
**multipart/form-data**: [OpenApiFormSubmitPayload](#openapiformsubmitpayload)
| **application/json**: [OpenApiFormSubmitPayload](#openapiformsubmitpayload)
**multipart/form-data**: [OpenApiFormSubmitPayload](#openapiformsubmitpayload)
| #### Responses @@ -212,6 +291,7 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | SSE event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| ### [POST] /apps/{app_id}/tasks/{task_id}:stop @@ -229,7 +309,7 @@ Upload a file to use as an input variable when running the app | 200 | Task stopped | **application/json**: [TaskStopResponse](#taskstopresponse)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| -### [POST] /apps/{app_id}:run +### [POST] /apps/{app_id}/workflow:run #### Parameters | Name | Located in | Description | Required | Schema | @@ -240,7 +320,7 @@ Upload a file to use as an input variable when running the app | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [AppRunRequest](#apprunrequest)
| +| Yes | **application/json**: [WorkflowRunPayload](#workflowrunpayload)
**multipart/form-data**: [WorkflowRunPayload](#workflowrunpayload)
| **application/json**: [WorkflowRunPayload](#workflowrunpayload)
**multipart/form-data**: [WorkflowRunPayload](#workflowrunpayload)
| #### Responses @@ -350,11 +430,19 @@ Upload a file to use as an input variable when running the app | default | Error | **application/json**: [ErrorBody](#errorbody)
| ### [GET] /workspaces +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | + #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Workspace list | **application/json**: [WorkspaceListResponse](#workspacelistresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| ### [GET] /workspaces/{workspace_id} @@ -388,9 +476,9 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Import completed | **application/json**: [Import](#import)
| -| 202 | Import pending confirmation | **application/json**: [Import](#import)
| -| 400 | Import failed | **application/json**: [Import](#import)
| +| 200 | Import completed | **application/json**: [AppDslImportResponse](#appdslimportresponse)
| +| 202 | Import pending confirmation | **application/json**: [AppDslImportResponse](#appdslimportresponse)
| +| 400 | Import failed | **application/json**: [AppDslImportResponse](#appdslimportresponse)
| | 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| @@ -521,6 +609,21 @@ Upload a file to use as an input variable when running the app | subject_type | [SubjectType](#subjecttype) | | Yes | | workspaces | [ [WorkspacePayload](#workspacepayload) ],
**Default:** | | No | +#### AdvancedChatRunPayload + +A chat run against an advanced-chat (chatflow) app, which can also pin a workflow version. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No | +| auto_generate_name | boolean,
**Default:** true | Let the server name a new conversation | No | +| conversation_id | string | Continue an existing conversation | No | +| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[]`. Send a list (part name `files[][]`) for a file-list variable | No | +| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes | +| query | string | User message | Yes | +| workflow_id | string | Pin a published workflow version | No | +| workspace_id | string | Workspace that owns the app | No | + #### AppDescribeInfo | Name | Type | Description | Required | @@ -584,6 +687,23 @@ Request body for POST /workspaces//apps/imports. | yaml_content | string | Inline YAML DSL string (required when mode is yaml-content) | No | | yaml_url | string | Remote URL to fetch YAML from (required when mode is yaml-url) | No | +#### AppDslImportResponse + +`Import` plus the server-built next step for a pending import. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | No | +| app_mode | string | | No | +| current_dsl_version | string,
**Default:** 0.7.0 | | No | +| error | string | | No | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| id | string | | Yes | +| imported_dsl_version | string | | No | +| permission_keys | [ string ] | | No | +| status | [ImportStatus](#importstatus) | | Yes | +| warnings | [ [DslImportWarning](#dslimportwarning) ] | | No | + #### AppInfo | Name | Type | Description | Required | @@ -611,6 +731,7 @@ mode is a closed enum of listable app types. | ---- | ---- | ----------- | -------- | | data | [ [AppListRow](#applistrow) ] | | Yes | | has_more | boolean | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | | limit | integer | | Yes | | page | integer | | Yes | | total | integer | | Yes | @@ -633,17 +754,17 @@ mode is a closed enum of listable app types. | ---- | ---- | ----------- | -------- | | AppMode | string | | | -#### AppRunRequest +#### ChatRunPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| auto_generate_name | boolean,
**Default:** true | | No | -| conversation_id | string | | No | -| files | [ object ] | | No | -| inputs | object | | Yes | -| query | string | | No | -| workflow_id | string | | No | -| workspace_id | string | | No | +| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No | +| auto_generate_name | boolean,
**Default:** true | Let the server name a new conversation | No | +| conversation_id | string | Continue an existing conversation | No | +| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[]`. Send a list (part name `files[][]`) for a file-list variable | No | +| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes | +| query | string | User message | Yes | +| workspace_id | string | Workspace that owns the app | No | #### CheckDependenciesResult @@ -651,6 +772,16 @@ mode is a closed enum of listable app types. | ---- | ---- | ----------- | -------- | | leaked_dependencies | [ [PluginDependency](#plugindependency) ] | | No | +#### CompletionRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No | +| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[]`. Send a list (part name `files[][]`) for a file-list variable | No | +| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes | +| query | string | Prompt text; most completion apps take their input through `inputs` | No | +| workspace_id | string | Workspace that owns the app | No | + #### DeploymentEdition Enum representing the deployment edition of the platform. @@ -782,6 +913,12 @@ future server adds a code. Formatter tests pin emitted values to the enum. | tenant_id | string | | No | | user_id | string | | No | +#### FileUploadPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| file | binary | The file to upload; its id can then be used in an app run's file variables | Yes | + #### FormSubmitResponse Empty 200 body for POST /apps//human-input-forms/:submit. `extra='forbid'` @@ -808,6 +945,17 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required. | ---- | ---- | ----------- | -------- | | ok | boolean | | Yes | +#### Hint + +A next step the caller can hand straight to `call --input `. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| form | [ object ] | Form fields behind `input.inputs`, copied from the pausing event | No | +| input | object | Ready-to-send input for `op`; unknown values are null | Yes | +| op | string | | Yes | +| summary | string | | Yes | + #### HumanInputFormDefinitionResponse | Name | Type | Description | Required | @@ -818,13 +966,6 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required. | resolved_default_values | object | | Yes | | user_actions | [ object ] | | No | -#### HumanInputFormSubmitPayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| action | string | ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list. | Yes | -| inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | - #### Import | Name | Type | Description | Required | @@ -897,6 +1038,7 @@ Strict (extra='forbid'). | ---- | ---- | ----------- | -------- | | data | [ [MemberResponse](#memberresponse) ] | | Yes | | has_more | boolean | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | | limit | integer | | Yes | | page | integer | | Yes | | total | integer | | Yes | @@ -931,6 +1073,16 @@ Strict (extra='forbid'). | ---- | ---- | ----------- | -------- | | OpenApiErrorCode | string | | | +#### OpenApiFormSubmitPayload + +The console payload plus local file parts; `_files.merge_files` sets them on `inputs`. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string | ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list. | Yes | +| files | object | Local files keyed by the form's file input name, same convention as the run ops' `files` | No | +| inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | + #### Package | Name | Type | Description | Required | @@ -955,6 +1107,7 @@ Strict (extra='forbid'). | ---- | ---- | ----------- | -------- | | data | [ [AppListRow](#applistrow) ] | | Yes | | has_more | boolean | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | | limit | integer | | Yes | | page | integer | | Yes | | total | integer | | Yes | @@ -1003,6 +1156,7 @@ Pagination for GET /account/sessions. Strict (extra='forbid'). | ---- | ---- | ----------- | -------- | | data | [ [SessionRow](#sessionrow) ] | | Yes | | has_more | boolean | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | | limit | integer | | Yes | | page | integer | | Yes | | total | integer | | Yes | @@ -1082,6 +1236,16 @@ types it as a required `'success'` rather than an optional field. | total_tokens | integer | | No | | workflow_id | string | | Yes | +#### WorkflowRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No | +| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[]`. Send a list (part name `files[][]`) for a file-list variable | No | +| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes | +| workflow_id | string | Pin a published workflow version | No | +| workspace_id | string | Workspace that owns the app | No | + #### WorkspaceDetailResponse | Name | Type | Description | Required | @@ -1093,11 +1257,25 @@ types it as a required `'success'` rather than an optional field. | role | string | | Yes | | status | string | | Yes | +#### WorkspaceListQuery + +Strict (extra='forbid'). + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | + #### WorkspaceListResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| workspaces | [ [WorkspaceSummaryResponse](#workspacesummaryresponse) ] | | Yes | +| data | [ [WorkspaceSummaryResponse](#workspacesummaryresponse) ] | | Yes | +| has_more | boolean | | Yes | +| hints | [ [Hint](#hint) ] | Next steps the caller can take | No | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | #### WorkspacePayload diff --git a/api/pyproject.toml b/api/pyproject.toml index 351b866208b..e1b9f8d9cda 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -52,11 +52,6 @@ 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 = [] @@ -191,6 +186,7 @@ dev = [ "types-pytz>=2026.3.1.20260727", "types-bleach>=6.4.0.20260728", "types-croniter>=6.2.4.20260711", + "types-jsonschema>=4.26.0.20260518", ] ############################################################ diff --git a/api/tests/integration_tests/controllers/openapi/conftest.py b/api/tests/integration_tests/controllers/openapi/conftest.py index 7b7e4f5bdac..86e1e30b5fc 100644 --- a/api/tests/integration_tests/controllers/openapi/conftest.py +++ b/api/tests/integration_tests/controllers/openapi/conftest.py @@ -10,6 +10,7 @@ from datetime import UTC, datetime, timedelta import pytest from flask import Flask +from controllers.openapi._catalog import CATALOG_HEADER, catalog_for from enums import DeploymentEdition from extensions.ext_database import db from extensions.ext_redis import redis_client @@ -113,6 +114,12 @@ def account_token(workspace_account, mint_token) -> str: return token +@pytest.fixture +def auth_headers(flask_app: Flask, account_token: str) -> dict[str, str]: + """What every guarded request needs: the bearer, and the fingerprint of the catalog it was built from.""" + return {"Authorization": f"Bearer {account_token}", CATALOG_HEADER: catalog_for(flask_app)[1]} + + @pytest.fixture(autouse=True) def _flush_auth_redis(flask_app: Flask) -> Generator[None, None, None]: def _flush(): diff --git a/api/tests/integration_tests/controllers/openapi/test_app_run.py b/api/tests/integration_tests/controllers/openapi/test_app_run.py index 8b77b99ccf4..21224c7963b 100644 --- a/api/tests/integration_tests/controllers/openapi/test_app_run.py +++ b/api/tests/integration_tests/controllers/openapi/test_app_run.py @@ -1,4 +1,4 @@ -"""Integration tests for POST /openapi/v1/apps/:run.""" +"""Integration tests for the per-mode run routes under POST /openapi/v1/apps/.""" from __future__ import annotations @@ -13,8 +13,8 @@ from extensions.ext_database import db from models import App -def test_run_chat_dispatches_to_chat_handler( - flask_app: Flask, account_token, app_in_workspace, monkeypatch: pytest.MonkeyPatch +def test_chat_run_reaches_the_generator( + flask_app: Flask, auth_headers, app_in_workspace, monkeypatch: pytest.MonkeyPatch ): captured = {} @@ -36,15 +36,25 @@ 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", - json={"inputs": {}, "query": "hi", "response_mode": "blocking", "user": "spoof@x.com"}, - headers={"Authorization": f"Bearer {account_token}"}, + f"/openapi/v1/apps/{app_in_workspace.id}/chat:run", + json={"inputs": {}, "query": "hi"}, + headers=auth_headers, ) assert res.status_code == 200 assert res.get_json()["mode"] == "chat" assert captured["mode"] == "chat" assert captured["invoke_from"] == InvokeFrom.OPENAPI - assert "user" not in captured["args"], "server must strip body.user; identity comes from bearer" + + +def test_chat_run_refuses_a_body_naming_the_caller(flask_app: Flask, auth_headers, app_in_workspace): + """Identity comes from the bearer; a `user` field is a foreign key and fails validation.""" + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app_in_workspace.id}/chat:run", + json={"inputs": {}, "query": "hi", "user": "spoof@x.com"}, + headers=auth_headers, + ) + assert res.status_code == 422 @pytest.fixture @@ -80,21 +90,18 @@ def app_with_mode(flask_app: Flask, workspace_account): db.session.commit() -def test_run_chat_without_query_returns_422( - flask_app: Flask, account_token, app_in_workspace, monkeypatch: pytest.MonkeyPatch -): +def test_chat_run_without_query_returns_422(flask_app: Flask, auth_headers, app_in_workspace): client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app_in_workspace.id}:run", - json={"inputs": {}, "response_mode": "blocking"}, - headers={"Authorization": f"Bearer {account_token}"}, + f"/openapi/v1/apps/{app_in_workspace.id}/chat:run", + json={"inputs": {}}, + headers=auth_headers, ) assert res.status_code == 422 - assert b"query_required_for_chat" in res.data -def test_run_completion_dispatches_to_completion_handler( - flask_app: Flask, account_token, app_with_mode, monkeypatch: pytest.MonkeyPatch +def test_completion_run_reaches_the_generator( + flask_app: Flask, auth_headers, app_with_mode, monkeypatch: pytest.MonkeyPatch ): app = app_with_mode("completion") @@ -116,31 +123,28 @@ 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", - json={"inputs": {}, "response_mode": "blocking"}, - headers={"Authorization": f"Bearer {account_token}"}, + f"/openapi/v1/apps/{app.id}/completion:run", + json={"inputs": {}}, + headers=auth_headers, ) assert res.status_code == 200 assert res.get_json()["mode"] == "completion" assert captured["mode"] == "completion" -def test_run_workflow_with_query_returns_422( - flask_app: Flask, account_token, app_with_mode, monkeypatch: pytest.MonkeyPatch -): +def test_workflow_run_with_query_returns_422(flask_app: Flask, auth_headers, app_with_mode): app = app_with_mode("workflow") client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app.id}:run", - json={"inputs": {}, "query": "hi", "response_mode": "blocking"}, - headers={"Authorization": f"Bearer {account_token}"}, + f"/openapi/v1/apps/{app.id}/workflow:run", + json={"inputs": {}, "query": "hi"}, + headers=auth_headers, ) assert res.status_code == 422 - assert b"query_not_supported_for_workflow" in res.data -def test_run_workflow_no_query_dispatches_to_workflow_handler( - flask_app: Flask, account_token, app_with_mode, monkeypatch: pytest.MonkeyPatch +def test_workflow_run_reaches_the_generator( + flask_app: Flask, auth_headers, app_with_mode, monkeypatch: pytest.MonkeyPatch ): app = app_with_mode("workflow") @@ -154,41 +158,38 @@ 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", - json={"inputs": {}, "response_mode": "blocking"}, - headers={"Authorization": f"Bearer {account_token}"}, + f"/openapi/v1/apps/{app.id}/workflow:run", + json={"inputs": {}}, + headers=auth_headers, ) assert res.status_code == 200 body = res.get_json() - assert body["mode"] == "workflow" assert body["workflow_run_id"] == "wfr" -def test_run_unsupported_mode_returns_422( - flask_app: Flask, account_token, app_with_mode, monkeypatch: pytest.MonkeyPatch -): +def test_run_route_of_another_mode_returns_422(flask_app: Flask, auth_headers, app_with_mode): app = app_with_mode("channel") client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app.id}:run", - json={"inputs": {}, "response_mode": "blocking"}, - headers={"Authorization": f"Bearer {account_token}"}, + f"/openapi/v1/apps/{app.id}/chat:run", + json={"inputs": {}, "query": "hi"}, + headers=auth_headers, ) assert res.status_code == 422 - assert b"mode_not_runnable" in res.data + assert b"app_mode_mismatch" in res.data 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}/chat:run", json={"inputs": {}, "query": "hi"}, ) assert res.status_code == 401 def test_run_with_insufficient_scope_returns_403( - flask_app: Flask, account_token, app_in_workspace, monkeypatch: pytest.MonkeyPatch + flask_app: Flask, auth_headers, app_in_workspace, monkeypatch: pytest.MonkeyPatch ): """Stub the authenticator to return an AuthContext with empty scopes.""" from libs import oauth_bearer @@ -205,25 +206,25 @@ 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}/chat:run", json={"inputs": {}, "query": "hi"}, - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert res.status_code == 403 -def test_run_with_unknown_app_returns_404(flask_app: Flask, account_token): +def test_run_with_unknown_app_returns_404(flask_app: Flask, auth_headers): client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{uuid.uuid4()}:run", + f"/openapi/v1/apps/{uuid.uuid4()}/chat:run", json={"inputs": {}, "query": "hi"}, - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert res.status_code == 404 def test_run_streaming_returns_event_stream( - flask_app: Flask, account_token, app_in_workspace, monkeypatch: pytest.MonkeyPatch + flask_app: Flask, auth_headers, app_in_workspace, monkeypatch: pytest.MonkeyPatch ): def _stream() -> Generator[str, None, None]: yield 'event: message\ndata: {"x": 1}\n\n' @@ -235,20 +236,20 @@ def test_run_streaming_returns_event_stream( client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app_in_workspace.id}:run", - json={"inputs": {}, "query": "hi", "response_mode": "streaming"}, - headers={"Authorization": f"Bearer {account_token}"}, + f"/openapi/v1/apps/{app_in_workspace.id}/chat:run", + json={"inputs": {}, "query": "hi"}, + headers=auth_headers, ) assert res.status_code == 200 assert res.headers["Content-Type"].startswith("text/event-stream") assert b"event: message" in res.data -def test_run_without_inputs_returns_422(flask_app: Flask, account_token, app_in_workspace): +def test_run_without_inputs_returns_422(flask_app: Flask, auth_headers, 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}/chat:run", json={"query": "hi"}, - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert res.status_code == 422 diff --git a/api/tests/integration_tests/controllers/openapi/test_apps.py b/api/tests/integration_tests/controllers/openapi/test_apps.py index ab992d1c5eb..cf9098f7695 100644 --- a/api/tests/integration_tests/controllers/openapi/test_apps.py +++ b/api/tests/integration_tests/controllers/openapi/test_apps.py @@ -7,26 +7,26 @@ from flask.testing import FlaskClient from models import App -def test_apps_bare_id_route_404(test_client, app_in_workspace, account_token): +def test_apps_bare_id_route_404(test_client, app_in_workspace, auth_headers): resp = test_client.get( f"/openapi/v1/apps/{app_in_workspace.id}", - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert resp.status_code == 404 -def test_apps_parameters_route_404(test_client, app_in_workspace, account_token): +def test_apps_parameters_route_404(test_client, app_in_workspace, auth_headers): resp = test_client.get( f"/openapi/v1/apps/{app_in_workspace.id}/parameters", - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert resp.status_code == 404 -def test_apps_info_route_404(test_client, app_in_workspace, account_token): +def test_apps_info_route_404(test_client, app_in_workspace, auth_headers): resp = test_client.get( f"/openapi/v1/apps/{app_in_workspace.id}/info", - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert resp.status_code == 404 @@ -34,11 +34,11 @@ def test_apps_info_route_404(test_client, app_in_workspace, account_token): def test_apps_describe_returns_merged_shape( test_client: FlaskClient, app_in_workspace: App, - account_token: str, + auth_headers: dict[str, str], ): res = test_client.get( f"/openapi/v1/apps/{app_in_workspace.id}", - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert res.status_code == 200 body = res.json @@ -50,11 +50,11 @@ def test_apps_describe_returns_merged_shape( def test_apps_describe_full_includes_input_schema( test_client: FlaskClient, app_in_workspace: App, - account_token: str, + auth_headers: dict[str, str], ): res = test_client.get( f"/openapi/v1/apps/{app_in_workspace.id}", - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert res.status_code == 200 body = res.json @@ -67,11 +67,11 @@ def test_apps_describe_full_includes_input_schema( def test_apps_describe_fields_info_only( test_client: FlaskClient, app_in_workspace: App, - account_token: str, + auth_headers: dict[str, str], ): res = test_client.get( f"/openapi/v1/apps/{app_in_workspace.id}?fields=info", - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert res.status_code == 200 body = res.json @@ -83,11 +83,11 @@ def test_apps_describe_fields_info_only( def test_apps_describe_fields_parameters_only( test_client: FlaskClient, app_in_workspace: App, - account_token: str, + auth_headers: dict[str, str], ): res = test_client.get( f"/openapi/v1/apps/{app_in_workspace.id}?fields=parameters", - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert res.status_code == 200 body = res.json @@ -99,11 +99,11 @@ def test_apps_describe_fields_parameters_only( def test_apps_describe_fields_input_schema_only( test_client: FlaskClient, app_in_workspace: App, - account_token: str, + auth_headers: dict[str, str], ): res = test_client.get( f"/openapi/v1/apps/{app_in_workspace.id}?fields=input_schema", - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert res.status_code == 200 body = res.json @@ -115,11 +115,11 @@ def test_apps_describe_fields_input_schema_only( def test_apps_describe_fields_combined( test_client: FlaskClient, app_in_workspace: App, - account_token: str, + auth_headers: dict[str, str], ): res = test_client.get( f"/openapi/v1/apps/{app_in_workspace.id}?fields=info,input_schema", - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert res.status_code == 200 body = res.json @@ -131,11 +131,11 @@ def test_apps_describe_fields_combined( def test_apps_describe_fields_unknown_returns_422( test_client: FlaskClient, app_in_workspace: App, - account_token: str, + auth_headers: dict[str, str], ): res = test_client.get( f"/openapi/v1/apps/{app_in_workspace.id}?fields=garbage", - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert res.status_code == 422 @@ -143,11 +143,11 @@ def test_apps_describe_fields_unknown_returns_422( def test_apps_describe_fields_extra_param_returns_422( test_client: FlaskClient, app_in_workspace: App, - account_token: str, + auth_headers: dict[str, str], ): res = test_client.get( f"/openapi/v1/apps/{app_in_workspace.id}?fields=info&page=1", - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert res.status_code == 422 @@ -156,12 +156,12 @@ def test_apps_list_returns_pagination_envelope( test_client: FlaskClient, workspace_account, app_in_workspace: App, - account_token: str, + auth_headers: dict[str, str], ): _, tenant, _ = workspace_account res = test_client.get( f"/openapi/v1/apps?workspace_id={tenant.id}&page=1&limit=20", - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert res.status_code == 200 body = res.json @@ -171,8 +171,8 @@ def test_apps_list_returns_pagination_envelope( assert any(d["id"] == app_in_workspace.id for d in body["data"]) -def test_apps_list_requires_workspace_id(test_client: FlaskClient, account_token: str): - res = test_client.get("/openapi/v1/apps", headers={"Authorization": f"Bearer {account_token}"}) +def test_apps_list_requires_workspace_id(test_client: FlaskClient, auth_headers: dict[str, str]): + res = test_client.get("/openapi/v1/apps", headers=auth_headers) assert res.status_code == 400 @@ -180,12 +180,12 @@ def test_apps_list_tag_no_match_returns_empty_data_not_400( test_client: FlaskClient, workspace_account, app_in_workspace: App, - account_token: str, + auth_headers: dict[str, str], ): _, tenant, _ = workspace_account res = test_client.get( f"/openapi/v1/apps?workspace_id={tenant.id}&tag=nonexistent", - headers={"Authorization": f"Bearer {account_token}"}, + headers=auth_headers, ) assert res.status_code == 200 assert res.json["data"] == [] @@ -193,9 +193,9 @@ def test_apps_list_tag_no_match_returns_empty_data_not_400( def test_account_sessions_returns_envelope( test_client: FlaskClient, - account_token: str, + auth_headers: dict[str, str], ): - res = test_client.get("/openapi/v1/account/sessions", headers={"Authorization": f"Bearer {account_token}"}) + res = test_client.get("/openapi/v1/account/sessions", headers=auth_headers) assert res.status_code == 200 body = res.json # canonical envelope shape diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py index 45079c1ee7e..f340025bc4c 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py @@ -5,7 +5,9 @@ from io import BytesIO from flask import Flask from sqlalchemy.orm import Session +from werkzeug.datastructures import FileStorage +from controllers.openapi._models import FileUploadPayload from controllers.openapi.files import AppFileUploadApi from models import Account, App from services.app_service import AppService, CreateAppParams @@ -40,17 +42,15 @@ class TestAppFileUpload: content = b"hello integration world" api = AppFileUploadApi() - data = {"file": (BytesIO(content), "note.txt", "text/plain")} - with app.test_request_context( - f"/openapi/v1/apps/{app_model.id}/files", - method="POST", - data=data, - content_type="multipart/form-data", - ): + body = FileUploadPayload( + file=FileStorage(stream=BytesIO(content), filename="note.txt", content_type="text/plain") + ) + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/files", method="POST"): result = api.post.__handler__( api, context_for(account, session=db_session_with_containers, view_args={"app_id": app_model.id}), app_model.id, + body=body, ) assert result.id diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py index ebe58bef716..b082c4197d4 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py @@ -7,6 +7,7 @@ from flask import Flask from sqlalchemy.orm import Session from werkzeug.exceptions import NotFound +from controllers.openapi._models import WorkspaceListQuery from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspacesApi, WorkspaceSwitchApi from models import Account from models.account import TenantAccountRole @@ -23,11 +24,13 @@ class TestWorkspacesList: api = WorkspacesApi() with app.test_request_context("/openapi/v1/workspaces"): - result = api.get.__handler__(api, context_for(account, session=db_session_with_containers)) + result = api.get.__handler__( + api, context_for(account, session=db_session_with_containers), query=WorkspaceListQuery() + ) - ids = {w.id for w in result.workspaces} + ids = {w.id for w in result.data} assert ids == {owner_tenant.id} - only = result.workspaces[0] + only = result.data[0] assert only.role == TenantAccountRole.OWNER.value assert only.status == "normal" # Newly-created owner membership is not yet "current"; switching flips it @@ -44,9 +47,11 @@ class TestWorkspacesList: api = WorkspacesApi() with app.test_request_context("/openapi/v1/workspaces"): - result = api.get.__handler__(api, context_for(account, session=db_session_with_containers)) + result = api.get.__handler__( + api, context_for(account, session=db_session_with_containers), query=WorkspaceListQuery() + ) - assert {w.id for w in result.workspaces} == {owner_tenant.id, second.id} + assert {w.id for w in result.data} == {owner_tenant.id, second.id} class TestWorkspaceDetail: @@ -121,8 +126,10 @@ class TestWorkspaceSwitch: # longer current (verified through the real read path). listing_api = WorkspacesApi() with app.test_request_context("/openapi/v1/workspaces"): - listing = listing_api.get.__handler__(listing_api, context_for(account, session=db_session_with_containers)) - by_id = {w.id: w for w in listing.workspaces} + listing = listing_api.get.__handler__( + listing_api, context_for(account, session=db_session_with_containers), query=WorkspaceListQuery() + ) + by_id = {w.id: w for w in listing.data} assert by_id[target.id].current is True assert by_id[owner_tenant.id].current is False diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_pipelines.py b/api/tests/unit_tests/controllers/openapi/auth/test_pipelines.py index 283542def02..298bb01aea5 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_pipelines.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_pipelines.py @@ -9,12 +9,15 @@ from flask import Flask from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden, Unauthorized +from controllers.openapi._catalog import CATALOG_HEADER, catalog_for +from controllers.openapi._errors import CatalogStale from controllers.openapi.auth.context import Context from controllers.openapi.auth.pipelines import ( _PIPELINES, AccountPipeline, ExternalSsoPipeline, Pipeline, + _RequiresCurrentCatalog, ) from controllers.openapi.auth.requirements import ( CheckAppAccess, @@ -24,7 +27,7 @@ from controllers.openapi.auth.requirements import ( Rank, Requirement, ) -from controllers.openapi.auth.spec import EndpointSpec +from controllers.openapi.auth.spec import CatalogMeta, EndpointSpec, Kind from controllers.openapi.auth.subjects import _SUBJECT_CLASSES, AccountSubject, Subject from enums import DeploymentEdition from libs.oauth_bearer import AuthContext, try_get_auth_ctx @@ -79,6 +82,10 @@ class _NoFixed(Pipeline): pass +def _current_catalog(app: Flask) -> dict[str, str]: + return {CATALOG_HEADER: catalog_for(app)[1]} + + def _run( pipeline: Pipeline, subject: Subject, @@ -91,7 +98,9 @@ def _run( return pipeline.run( subject=subject, auth=subject.auth, - spec=EndpointSpec(requirements=requirements), + spec=EndpointSpec( + requirements=requirements, catalog=CatalogMeta(op="test.op", kind=Kind.OBJECT, summary="test") + ), ctx=ctx, session=session, call=call, @@ -151,6 +160,7 @@ def test_auth_ctx_is_published_for_the_view_and_reset_after_it( def test_a_caller_that_cannot_be_resolved_leaves_the_auth_ctx_unset( + app: Flask, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -162,13 +172,15 @@ def test_a_caller_that_cannot_be_resolved_leaves_the_auth_ctx_unset( monkeypatch.setattr(MOUNT, never_reached) subject = account_subject() - with pytest.raises(Unauthorized, match="account not found"): - _run(AccountPipeline(), subject, make_ctx(sqlite_session, subject), sqlite_session) + with app.test_request_context("/openapi/v1/account", headers=_current_catalog(app)): + with pytest.raises(Unauthorized, match="account not found"): + _run(AccountPipeline(), subject, make_ctx(sqlite_session, subject), sqlite_session) assert try_get_auth_ctx() is None def test_the_requirements_that_share_a_datum_fetch_it_once( + app: Flask, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -181,6 +193,7 @@ def test_the_requirements_that_share_a_datum_fetch_it_once( subject = account_subject() with ( + app.test_request_context(f"/openapi/v1/apps/{APP_ID}", headers=_current_catalog(app)), patch.object(AppService, "get_app_by_id", wraps=AppService.get_app_by_id) as app_fetch, patch.object(TenantService, "get_tenant_by_id", wraps=TenantService.get_tenant_by_id) as workspace_fetch, patch.object(AccountService, "get_account_by_id", wraps=AccountService.get_account_by_id) as caller_fetch, @@ -226,7 +239,7 @@ def test_a_refused_sso_request_never_creates_an_end_user( subject = sso_subject() ctx = make_ctx(sqlite_session, subject, app_id=APP_ID) - with app.test_request_context(f"/openapi/v1/apps/{APP_ID}:run"): + with app.test_request_context(f"/openapi/v1/apps/{APP_ID}:run", headers=_current_catalog(app)): with patch(FEATURES, return_value=system_features(webapp_auth=webapp_auth)): with patch(ACCESS_MODE, return_value=webapp_settings(WebAppAccessMode.PRIVATE_ALL.value)): with pytest.raises(Forbidden, match=message): @@ -237,3 +250,32 @@ def test_a_refused_sso_request_never_creates_an_end_user( def test_every_registrable_subject_has_a_pipeline() -> None: assert set(_SUBJECT_CLASSES.values()) == set(_PIPELINES) + + +def test_every_pipeline_checks_the_catalog_before_anything_else() -> None: + """Fixed first and in the first band, so nothing a route declares below + `Rank.FIRST` - and nothing that mints a row - runs on a stale catalog. + """ + assert all(isinstance(pipeline.fixed[0], _RequiresCurrentCatalog) for pipeline in _PIPELINES.values()) + assert _RequiresCurrentCatalog.rank is Rank.FIRST + + +@pytest.mark.parametrize("sent", [None, "not-the-current-catalog"], ids=["missing", "stale"]) +def test_a_request_built_from_another_catalog_is_refused( + app: Flask, + sqlite_session: Session, + sent: str | None, +) -> None: + subject = account_subject() + headers: dict[str, str] = {} if sent is None else {CATALOG_HEADER: sent} + + with app.test_request_context("/openapi/v1/account", headers=headers): + with pytest.raises(CatalogStale): + _RequiresCurrentCatalog().run(subject, make_ctx(sqlite_session, subject), sqlite_session) + + +def test_a_request_built_from_the_current_catalog_passes(app: Flask, sqlite_session: Session) -> None: + subject = account_subject() + + with app.test_request_context("/openapi/v1/account", headers=_current_catalog(app)): + _RequiresCurrentCatalog().run(subject, make_ctx(sqlite_session, subject), sqlite_session) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_router.py b/api/tests/unit_tests/controllers/openapi/auth/test_router.py index 14afc86d260..d4d6b64dbf2 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_router.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_router.py @@ -12,10 +12,11 @@ from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import Forbidden, NotFound, ServiceUnavailable, Unauthorized import libs.rate_limit as rate_limit_module +from controllers.openapi._catalog import CATALOG_HEADER, catalog_for from controllers.openapi.auth.context import Context from controllers.openapi.auth.requirements import Requirement from controllers.openapi.auth.router import subject_router -from controllers.openapi.auth.spec import EndpointSpec +from controllers.openapi.auth.spec import CatalogMeta, EndpointSpec, Kind from controllers.openapi.auth.subjects import AccountSubject from enums import DeploymentEdition from libs.oauth_bearer import ( @@ -61,13 +62,23 @@ def _guard( requirements: tuple[Requirement, ...] = (), edition: frozenset[DeploymentEdition] | None = None, ) -> Callable[..., object]: - return subject_router.guard(EndpointSpec(requirements=requirements, edition=edition))(view) + return subject_router.guard( + EndpointSpec( + requirements=requirements, + edition=edition, + catalog=CatalogMeta(op="test.op", kind=Kind.OBJECT, summary="test"), + ) + )(view) def _nothing(**_kwargs: object) -> None: return None +def _admitted(app: Flask) -> dict[str, str]: + return {"Authorization": "Bearer tok", CATALOG_HEADER: catalog_for(app)[1]} + + def test_endpoint_edition_gate_404s_before_the_bearer_is_read( app: Flask, config_overrides: Callable[..., None], @@ -303,7 +314,7 @@ def test_an_account_token_reaches_the_view_with_a_resolved_context( view = _guard(handler) - with app.test_request_context("/openapi/v1/account", headers={"Authorization": "Bearer tok"}): + with app.test_request_context("/openapi/v1/account", headers=_admitted(app)): result = view("self") assert result == "answered" @@ -329,7 +340,7 @@ def test_a_mutation_is_committed_when_the_view_returns( monkeypatch.setattr(MOUNT, lambda _user: None) view = _guard(_rename_handler) - with app.test_request_context("/openapi/v1/account", headers={"Authorization": "Bearer tok"}): + with app.test_request_context("/openapi/v1/account", headers=_admitted(app)): view() with sqlite_session_factory() as verify: @@ -354,7 +365,7 @@ def test_a_mutation_is_rolled_back_when_the_view_raises( monkeypatch.setattr(MOUNT, lambda _user: None) view = _guard(_rename_then_fail) - with app.test_request_context("/openapi/v1/account", headers={"Authorization": "Bearer tok"}): + with app.test_request_context("/openapi/v1/account", headers=_admitted(app)): with pytest.raises(RuntimeError, match="after the write"): view() diff --git a/api/tests/unit_tests/controllers/openapi/conftest.py b/api/tests/unit_tests/controllers/openapi/conftest.py index 5368d88c70f..14b9681faac 100644 --- a/api/tests/unit_tests/controllers/openapi/conftest.py +++ b/api/tests/unit_tests/controllers/openapi/conftest.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import Session from app_factory import create_flask_app_with_configs from controllers.openapi import bp as openapi_bp +from controllers.openapi._catalog import CATALOG_HEADER, catalog_for from enums import DeploymentEdition from libs.oauth_bearer import AuthContext, TokenType from models import Account, App, Tenant, TenantAccountJoin @@ -118,5 +119,5 @@ def admitted_bearer( workspace_id=workspace_id, app_id=app_id, member_id=str(uuid.uuid4()), - headers={"Authorization": "Bearer dfoa_admitted"}, + headers={"Authorization": "Bearer dfoa_admitted", CATALOG_HEADER: catalog_for(openapi_app)[1]}, ) diff --git a/api/tests/unit_tests/controllers/openapi/test_app_dsl.py b/api/tests/unit_tests/controllers/openapi/test_app_dsl.py index 6b75a179a35..f6c010091bf 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_dsl.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_dsl.py @@ -9,10 +9,13 @@ from sqlalchemy.engine import Engine from werkzeug.exceptions import Forbidden from controllers.openapi import app_dsl as app_dsl_module -from controllers.openapi._models import AppDslImportPayload +from controllers.openapi._contract import op_of +from controllers.openapi._models import AppDslImportPayload, AppDslImportResponse, Hint from controllers.openapi.app_dsl import AppDslImportApi, AppDslImportConfirmApi from controllers.openapi.auth.spec import EndpointSpec from models import Account +from services.app_dsl_service import Import +from services.entities.dsl_entities import ImportStatus from services.errors.account import NoPermissionError @@ -20,7 +23,7 @@ class _EndpointView(Protocol): """Structural stand-in for a `view` carrying the attributes `@endpoint` attaches.""" __spec__: EndpointSpec - __handler__: Callable[..., object] + __handler__: Callable[..., tuple[AppDslImportResponse, int]] @pytest.mark.parametrize( @@ -57,3 +60,41 @@ def test_permission_denial_maps_to_forbidden( cast(_EndpointView, api.post).__handler__(api, SimpleNamespace(account=Mock(spec=Account)), **kwargs) assert isinstance(exc_info.value.__cause__, NoPermissionError) + + +@pytest.mark.parametrize( + ("result", "status", "hints"), + [ + pytest.param( + Import(id="import-1", status=ImportStatus.PENDING), + 202, + [ + Hint( + summary="Confirm the pending import", + op=op_of(AppDslImportConfirmApi.post), + input={"workspace_id": "workspace-1", "import_id": "import-1"}, + ) + ], + id="pending", + ), + pytest.param(Import(id="import-2", status=ImportStatus.COMPLETED, app_id="app-2"), 200, [], id="completed"), + ], +) +def test_import_hints_a_confirm_only_while_pending( + app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, result: Import, status: int, hints: list[Hint] +) -> None: + service = Mock() + service.import_app.return_value = result + monkeypatch.setattr(app_dsl_module, "AppDslService", Mock(return_value=service)) + monkeypatch.setattr(app_dsl_module, "db", SimpleNamespace(engine=sqlite_engine)) + + api = AppDslImportApi() + with app.test_request_context("/openapi/v1/workspaces/workspace-1/apps/imports", method="POST"): + response, code = cast(_EndpointView, api.post).__handler__( + api, + SimpleNamespace(account=Mock(spec=Account)), + workspace_id="workspace-1", + body=AppDslImportPayload(mode="yaml-content", yaml_content="app: {}"), + ) + + assert (code, response.hints) == (status, hints) diff --git a/api/tests/unit_tests/controllers/openapi/test_app_list_query.py b/api/tests/unit_tests/controllers/openapi/test_app_list_query.py index 7cc2149baff..b6376a08b9b 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_list_query.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_list_query.py @@ -15,6 +15,7 @@ from pydantic import ValidationError from controllers.openapi._models import MAX_PAGE_LIMIT from controllers.openapi.apps import AppListQuery +from services.app_service import AppListParams from ._mode_constants import LISTABLE_MODES, NON_LISTABLE_MODES @@ -107,3 +108,8 @@ def test_all_fields_accept_valid_values(): assert q.mode is not None assert q.mode.value == "workflow" assert q.name == "search" + + +def test_max_page_limit_is_accepted_by_the_app_service(): + # The catalog advertises MAX_PAGE_LIMIT; the service model must not reject it. + assert AppListParams(limit=MAX_PAGE_LIMIT).limit == MAX_PAGE_LIMIT diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_dispatch.py b/api/tests/unit_tests/controllers/openapi/test_app_run_dispatch.py deleted file mode 100644 index 11d1fc31b5d..00000000000 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_dispatch.py +++ /dev/null @@ -1,32 +0,0 @@ -import pytest - -from controllers.openapi.app_run import ( - _DISPATCH, - AppRunRequest, -) -from models.model import AppMode - - -def test_dispatch_covers_runnable_modes(): - runnable = {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW} - assert set(_DISPATCH) == runnable - - -def test_app_run_request_strips_blank_conversation_id(): - payload = AppRunRequest(inputs={}, conversation_id=" ") - assert payload.conversation_id is None - - -def test_app_run_request_rejects_invalid_uuid_conversation_id(): - from pydantic import ValidationError - - with pytest.raises(ValidationError, match="conversation_id must be a valid UUID"): - AppRunRequest(inputs={}, conversation_id="not-a-uuid") - - -def test_app_run_request_accepts_valid_uuid_conversation_id(): - import uuid as _uuid - - cid = str(_uuid.uuid4()) - payload = AppRunRequest(inputs={}, conversation_id=cid) - assert payload.conversation_id == cid diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py b/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py index d2787a57591..7d94bae5fab 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py @@ -5,7 +5,7 @@ Both render through the ErrorBody formatter: TooManyRequests -> code "too_many_r """ import pytest -from werkzeug.exceptions import TooManyRequests +from werkzeug.exceptions import BadRequest, TooManyRequests from controllers.openapi.app_run import _translate_service_errors from controllers.service_api.app.error import TriggerWorkflowServiceModeUnavailableError @@ -39,3 +39,13 @@ def test_translate_maps_trigger_workflow_to_stable_unavailable_error(): raise TriggerWorkflowServiceModeUnavailableServiceError() assert exc.value.error_code == "trigger_workflow_service_mode_unavailable" assert exc.value.code == 403 + + +def test_translate_maps_value_error_to_a_fixed_bad_request(): + # Regression guard: an unpublished workflow used to surface as a detail-less 500. + # The service message names internals, so the client gets a fixed text. + with pytest.raises(BadRequest) as exc: + with _translate_service_errors(): + raise ValueError("variable 'secret_key' of node 42 is missing") + assert exc.value.code == 400 + assert exc.value.description == "invalid run input" diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py index fba3e9d91f6..0915ae2acb4 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py @@ -1,17 +1,29 @@ -"""Tests: openapi /run always streams; response_mode removed from AppRunRequest.""" +"""Tests for the /openapi/v1 run routes: per-mode bodies and handlers, task stop.""" from __future__ import annotations +import json import sys import uuid +from io import BytesIO from types import SimpleNamespace from unittest.mock import Mock import pytest from flask import Flask +from pydantic import BaseModel, ValidationError +from werkzeug.datastructures import FileStorage +from werkzeug.exceptions import UnprocessableEntity -from controllers.openapi._models import AppRunRequest, TaskStopResponse -from controllers.openapi.app_run import AppRunApi, AppRunTaskStopApi +from controllers.openapi._models import ( + AdvancedChatRunPayload, + ChatRunPayload, + CompletionRunPayload, + TaskStopResponse, + WorkflowRunPayload, +) +from controllers.openapi.app_run import AdvancedChatRunApi, AppRunTaskStopApi, ChatRunApi, CompletionRunApi +from graphon.file import FileType from models import Account from models.enums import CreatorUserRole from models.model import App, AppMode @@ -38,11 +50,25 @@ def _make_account() -> Account: return account -def test_app_run_request_has_no_response_mode_field(): - """Not a declared field, and a body carrying it is accepted and ignored.""" - assert "response_mode" not in AppRunRequest.model_fields - req = AppRunRequest.model_validate({"inputs": {}, "response_mode": "blocking"}) - assert not hasattr(req, "response_mode") +@pytest.mark.parametrize( + ("model", "payload"), + [ + pytest.param(ChatRunPayload, {"inputs": {}, "query": " "}, id="chat.blank_query"), + pytest.param(WorkflowRunPayload, {"inputs": {}, "query": "x"}, id="workflow.query_is_foreign"), + pytest.param(CompletionRunPayload, {"inputs": {}, "conversation_id": "x"}, id="completion.conversation_id"), + pytest.param(ChatRunPayload, {"inputs": {}, "query": "hi", "response_mode": "blocking"}, id="chat.foreign"), + pytest.param(ChatRunPayload, {"inputs": {}, "query": "hi", "conversation_id": "not-a-uuid"}, id="chat.bad_cid"), + ], +) +def test_per_mode_payloads_reject_what_the_mode_does_not_take(model: type[BaseModel], payload: dict): + with pytest.raises(ValidationError): + model.model_validate(payload) + + +def test_chat_payload_normalizes_conversation_id(): + assert ChatRunPayload(inputs={}, query="hi", conversation_id=" ").conversation_id is None + cid = str(uuid.uuid4()) + assert ChatRunPayload(inputs={}, query="hi", conversation_id=cid).conversation_id == cid def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, monkeypatch: pytest.MonkeyPatch): @@ -85,29 +111,114 @@ class _SealableContext: return self._values[name] -def test_run_reads_everything_off_the_context_before_streaming(app: Flask, monkeypatch: pytest.MonkeyPatch): - """The SSE body runs after the router's session is gone, and the generator - is always asked to stream. - """ - generate_mock = Mock(return_value=iter(["event: a\n\n", "event: b\n\n"])) +def _generate_stub(monkeypatch: pytest.MonkeyPatch, chunks: list[str]) -> Mock: + generate_mock = Mock(return_value=iter(chunks)) class GenerateService: generate = generate_mock monkeypatch.setattr(sys.modules["controllers.openapi.app_run"], "AppGenerateService", GenerateService) + return generate_mock - ctx = _SealableContext( - app=_make_app(), + +def _ctx(mode: AppMode, session: Mock | None = None) -> _SealableContext: + app_model = _make_app() + app_model.mode = mode + return _SealableContext( + app=app_model, caller=_make_account(), - session=Mock(), + session=session or Mock(), subject=SimpleNamespace(caller_role=CreatorUserRole.ACCOUNT), ) - api = AppRunApi() - with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}:run", method="POST"): - response = api.post.__handler__(api, ctx, app_id=_TEST_APP_ID, body=AppRunRequest(inputs={}, query="hello")) + +def test_run_reads_everything_off_the_context_before_streaming(app: Flask, monkeypatch: pytest.MonkeyPatch): + generate_mock = _generate_stub(monkeypatch, ["event: a\n\n", "event: b\n\n"]) + session = Mock() + ctx = _ctx(AppMode.ADVANCED_CHAT, session) + api = AdvancedChatRunApi() + body = AdvancedChatRunPayload(inputs={}, query="hi") + with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/advanced-chat:run", method="POST"): + response = api.post.__handler__(api, ctx, app_id=_TEST_APP_ID, body=body) ctx.seal() body = "".join(response.response) assert body == "event: a\n\nevent: b\n\n" assert generate_mock.call_args.kwargs["streaming"] is True + session.commit.assert_not_called() + + +def test_per_mode_route_refuses_an_app_of_another_mode(app: Flask, monkeypatch: pytest.MonkeyPatch): + generate_mock = _generate_stub(monkeypatch, []) + api = ChatRunApi() + body = ChatRunPayload(inputs={}, query="hi") + with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/chat:run", method="POST"): + with pytest.raises(UnprocessableEntity, match="app_mode_mismatch"): + api.post.__handler__(api, _ctx(AppMode.WORKFLOW), app_id=_TEST_APP_ID, body=body) + generate_mock.assert_not_called() + + +def test_run_hands_the_generator_file_mappings_for_inputs_and_attachments(app: Flask, monkeypatch: pytest.MonkeyPatch): + generate_mock = _generate_stub(monkeypatch, []) + upload_service = Mock() + upload_service.upload_file.side_effect = lambda **kw: SimpleNamespace( + id=f"uf-{kw['filename']}", extension=kw["filename"].rsplit(".", 1)[-1], mime_type=kw["mimetype"] + ) + monkeypatch.setattr( + sys.modules["controllers.openapi._files"], "application_services", lambda: SimpleNamespace(files=upload_service) + ) + body = CompletionRunPayload( + inputs={}, + files={"doc": FileStorage(stream=BytesIO(b"pdf"), filename="r.pdf", content_type="application/pdf")}, + attachments=[FileStorage(stream=BytesIO(b"jpg"), filename="p.jpg", content_type="image/jpeg")], + ) + order = Mock() + session = Mock() + order.attach_mock(session, "session") + order.attach_mock(upload_service, "uploads") + + api = CompletionRunApi() + with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/completion:run", method="POST"): + api.post.__handler__(api, _ctx(AppMode.COMPLETION, session), app_id=_TEST_APP_ID, body=body) + + # The read transaction ends before the first object-storage write (api/AGENTS.md). + assert [call[0] for call in order.mock_calls][:2] == ["session.commit", "uploads.upload_file"] + args = generate_mock.call_args.kwargs["args"] + assert args["inputs"]["doc"] == { + "transfer_method": "local_file", + "upload_file_id": "uf-r.pdf", + "type": FileType.DOCUMENT, + } + assert args["files"] == [{"transfer_method": "local_file", "upload_file_id": "uf-p.jpg", "type": FileType.IMAGE}] + assert args["query"] == "" + assert {"attachments", "auto_generate_name"}.isdisjoint(args) + + +def test_chat_route_hints_the_reply_on_message_end(app: Flask, monkeypatch: pytest.MonkeyPatch): + message = 'data: {"event": "message", "answer": "hi"}\n\n' + end = {"event": "message_end", "conversation_id": "c1", "message_id": "m1", "created_at": 1, "id": "m1"} + end["task_id"] = "t1" + _generate_stub(monkeypatch, [message, f"data: {json.dumps(end)}\n\n"]) + api = ChatRunApi() + body = ChatRunPayload(inputs={}, query="hi") + with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/chat:run", method="POST"): + response = api.post.__handler__(api, _ctx(AppMode.CHAT), app_id=_TEST_APP_ID, body=body) + chunks = list(response.response) + assert chunks[0] == message + assert json.loads(chunks[1][len("data: ") :])["hints"] == [ + { + "summary": "Reply in this conversation", + "op": "console_app.chat.run", + "input": {"app_id": _TEST_APP_ID, "conversation_id": "c1", "query": None, "inputs": {}}, + } + ] + + +def test_chat_route_passes_a_message_end_without_conversation_through(app: Flask, monkeypatch: pytest.MonkeyPatch): + end = 'data: {"event": "message_end", "message_id": "m1", "created_at": 1, "id": "m1", "task_id": "t1"}\n\n' + _generate_stub(monkeypatch, [end]) + api = ChatRunApi() + body = ChatRunPayload(inputs={}, query="hi") + with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/chat:run", method="POST"): + response = api.post.__handler__(api, _ctx(AppMode.CHAT), app_id=_TEST_APP_ID, body=body) + assert list(response.response) == [end] diff --git a/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py b/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py index 69c82d1e883..e95d696b217 100644 --- a/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py +++ b/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py @@ -77,6 +77,7 @@ import libs.rate_limit as rate_limit_module from app_factory import create_flask_app_with_configs from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission, Workspace from controllers.openapi import bp as openapi_bp +from controllers.openapi._catalog import CATALOG_HEADER, catalog_for from controllers.openapi.auth.requirements import ( CheckAppAccess, CheckAppApiEnabled, @@ -245,6 +246,8 @@ ADMIT_NO_MOUNT = Expect( ) +_RUN_TRAITS = frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}) + ROUTES: tuple[Route, ...] = ( Route("account.get", "GET", "/account", frozenset({Trait.ACCOUNT_PRIMARY})), Route("account.sessions.revoke_self", "DELETE", "/account/sessions/self", frozenset({Trait.ACCOUNT_PRIMARY})), @@ -308,12 +311,10 @@ ROUTES: tuple[Route, ...] = ( "/apps/{app_id}/dependencies:check", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), - Route( - "app_run.run", - "POST", - "/apps/{app_id}:run", - frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), - ), + Route("app_run.workflow", "POST", "/apps/{app_id}/workflow:run", _RUN_TRAITS), + Route("app_run.chat", "POST", "/apps/{app_id}/chat:run", _RUN_TRAITS), + Route("app_run.advanced_chat", "POST", "/apps/{app_id}/advanced-chat:run", _RUN_TRAITS), + Route("app_run.completion", "POST", "/apps/{app_id}/completion:run", _RUN_TRAITS), Route( "app_run.stop", "POST", @@ -618,7 +619,10 @@ MATRIX: dict[str, dict[Case, Expect]] = { Case.RBAC_ON_LOW_ROLE: ADMIT, Case.RBAC_ON_DENIED: DENY_RBAC, }, - "app_run.run": dict(_DUAL_SUBJECT_RUN), + "app_run.workflow": dict(_DUAL_SUBJECT_RUN), + "app_run.chat": dict(_DUAL_SUBJECT_RUN), + "app_run.advanced_chat": dict(_DUAL_SUBJECT_RUN), + "app_run.completion": dict(_DUAL_SUBJECT_RUN), "human_input_form.get": dict(_DUAL_SUBJECT_RUN), "files.upload": { **_DUAL_SUBJECT_RUN, @@ -754,7 +758,10 @@ DECLARED: dict[str, tuple[Requirement, ...]] = { "app_dsl.import_confirm": _REQ_DSL_WORKSPACE, "app_dsl.export": _REQ_DSL_APP, "app_dsl.check_dependencies": _REQ_DSL_APP, - "app_run.run": _REQ_RUN, + "app_run.workflow": _REQ_RUN, + "app_run.chat": _REQ_RUN, + "app_run.advanced_chat": _REQ_RUN, + "app_run.completion": _REQ_RUN, "app_run.stop": _REQ_RUN, "files.upload": _REQ_FILES, "human_input_form.get": _REQ_RUN_FORM, @@ -1077,7 +1084,7 @@ def _run_case( ) settings = _access_mode_settings(scenario.access_mode) - headers: dict[str, str] = {} + headers: dict[str, str] = {CATALOG_HEADER: catalog_for(app)[1]} bearer = _bearer_for(route, scenario) if bearer is not None: headers["Authorization"] = f"Bearer {world.tokens[bearer]}" @@ -1192,10 +1199,10 @@ def test_registered_openapi_routes_match_the_matrix(matrix_app: Flask) -> None: expected = {(route.method, _rule_path(route)) for route in ROUTES} assert guarded == expected - # The remainder is the device-flow and documentation surface. Its size is pinned - # rather than enumerated: a list of exemptions rots silently — the one this + # The remainder is the device-flow, documentation and catalog surface. Its size is + # pinned rather than enumerated: a list of exemptions rots silently — the one this # replaced still named `swagger.json`, a route that is not registered at all. - assert len(unguarded) == 13 + assert len(unguarded) == 14 @singledispatch @@ -1252,6 +1259,7 @@ ERROR_DEFAULT_RESPONSE: dict[str, object] = { """Exactly what `@returns` registers as `("default", "Error", ErrorBody)`.""" EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = { + ("get", "/_catalog"): frozenset({"200"}), ("get", "/_health"): frozenset({"200", "default"}), ("get", "/_version"): frozenset({"200", "default"}), ("get", "/account"): frozenset({"200", "default"}), @@ -1262,12 +1270,15 @@ EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = { ("get", "/apps/{app_id}"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/dependencies:check"): frozenset({"200", "default"}), ("get", "/apps/{app_id}/dsl"): frozenset({"200", "422", "default"}), - ("post", "/apps/{app_id}/files"): frozenset({"201", "400", "401", "413", "415", "default"}), + ("post", "/apps/{app_id}/files"): frozenset({"201", "400", "401", "413", "415", "422", "default"}), ("get", "/apps/{app_id}/human-input-forms/{form_token}"): frozenset({"200", "default"}), ("post", "/apps/{app_id}/human-input-forms/{form_token}:submit"): frozenset({"200", "422", "default"}), - ("get", "/apps/{app_id}/tasks/{task_id}/events"): frozenset({"200", "default"}), + ("get", "/apps/{app_id}/tasks/{task_id}/events"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/tasks/{task_id}:stop"): frozenset({"200", "default"}), - ("post", "/apps/{app_id}:run"): frozenset({"200", "422", "default"}), + ("post", "/apps/{app_id}/workflow:run"): frozenset({"200", "422", "default"}), + ("post", "/apps/{app_id}/chat:run"): frozenset({"200", "422", "default"}), + ("post", "/apps/{app_id}/advanced-chat:run"): frozenset({"200", "422", "default"}), + ("post", "/apps/{app_id}/completion:run"): frozenset({"200", "422", "default"}), # The five device-flow rows are the only operations with no `default`: they # document their 200 with a raw `openapi_ns.response` rather than `@returns`, # so no `ErrorBody` schema is registered for them. @@ -1278,7 +1289,7 @@ EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = { ("post", "/oauth/device/token"): frozenset({"200"}), ("get", "/permitted-external-apps"): frozenset({"200", "422", "default"}), ("get", "/permitted-external-apps/{app_id}"): frozenset({"200", "422", "default"}), - ("get", "/workspaces"): frozenset({"200", "default"}), + ("get", "/workspaces"): frozenset({"200", "422", "default"}), ("get", "/workspaces/{workspace_id}"): frozenset({"200", "default"}), ("post", "/workspaces/{workspace_id}/apps/imports"): frozenset({"200", "202", "400", "422", "default"}), ("post", "/workspaces/{workspace_id}/apps/imports/{import_id}:confirm"): frozenset({"200", "400", "default"}), @@ -1303,6 +1314,23 @@ def openapi_document(config_overrides: Callable[..., None]) -> dict[str, object] return response.get_json() +def test_file_bearing_bodies_are_documented_as_multipart(openapi_document: dict[str, object]) -> None: + """The exported contract names the wire form `_multipart.py` accepts: a body of only + files is multipart alone, a run body is JSON or multipart with its JSON-text parts marked.""" + paths = openapi_document["paths"] + assert isinstance(paths, dict) + upload = paths["/apps/{app_id}/files"]["post"]["requestBody"]["content"] + assert set(upload) == {"multipart/form-data"} + assert "encoding" not in upload["multipart/form-data"] + run = paths["/apps/{app_id}/chat:run"]["post"]["requestBody"]["content"] + assert set(run) == {"application/json", "multipart/form-data"} + assert run["multipart/form-data"]["schema"] == run["application/json"]["schema"] + encoding = run["multipart/form-data"]["encoding"] + assert {"inputs", "query"} <= set(encoding) + assert {"files", "attachments"}.isdisjoint(encoding) + assert encoding["inputs"] == {"contentType": "application/json"} + + def _operations(document: dict[str, object]) -> Iterator[tuple[tuple[str, str], dict[str, object]]]: paths = document["paths"] assert isinstance(paths, dict) diff --git a/api/tests/unit_tests/controllers/openapi/test_catalog.py b/api/tests/unit_tests/controllers/openapi/test_catalog.py new file mode 100644 index 00000000000..b007f64090c --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_catalog.py @@ -0,0 +1,192 @@ +"""The catalog on /openapi/v1: every guarded route is in it, and its schemas are a shape an agent can read raw.""" + +import hashlib +import re +from collections.abc import Iterator + +import pytest +from flask import Flask +from flask_restx.model import ModelBase +from jsonschema import Draft202012Validator + +from configs import dify_config +from controllers.common.fields import EventStreamResponse +from controllers.openapi import bp as openapi_bp +from controllers.openapi._catalog import ( + CATALOG_HEADER, + CATALOG_PATH, + CatalogOp, + build_catalog, + catalog_for, + iter_handlers, +) +from controllers.openapi._errors import OpenApiErrorCode +from controllers.openapi._models import Hinted +from controllers.openapi.auth.spec import EndpointSpec, Kind, spec_of +from tests.unit_tests.controllers.openapi.conftest import AdmittedWorld + +OP_ID_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$") +MAX_DEPTH = 4 +_UNCATALOGUED_PREFIX = "/openapi/v1/oauth/" +# The client fills these from its own context (the workspace pin), so examples leave them out. +_CLIENT_CONTEXT = frozenset({"workspace_id"}) +_UNCATALOGUED = { + "/openapi/v1/", + "/openapi/v1/openapi.json", + "/openapi/v1/_health", + "/openapi/v1/_version", + CATALOG_PATH, +} + + +@pytest.fixture +def app() -> Flask: + a = Flask(__name__) + a.config["TESTING"] = True + a.register_blueprint(openapi_bp) + return a + + +@pytest.fixture +def ops(app: Flask) -> dict[str, CatalogOp]: + return build_catalog(app)["ops"] + + +def _response_model_names(fn: object) -> set[str]: + responses: dict[str, tuple[object, ...]] = vars(fn).get("__apidoc__", {}).get("responses", {}) + return {entry[1].name for entry in responses.values() if isinstance(entry[1], ModelBase)} + + +def _walk( + node: object, path: tuple[str, ...] = (), depth: int = 0 +) -> Iterator[tuple[tuple[str, ...], int, dict[str, object]]]: + if isinstance(node, dict): + yield path, depth, node + for key in ("properties", "$defs"): + for name, child in (node.get(key) or {}).items(): + yield from _walk(child, (*path, name), depth + 1) + for key in ("items", "additionalProperties"): + if isinstance(node.get(key), dict): + yield from _walk(node[key], (*path, key), depth + 1) + for alt in node.get("anyOf") or []: + yield from _walk(alt, path, depth) + + +def test_every_guarded_route_declares_catalog_meta_once(app: Flask, ops: dict[str, CatalogOp]) -> None: + seen: dict[str, str] = {} + streaming: set[str] = set() + for rule, verb, fn in iter_handlers(app): + spec = spec_of(fn) + if rule.rule.startswith(_UNCATALOGUED_PREFIX) or rule.rule in _UNCATALOGUED: + assert spec is None, f"{verb} {rule.rule} must stay out of the catalog" + continue + assert isinstance(spec, EndpointSpec), f"{verb} {rule.rule}" + meta = spec.catalog + assert OP_ID_RE.fullmatch(meta.op), meta.op + assert meta.summary.strip(), meta.op + assert meta.op not in seen, f"{meta.op} declared twice: {seen[meta.op]} and {verb} {rule.rule}" + seen[meta.op] = f"{verb} {rule.rule}" + admitted = spec.edition is None or dify_config.DEPLOYMENT_EDITION in spec.edition + assert (meta.op in ops) is admitted, meta.op + if EventStreamResponse.__name__ in _response_model_names(fn): + streaming.add(meta.op) + assert meta.kind is Kind.SSE, meta.op + assert set(ops) <= set(seen) + per_mode = {f"console_app.{mode}.run" for mode in ("workflow", "chat", "advanced_chat", "completion")} + assert streaming == per_mode | {"run.events"} + + +def test_run_entries_carry_path_bind_kind_and_flags(ops: dict[str, CatalogOp]) -> None: + chat = ops["console_app.chat.run"] + assert set(chat) == {"summary", "method", "path", "kind", "input", "bind", "internal", "deprecated", "examples"} + assert (chat["method"], chat["path"], chat["kind"]) == ( + "POST", + "/openapi/v1/apps/{app_id}/chat:run", + "sse", + ) + assert {"app_id", "inputs", "query"} <= set(chat["input"]["required"]) + assert set(chat["bind"]) == set(chat["input"]["properties"]) + assert {k: chat["bind"][k] for k in ("app_id", "inputs", "files", "attachments")} == { + "app_id": "path", + "inputs": "body", + "files": "file", + "attachments": "file", + } + assert chat["deprecated"] is False + assert ops["console_app.file.upload"]["bind"]["file"] == "file" + assert ops["console_app.list"]["bind"]["page"] == "query" + assert ops["run.events"]["bind"]["continue_on_pause"] == "query" + assert ops["workspace.switch"]["internal"] is True + + +def test_input_schemas_are_flat_shallow_and_described(ops: dict[str, CatalogOp]) -> None: + bad = [ + (op, p, k) + for op, e in ops.items() + for p, d, n in _walk(e["input"]) + for k in ("oneOf", "$ref", "$defs") + if k in n or d > MAX_DEPTH + ] + assert bad == [] + undocumented = [ + (op, name) + for op, e in ops.items() + for name, prop in e["input"]["properties"].items() + if "object" in {prop.get("type")} | {a.get("type") for a in prop.get("anyOf", [])} + and not prop.get("description") + ] + assert undocumented == [] + assert [ + op for op, e in ops.items() if e["kind"] == "list" and not {"page", "limit"} <= set(e["input"]["properties"]) + ] == [] + assert [op for op, e in ops.items() if set(Hinted.model_fields) & set(e["input"]["properties"])] == [] + desc = ops["console_app.chat.run"]["input"]["properties"]["inputs"]["description"] + assert "console_app.describe" in desc + assert "input_schema" in desc + + +def test_every_op_carries_examples_that_pass_its_input_schema(ops: dict[str, CatalogOp]) -> None: + assert [op for op, e in ops.items() if not e["examples"]] == [] + bad = [] + for op, e in ops.items(): + schema = { + **e["input"], + "required": [name for name in e["input"]["required"] if name not in _CLIENT_CONTEXT], + "additionalProperties": False, + } + validator = Draft202012Validator(schema) + for example in e["examples"]: + assert example["title"].strip(), op + bad.extend((op, example["title"], err.message) for err in validator.iter_errors(example["input"])) + assert bad == [] + + +def test_catalog_route_serves_canonical_bytes_and_every_response_carries_the_fingerprint(app: Flask) -> None: + client = app.test_client() + raw, fingerprint = catalog_for(app) + res = client.get(CATALOG_PATH) + assert (res.status_code, res.mimetype, res.data) == (200, "application/json", raw) + assert fingerprint == hashlib.sha256(raw).hexdigest() == res.headers[CATALOG_HEADER] + assert client.get("/openapi/v1/apps").headers[CATALOG_HEADER] == fingerprint + assert client.get("/openapi/v1/does-not-exist").headers[CATALOG_HEADER] == fingerprint + + +def test_a_guarded_route_refuses_a_request_that_does_not_name_the_current_catalog( + admitted_bearer: AdmittedWorld, +) -> None: + """The wire shape of the refusal: 412 with the canonical body, and the + current fingerprint on the response so the client can refetch and retry. + The same bearer with the current fingerprint gets past the guard: the + unknown query answers 422 from `@accepts`, which sits inside it. + """ + path = f"/openapi/v1/workspaces/{admitted_bearer.workspace_id}/members?nope=1" + current = admitted_bearer.headers[CATALOG_HEADER] + without = {"Authorization": admitted_bearer.headers["Authorization"]} + + refused = admitted_bearer.client.get(path, headers=without) + admitted = admitted_bearer.client.get(path, headers=admitted_bearer.headers) + + assert refused.status_code == 412 + assert refused.get_json()["code"] == OpenApiErrorCode.CATALOG_STALE + assert refused.headers[CATALOG_HEADER] == current + assert admitted.status_code == 422 diff --git a/api/tests/unit_tests/controllers/openapi/test_contract.py b/api/tests/unit_tests/controllers/openapi/test_contract.py index bb0fa8a7386..3f731062f7f 100644 --- a/api/tests/unit_tests/controllers/openapi/test_contract.py +++ b/api/tests/unit_tests/controllers/openapi/test_contract.py @@ -13,7 +13,8 @@ from werkzeug.exceptions import UnprocessableEntity from controllers.common.schema import register_response_schema_model, register_schema_model from controllers.openapi import openapi_ns -from controllers.openapi._contract import accepts, returns +from controllers.openapi._contract import accepts, paginated, returns +from controllers.openapi._models import Hint, PaginationEnvelope class ContractQuery(BaseModel): @@ -54,6 +55,28 @@ def _guard_like(view): return wrapper +class _Page(PaginationEnvelope[int]): + pass + + +def test_paginated_fills_the_next_page_hint_from_the_call_and_leaves_the_rest_alone(): + @paginated("thing.list") + def view(*, ctx, workspace_id, query): + return _Page.build(page=query.page, limit=query.limit, total=50, items=[]) + + result = view(ctx=object(), workspace_id="ws-1", query=ContractQuery(page=1, limit=20)) + assert result.hints == [ + Hint(summary="Next page", op="thing.list", input={"workspace_id": "ws-1", "page": 2, "limit": 20}) + ] + assert view(ctx=object(), workspace_id="ws-1", query=ContractQuery(page=3, limit=20)).hints == [] + + @paginated("thing.get") + def plain(*, ctx): + return ContractResp(value=1) + + assert plain(ctx=object()) == ContractResp(value=1) + + def test_accepts_injects_validated_query_with_defaults_for_absent_fields(app): @accepts(query=ContractQuery) def view(*, query): diff --git a/api/tests/unit_tests/controllers/openapi/test_cors.py b/api/tests/unit_tests/controllers/openapi/test_cors.py index 895c685da15..fbeffa5ffea 100644 --- a/api/tests/unit_tests/controllers/openapi/test_cors.py +++ b/api/tests/unit_tests/controllers/openapi/test_cors.py @@ -16,7 +16,8 @@ from flask_cors import CORS from flask_restx import Resource from configs import dify_config -from extensions.ext_blueprints import OPENAPI_HEADERS, OPENAPI_MAX_AGE_SECONDS +from constants import HEADER_NAME_CATALOG +from extensions.ext_blueprints import EXPOSED_HEADERS, OPENAPI_HEADERS, OPENAPI_MAX_AGE_SECONDS from libs.external_api import ExternalApi if not hasattr(builtins, "MethodView"): @@ -41,7 +42,7 @@ def _make_app(allowed_origins: list[str], blueprint_name: str) -> Flask: supports_credentials=True, allow_headers=list(OPENAPI_HEADERS), methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], - expose_headers=["X-Version"], + expose_headers=list(EXPOSED_HEADERS), max_age=OPENAPI_MAX_AGE_SECONDS, ) @@ -125,3 +126,29 @@ def test_authorization_header_is_in_allow_headers(): allow_headers = response.headers.get("Access-Control-Allow-Headers", "").lower() assert "authorization" in allow_headers + + +def test_catalog_fingerprint_header_is_exposed(): + app = _make_app(["https://app.example.com"], "openapi_t6") + client = app.test_client() + response = client.get("/openapi/v1/_health", headers={"Origin": "https://app.example.com"}) + + expose_headers = response.headers.get("Access-Control-Expose-Headers", "") + assert HEADER_NAME_CATALOG in expose_headers + + +def test_catalog_fingerprint_header_is_allowed_on_requests(): + """Every guarded route needs the fingerprint on the request, so preflight must admit it.""" + app = _make_app(["https://app.example.com"], "openapi_t7") + client = app.test_client() + response = client.options( + "/openapi/v1/_health", + headers={ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": HEADER_NAME_CATALOG, + }, + ) + + allow_headers = response.headers.get("Access-Control-Allow-Headers", "").lower() + assert HEADER_NAME_CATALOG.lower() in allow_headers diff --git a/api/tests/unit_tests/controllers/openapi/test_error_contract.py b/api/tests/unit_tests/controllers/openapi/test_error_contract.py index 8d1cf8ddf04..4d3d2934e05 100644 --- a/api/tests/unit_tests/controllers/openapi/test_error_contract.py +++ b/api/tests/unit_tests/controllers/openapi/test_error_contract.py @@ -25,7 +25,9 @@ from controllers.common.errors import ( TooManyFilesError, UnsupportedFileTypeError, ) +from controllers.openapi._catalog import CATALOG_HEADER, catalog_for from controllers.openapi._errors import ( + CatalogStale, ErrorBody, ErrorDetail, FilenameNotExists, @@ -226,7 +228,8 @@ class TestWireContract: monkeypatch.setattr("controllers.openapi.auth.pipelines._mount_flask_login", lambda _user: None) resp = openapi_app.test_client().get( - "/openapi/v1/account/sessions?page=0", headers={"Authorization": "Bearer dfoa_wire"} + "/openapi/v1/account/sessions?page=0", + headers={"Authorization": "Bearer dfoa_wire", CATALOG_HEADER: catalog_for(openapi_app)[1]}, ) assert resp.status_code == 422, resp.get_json() @@ -305,6 +308,7 @@ ERROR_MATRIX = [ (MemberLicenseExceeded(), 403, "member_license_exceeded"), (HumanInputFormNotFound(), 404, "form_not_found"), (RecipientSurfaceMismatch(), 403, "recipient_surface_mismatch"), + (CatalogStale(), 412, "catalog_stale"), ] diff --git a/api/tests/unit_tests/controllers/openapi/test_files.py b/api/tests/unit_tests/controllers/openapi/test_files.py index c950b35f2af..97a04eec74d 100644 --- a/api/tests/unit_tests/controllers/openapi/test_files.py +++ b/api/tests/unit_tests/controllers/openapi/test_files.py @@ -11,6 +11,9 @@ from controllers.common.errors import ( FileTooLargeError, UnsupportedFileTypeError, ) +from controllers.openapi._models import FileUploadPayload +from controllers.openapi._multipart import body_from_request +from controllers.openapi._upload import file_fields from controllers.openapi.files import AppFileUploadApi from libs.exception import BaseHTTPException from models import Account @@ -35,8 +38,12 @@ def _upload_result() -> SimpleNamespace: ) +def _body() -> FileUploadPayload: + return FileUploadPayload.model_validate(body_from_request(file_fields=file_fields(FileUploadPayload))) + + def _file_service(monkeypatch: pytest.MonkeyPatch) -> Mock: - from controllers.openapi import files as module + from controllers.openapi import _files as module service = Mock() monkeypatch.setattr(module, "application_services", lambda: SimpleNamespace(files=service)) @@ -55,7 +62,7 @@ def test_upload_uses_injected_file_service(app: Flask, monkeypatch: pytest.Monke content_type="multipart/form-data", ): api = AppFileUploadApi() - result = api.post.__handler__(api, SimpleNamespace(caller=caller), app_id="app-1") + result = api.post.__handler__(api, SimpleNamespace(caller=caller, session=Mock()), app_id="app-1", body=_body()) assert result.id == "00000000-0000-0000-0000-000000000001" service.upload_file.assert_called_once_with( @@ -106,7 +113,7 @@ def test_upload_preserves_specific_file_errors( ): api = AppFileUploadApi() with pytest.raises(controller_error) as error_info: - api.post.__handler__(api, SimpleNamespace(caller=_caller()), app_id="app-1") + api.post.__handler__(api, SimpleNamespace(caller=_caller(), session=Mock()), app_id="app-1", body=_body()) assert error_info.value.code == status assert error_info.value.error_code == error_code @@ -127,7 +134,7 @@ def test_upload_maps_other_value_errors_to_bad_request(app: Flask, monkeypatch: ): api = AppFileUploadApi() with pytest.raises(BadRequest) as error_info: - api.post.__handler__(api, SimpleNamespace(caller=_caller()), app_id="app-1") + api.post.__handler__(api, SimpleNamespace(caller=_caller(), session=Mock()), app_id="app-1", body=_body()) assert error_info.value.description == str(service_error) assert error_info.value.__cause__ is service_error diff --git a/api/tests/unit_tests/controllers/openapi/test_files_merge.py b/api/tests/unit_tests/controllers/openapi/test_files_merge.py new file mode 100644 index 00000000000..8ddc3f5e963 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_files_merge.py @@ -0,0 +1,56 @@ +"""Uploaded parts become the local-file mappings the core file factory accepts, merged into inputs by name.""" + +from io import BytesIO +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from werkzeug.datastructures import FileStorage + +from controllers.openapi import _files as module +from controllers.openapi._errors import InvalidFilePart +from controllers.openapi._files import merge_files +from factories.file_factory.validation import is_file_valid_with_config +from graphon.file import FileTransferMethod, FileType, FileUploadConfig + +_DOCUMENT_VARIABLE_CONFIG = FileUploadConfig( + allowed_file_types=[FileType.DOCUMENT], + allowed_file_upload_methods=[FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL], + number_limits=5, +) + + +def _fs(name: str, mimetype: str) -> FileStorage: + return FileStorage(stream=BytesIO(b"bytes"), filename=name, content_type=mimetype) + + +@pytest.fixture(autouse=True) +def uploads(monkeypatch: pytest.MonkeyPatch) -> Mock: + service = Mock() + service.upload_file.side_effect = lambda **kw: SimpleNamespace( + id=f"uf-{kw['filename']}", extension=kw["filename"].rsplit(".", 1)[-1], mime_type=kw["mimetype"] + ) + monkeypatch.setattr(module, "application_services", lambda: SimpleNamespace(files=service)) + return service + + +def test_parts_become_mappings_the_core_file_factory_accepts() -> None: + original = {"q": "hi"} + inputs = merge_files(original, {"doc": _fs("r.pdf", "application/pdf"), "pages": [_fs("1.png", "image/png")]}, None) + assert original == {"q": "hi"} + assert inputs == { + "q": "hi", + "doc": {"transfer_method": "local_file", "upload_file_id": "uf-r.pdf", "type": FileType.DOCUMENT}, + "pages": [{"transfer_method": "local_file", "upload_file_id": "uf-1.png", "type": FileType.IMAGE}], + } + assert is_file_valid_with_config( + input_file_type=inputs["doc"]["type"], + file_extension=".pdf", + file_transfer_method=FileTransferMethod(inputs["doc"]["transfer_method"]), + config=_DOCUMENT_VARIABLE_CONFIG, + ) + + +def test_a_name_given_in_both_inputs_and_files_is_refused() -> None: + with pytest.raises(InvalidFilePart, match="doc"): + merge_files({"doc": "https://x/a.pdf"}, {"doc": _fs("a.pdf", "application/pdf")}, None) diff --git a/api/tests/unit_tests/controllers/openapi/test_hints.py b/api/tests/unit_tests/controllers/openapi/test_hints.py new file mode 100644 index 00000000000..a03fd712fd5 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_hints.py @@ -0,0 +1,89 @@ +"""The two hint mechanics a handler cannot do inline: the next page of a list, and hints on one SSE event kind.""" + +import json +from collections.abc import Iterator, Mapping + +from pydantic import BaseModel + +from controllers.openapi._hints import attach_stream_hints, next_page_hint +from controllers.openapi._models import Hint, PageQuery, PaginationEnvelope + + +class _Envelope(PaginationEnvelope[str]): + pass + + +class _Query(PageQuery): + name: str | None = None + + +def test_next_page_hint_copies_path_and_query_and_bumps_page() -> None: + hint = next_page_hint( + op="thing.list", + path_args={"workspace_id": "ws-1"}, + query=_Query(page=2, name="x"), + envelope=_Envelope.build(page=2, limit=20, total=100, items=[]), + ) + assert hint == Hint( + summary="Next page", op="thing.list", input={"workspace_id": "ws-1", "name": "x", "page": 3, "limit": 20} + ) + last = _Envelope.build(page=5, limit=20, total=100, items=[]) + assert next_page_hint(op="thing.list", path_args={}, query=None, envelope=last) is None + + +def _sse(event: Mapping[str, object]) -> str: + return f"data: {json.dumps(event)}\n\n" + + +def _build(event: Mapping[str, object]) -> list[Hint]: + return [Hint(summary="Go on", op="thing.resume", input={"run_id": event["run_id"]})] + + +def test_attach_stream_hints_decorates_only_the_wanted_event() -> None: + passthrough = [ + "event: ping\n\n", + _sse({"event": "message", "answer": "paused"}), + ] + wanted = {"event": "paused", "run_id": "r1", "data": {"x": 1}} + out = list(attach_stream_hints(iter([*passthrough, _sse(wanted)]), event="paused", build=_build)) + assert out[:-1] == passthrough + assert json.loads(out[-1][len("data: ") :]) == { + **wanted, + "hints": [{"summary": "Go on", "op": "thing.resume", "input": {"run_id": "r1"}}], + } + + +def test_attach_stream_hints_passes_through_what_it_cannot_read() -> None: + """A frame that only mentions the event name, a non-object frame, and an event the + builder cannot read all reach the wire unchanged instead of ending the stream.""" + + class _Fields(BaseModel): + run_id: str + + def build(event: Mapping[str, object]) -> list[Hint]: + return _build(_Fields.model_validate(event).model_dump()) + + chunks = [ + 'data: {"answer": "the paused word", "event": "message"}\n\n', + "data: not json but paused\n\n", + 'data: ["paused"]\n\n', + _sse({"event": "paused", "data": {"x": 1}}), + ] + assert list(attach_stream_hints(iter(chunks), event="paused", build=build)) == chunks + + +def test_attach_stream_hints_closes_the_source() -> None: + class Source: + closed = False + + def __iter__(self) -> Iterator[str]: + yield _sse({"event": "message"}) + + def close(self) -> None: + self.closed = True + + source = Source() + gen = attach_stream_hints(source, event="paused", build=_build) + next(gen) + gen.close() + assert source.closed diff --git a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py index 80bfae27870..5f5aca795a8 100644 --- a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py @@ -21,9 +21,9 @@ from unittest.mock import Mock import pytest from flask import Flask -from controllers.common.human_input import HumanInputFormSubmitPayload +from controllers.openapi._contract import op_of from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch -from controllers.openapi._models import FormSubmitResponse +from controllers.openapi._models import FormSubmitResponse, OpenApiFormSubmitPayload from controllers.openapi.auth.context import Context from controllers.openapi.auth.requirements import Rank from controllers.openapi.auth.subjects import Subject @@ -31,7 +31,10 @@ from controllers.openapi.human_input_form import ( CheckFormSurface, OpenApiWorkflowHumanInputFormApi, OpenApiWorkflowHumanInputFormSubmitApi, + with_form_hints, ) +from core.app.entities.task_entities import HumanInputRequiredResponse +from core.workflow.nodes.human_input import ParagraphInputConfig, UserActionConfig from models.account import Account from models.enums import CreatorUserRole, EndUserType from models.human_input import RecipientType @@ -165,7 +168,7 @@ class TestOpenApiHumanInputFormPost: _context(_make_account("acct-42"), CreatorUserRole.ACCOUNT), app_id="app-1", form_token="tok-1", - body=HumanInputFormSubmitPayload(action="approve", inputs={"field1": "val"}), + body=OpenApiFormSubmitPayload(action="approve", inputs={"field1": "val"}), ) service_mock.submit_form_by_token.assert_called_once_with( @@ -192,7 +195,7 @@ class TestOpenApiHumanInputFormPost: _context(_make_end_user("eu-7"), CreatorUserRole.END_USER), app_id="app-1", form_token="tok-1", - body=HumanInputFormSubmitPayload(action="approve", inputs={}), + body=OpenApiFormSubmitPayload(action="approve", inputs={}), ) service_mock.submit_form_by_token.assert_called_once_with( @@ -248,3 +251,48 @@ class TestCheckFormSurface: """ foreign = _make_form(app_id=str(uuid.uuid4()), recipient_type=RecipientType.CONSOLE) self._run(monkeypatch, foreign if form == "foreign" else None) + + +def _paused(*, form_token: str | None, inputs: list, actions: list) -> dict: + response = HumanInputRequiredResponse( + task_id="t1", + workflow_run_id="r1", + data=HumanInputRequiredResponse.Data( + form_id="f1", + node_id="n1", + node_title="Review", + form_content="", + inputs=inputs, + actions=actions, + form_token=form_token, + expiration_time=4102444800, + ), + ) + return response.model_dump(mode="json") + + +class TestFormHints: + def test_with_form_hints_adds_one_hint_per_action_targeting_the_submit_route(self): + event = _paused( + form_token="ft-1", + inputs=[ParagraphInputConfig(output_variable_name="comment")], + actions=[UserActionConfig(id="approve", title="批准"), UserActionConfig(id="reject", title="")], + ) + [out] = list(with_form_hints(iter([f"data: {json.dumps(event)}\n\n"]), app_id="a1")) + body = json.loads(out[len("data: ") :]) + op = op_of(OpenApiWorkflowHumanInputFormSubmitApi.post) + assert op == "run.form.submit" + assert body["hints"] == [ + { + "summary": "批准", + "op": op, + "input": {"app_id": "a1", "form_token": "ft-1", "action": "approve", "inputs": {"comment": None}}, + "form": event["data"]["inputs"], + }, + { + "summary": "reject", + "op": op, + "input": {"app_id": "a1", "form_token": "ft-1", "action": "reject", "inputs": {"comment": None}}, + "form": event["data"]["inputs"], + }, + ] diff --git a/api/tests/unit_tests/controllers/openapi/test_multipart.py b/api/tests/unit_tests/controllers/openapi/test_multipart.py new file mode 100644 index 00000000000..08502de53db --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_multipart.py @@ -0,0 +1,68 @@ +"""Multipart bodies become the same dict a JSON body would be; file parts land where their name says.""" + +from collections.abc import Mapping +from io import BytesIO + +import pytest +from flask import Flask +from flask.ctx import RequestContext +from werkzeug.datastructures import FileStorage + +from controllers.openapi._errors import InvalidFilePart +from controllers.openapi._multipart import body_from_request + +_FILE_FIELDS = frozenset({"file", "files", "attachments"}) + + +@pytest.fixture +def app() -> Flask: + a = Flask(__name__) + a.config["TESTING"] = True + return a + + +def _part(name: str = "x.txt") -> tuple[BytesIO, str]: + return (BytesIO(b"x"), name) + + +def _multipart(app: Flask, data: Mapping[str, object]) -> RequestContext: + return app.test_request_context("/x", method="POST", data=data, content_type="multipart/form-data") + + +def test_json_request_passes_through(app: Flask) -> None: + with app.test_request_context("/x", method="POST", json={"inputs": {"q": "hi"}}): + assert body_from_request(file_fields=_FILE_FIELDS) == {"inputs": {"q": "hi"}} + + +def test_multipart_places_every_part_name_shape(app: Flask) -> None: + data = { + "inputs": '{"q": "refund 42"}', + "file": _part("one.txt"), + "files[doc]": _part("r.pdf"), + "files[pages][]": [_part("1.png"), _part("2.png")], + "attachments[]": _part("p.jpg"), + } + with _multipart(app, data): + body = body_from_request(file_fields=_FILE_FIELDS) + assert body["inputs"] == {"q": "refund 42"} + assert isinstance(body["file"], FileStorage) + assert isinstance(body["files"]["doc"], FileStorage) + assert [f.filename for f in body["files"]["pages"]] == ["1.png", "2.png"] + assert [f.filename for f in body["attachments"]] == ["p.jpg"] + + +@pytest.mark.parametrize( + ("data", "reason"), + [ + pytest.param({"inputs": "not json"}, "JSON text", id="text_part_not_json"), + pytest.param({"inputs[doc]": _part()}, "does not take a file", id="field_is_not_a_file_field"), + pytest.param({"files[a][b]": _part()}, "named", id="two_keys"), + pytest.param( + {"files[doc]": [_part("a.pdf"), _part("b.pdf")]}, r"files\[doc\]\[\]", id="repeated_without_marker" + ), + pytest.param({"files": '{"doc": "x"}', "files[doc]": _part()}, "file parts", id="json_text_in_file_field"), + ], +) +def test_multipart_rejects_malformed_bodies(app: Flask, data: Mapping[str, object], reason: str) -> None: + with _multipart(app, data), pytest.raises(InvalidFilePart, match=reason): + body_from_request(file_fields=_FILE_FIELDS) diff --git a/api/tests/unit_tests/controllers/openapi/test_version_gate.py b/api/tests/unit_tests/controllers/openapi/test_version_gate.py deleted file mode 100644 index e2b16259952..00000000000 --- a/api/tests/unit_tests/controllers/openapi/test_version_gate.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Tests for the difyctl version gate on /openapi/v1 (HTTP 426 Upgrade Required). - -The gate is an app-level ``before_app_request`` hook: it must fire before routing, -so requests to *removed* paths (which no longer match a route) become 426 rather -than a bare 404. It reads the difyctl version from the User-Agent and fails open -for anything it can't confidently identify as an outdated difyctl. -""" - -from __future__ import annotations - -import uuid - -import pytest -from flask import Flask - -# Floor is [tool.dify] min_difyctl_version = "0.2.0". Comparison is on the numeric -# core (major.minor.patch), so 0.2.0-alpha passes (core 0.2.0 == floor) while -# 0.1.0 (core 0.1.0 < 0.2.0) is blocked. -OLD_UA = "difyctl/0.1.0 (darwin; arm64; stable)" -CURRENT_UA = "difyctl/0.2.0-alpha (darwin; arm64; stable)" - - -@pytest.fixture -def client(openapi_app: Flask): - return openapi_app.test_client() - - -def _gated_path() -> str: - """An existing, auth-guarded route on the surface (GET /apps/).""" - return f"/openapi/v1/apps/{uuid.uuid4()}" - - -class TestVersionGate: - def test_old_client_gets_426_with_upgrade_body(self, client): - res = client.get(_gated_path(), headers={"User-Agent": OLD_UA}) - - assert res.status_code == 426 - body = res.get_json() - assert body["code"] == "upgrade_required" - assert body["status"] == 426 - assert "0.1.0" in body["message"] - assert "0.2.0" in body["message"] - assert "docs.dify.ai" in body["hint"] - - def test_removed_old_path_gets_426_not_404(self, client): - # /apps//run was renamed to /apps/:run — the old path matches no - # route. The app-level gate must still turn it into 426, not a bare 404. - res = client.post( - f"/openapi/v1/apps/{uuid.uuid4()}/run", - headers={"User-Agent": OLD_UA}, - json={"inputs": {}}, - ) - - assert res.status_code == 426 - assert res.get_json()["code"] == "upgrade_required" - - def test_current_client_passes_gate(self, client): - # Gate passes → normal dispatch (auth rejects, never the gate's 426). - # 0.2.0-alpha == floor on the numeric core, so it passes despite the suffix. - res = client.get(_gated_path(), headers={"User-Agent": CURRENT_UA}) - - assert res.status_code != 426 - - def test_prerelease_at_floor_passes(self, client): - # Numeric-core comparison: a pre-release of the floor version (0.2.0-rc.1, - # core 0.2.0) passes, even though 0.2.0-rc.1 < 0.2.0 under naive ordering. - res = client.get(_gated_path(), headers={"User-Agent": "difyctl/0.2.0-rc.1 (darwin; arm64; rc)"}) - - assert res.status_code != 426 - - def test_prerelease_below_floor_gets_426(self, client): - # 0.1.9-rc.1 has core 0.1.9 < 0.2.0 floor → still blocked. - res = client.get(_gated_path(), headers={"User-Agent": "difyctl/0.1.9-rc.1 (darwin; arm64; rc)"}) - - assert res.status_code == 426 - - def test_non_difyctl_ua_passes(self, client): - res = client.get(_gated_path(), headers={"User-Agent": "curl/8.4.0"}) - - assert res.status_code != 426 - - def test_missing_ua_passes(self, client): - res = client.get(_gated_path()) - - assert res.status_code != 426 - - def test_unparseable_version_passes(self, client): - res = client.get(_gated_path(), headers={"User-Agent": "difyctl/notaversion (x; y; z)"}) - - assert res.status_code != 426 - - def test_version_probe_allowlisted(self, client): - res = client.get("/openapi/v1/_version", headers={"User-Agent": OLD_UA}) - - assert res.status_code == 200 - - def test_health_allowlisted(self, client): - res = client.get("/openapi/v1/_health", headers={"User-Agent": OLD_UA}) - - assert res.status_code == 200 diff --git a/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py b/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py index 28039508b9d..9049b5ff4c9 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py +++ b/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py @@ -18,7 +18,7 @@ import pytest from flask import Flask from werkzeug.exceptions import NotFound -from controllers.openapi.workflow_events import OpenApiWorkflowEventsApi +from controllers.openapi.workflow_events import OpenApiWorkflowEventsApi, WorkflowEventsQuery from core.db.session_factory import session_factory from graphon.enums import WorkflowExecutionStatus from models.account import Account @@ -137,6 +137,7 @@ class TestOpenApiWorkflowEventsApi: _context(_make_account(), CreatorUserRole.ACCOUNT), app_id="app-1", task_id="wf-run-1", + query=WorkflowEventsQuery(), ) # The stream outlives the guard's session, so the repository gets the guard's @@ -155,6 +156,7 @@ class TestOpenApiWorkflowEventsApi: _context(_make_account(), CreatorUserRole.ACCOUNT), app_id="app-1", task_id="wf-run-1", + query=WorkflowEventsQuery(), ) def test_account_caller_rejected_for_end_user_run(self, app: Flask, monkeypatch: pytest.MonkeyPatch): @@ -168,6 +170,7 @@ class TestOpenApiWorkflowEventsApi: _context(_make_account(), CreatorUserRole.ACCOUNT), app_id="app-1", task_id="wf-run-1", + query=WorkflowEventsQuery(), ) def test_end_user_caller_checks_created_by_end_user(self, app: Flask, monkeypatch: pytest.MonkeyPatch): @@ -182,6 +185,7 @@ class TestOpenApiWorkflowEventsApi: _context(_make_end_user(), CreatorUserRole.END_USER), app_id="app-1", task_id="wf-run-1", + query=WorkflowEventsQuery(), ) assert resp.mimetype == "text/event-stream" @@ -218,7 +222,7 @@ class TestOpenApiWorkflowEventsApi: ctx = _context(_make_account(), CreatorUserRole.ACCOUNT) api = OpenApiWorkflowEventsApi() with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): - resp = api.get.__handler__(api, ctx, app_id="app-1", task_id="wf-run-1") + resp = api.get.__handler__(api, ctx, app_id="app-1", task_id="wf-run-1", query=WorkflowEventsQuery()) ctx.seal() body = "".join(resp.response) @@ -249,11 +253,38 @@ class TestOpenApiWorkflowEventsApi: ctx = _context(_make_account(), CreatorUserRole.ACCOUNT) api = OpenApiWorkflowEventsApi() - query = "?include_state_snapshot=true" if include_state_snapshot else "" - with app.test_request_context(f"/openapi/v1/apps/app-1/tasks/wf-run-1/events{query}"): - resp = api.get.__handler__(api, ctx, app_id="app-1", task_id="wf-run-1") + query = WorkflowEventsQuery(include_state_snapshot=include_state_snapshot) + with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): + resp = api.get.__handler__(api, ctx, app_id="app-1", task_id="wf-run-1", query=query) ctx.seal() body = "".join(resp.response) assert resp.mimetype == "text/event-stream" assert body == "event: a\n\nevent: b\n\n" + + @pytest.mark.parametrize("continue_on_pause", [False, True], ids=["close", "keep_open"]) + def test_continue_on_pause_comes_off_the_declared_query_model( + self, app: Flask, monkeypatch: pytest.MonkeyPatch, continue_on_pause: bool + ): + module = sys.modules["controllers.openapi.workflow_events"] + self._bind_repo(monkeypatch, _make_workflow_run(created_by_role=CreatorUserRole.ACCOUNT, created_by="acct-1")) + generator_mock = Mock() + generator_mock.convert_to_event_stream.return_value = iter([]) + monkeypatch.setattr(module, "WorkflowAppGenerator", lambda: generator_mock) + msg_gen_mock = Mock() + msg_gen_mock.retrieve_events.return_value = iter([]) + monkeypatch.setattr(module, "MessageGenerator", lambda: msg_gen_mock) + + api = OpenApiWorkflowEventsApi() + with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): + resp = api.get.__handler__( + api, + _context(_make_account(), CreatorUserRole.ACCOUNT), + app_id="app-1", + task_id="wf-run-1", + query=WorkflowEventsQuery(continue_on_pause=continue_on_pause), + ) + "".join(resp.response) + + terminal_events = msg_gen_mock.retrieve_events.call_args.kwargs["terminal_events"] + assert terminal_events == ([] if continue_on_pause else None) diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py index c13b7b2e0aa..f1f4293ce42 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py @@ -37,6 +37,7 @@ from werkzeug.test import TestResponse from controllers.openapi import bp as openapi_bp from controllers.openapi import workspaces as workspaces_module +from controllers.openapi._catalog import CATALOG_HEADER, catalog_for from controllers.openapi._errors import ( ErrorBody, MemberLicenseExceeded, @@ -354,6 +355,40 @@ def test_members_list_paginates_with_query_params(database_session: Session): assert [d.id for d in result.data] == member_ids[2:4] +def test_members_list_next_page_hint_reaches_the_wire(admitted_bearer: AdmittedWorld, database_session: Session): + for i in range(4): + member_id = str(uuid.uuid4()) + database_session.add_all( + [ + _account(account_id=member_id, email=f"member{i}@example.com"), + TenantAccountJoin( + tenant_id=admitted_bearer.workspace_id, + account_id=member_id, + current=False, + role=TenantAccountRole.NORMAL, + ), + ] + ) + database_session.commit() + + res = admitted_bearer.client.get( + f"/openapi/v1/workspaces/{admitted_bearer.workspace_id}/members?page=1&limit=2", + headers=admitted_bearer.headers, + ) + + assert res.status_code == 200 + body = res.get_json() + assert body["has_more"] is True + assert body["hints"] == [ + { + "summary": "Next page", + "op": "workspace.members.list", + "input": {"workspace_id": admitted_bearer.workspace_id, "page": 2, "limit": 2}, + "form": None, + } + ] + + # --------------------------------------------------------------------------- # Invite endpoint # --------------------------------------------------------------------------- @@ -436,7 +471,7 @@ def test_invite_commits_the_invitation( response = openapi_app.test_client().post( f"/openapi/v1/workspaces/{ws_id}/members", json={"email": "new@example.com", "role": "normal"}, - headers={"Authorization": "Bearer dfoa_matrix"}, + headers={"Authorization": "Bearer dfoa_matrix", CATALOG_HEADER: catalog_for(openapi_app)[1]}, ) assert response.status_code == 201, response.get_json() diff --git a/api/tests/unit_tests/pyrefly.toml b/api/tests/unit_tests/pyrefly.toml index 18d3252ef04..85b3860cd9a 100644 --- a/api/tests/unit_tests/pyrefly.toml +++ b/api/tests/unit_tests/pyrefly.toml @@ -175,7 +175,6 @@ project-excludes = [ "controllers/openapi/test_oauth_sso_csrf.py", "controllers/openapi/test_oauth_sso_host_header.py", "controllers/openapi/test_supported_app_type.py", - "controllers/openapi/test_version_gate.py", "controllers/openapi/test_workflow_events_openapi.py", "controllers/openapi/test_workspaces.py", "controllers/openapi/test_workspaces_members.py", diff --git a/api/uv.lock b/api/uv.lock index cfef20836a2..fab3c62c442 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1478,6 +1478,7 @@ dev = [ { name = "types-greenlet" }, { name = "types-html5lib" }, { name = "types-jmespath" }, + { name = "types-jsonschema" }, { name = "types-markdown" }, { name = "types-oauthlib" }, { name = "types-objgraph" }, @@ -1768,6 +1769,7 @@ dev = [ { name = "types-greenlet", specifier = ">=3.5.0.20260518" }, { name = "types-html5lib", specifier = ">=1.1.11.20260518" }, { name = "types-jmespath", specifier = ">=1.1.0.20260724" }, + { name = "types-jsonschema", specifier = ">=4.26.0.20260518" }, { name = "types-markdown", specifier = ">=3.10.2.20260712" }, { name = "types-oauthlib", specifier = ">=3.3.0.20260724" }, { name = "types-objgraph", specifier = ">=3.6.0.20260508" }, @@ -6956,6 +6958,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/e4/ed44f19a906c8f249653e1f1befb5f1cc87a7826629375ae7ceb62fcd0cc/types_jmespath-1.1.0.20260724-py3-none-any.whl", hash = "sha256:ad965080d2ef25f4dc95da30819be559597c4301efc0311a9571b2ce8fc62c32", size = 11524, upload-time = "2026-07-24T04:58:23.763Z" }, ] +[[package]] +name = "types-jsonschema" +version = "4.26.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/46/73b6a5d61a61015c4248030a8cb07e5bdddb4041430fae9e585a68692578/types_jsonschema-4.26.0.20260518.tar.gz", hash = "sha256:e1dd53dc97a64f5eccdd6fa9839666e09bb500a8ebba2db6fdaf1789faea81a6", size = 16638, upload-time = "2026-05-18T06:06:44.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/d5/134f8a147dcecda10db7f60cfc6af0578a25a5c53c87b3907a64385e0184/types_jsonschema-4.26.0.20260518-py3-none-any.whl", hash = "sha256:30b30a518c7fe335df85c919fcbcc631b69c03d4a4b5b632fa916bea03065307", size = 16072, upload-time = "2026-05-18T06:06:43.264Z" }, +] + [[package]] name = "types-markdown" version = "3.10.2.20260712" diff --git a/cli/src/api/workspaces.test.ts b/cli/src/api/workspaces.test.ts index 4ad4b3d64d5..4e7b4b11ba8 100644 --- a/cli/src/api/workspaces.test.ts +++ b/cli/src/api/workspaces.test.ts @@ -26,9 +26,11 @@ describe('WorkspacesClient.list', () => { jsonResponder( 200, { - workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, - ], + page: 1, + limit: 200, + total: 1, + has_more: false, + data: [{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }], }, cap, ), @@ -37,8 +39,8 @@ describe('WorkspacesClient.list', () => { const res = await makeClient(stub.url).list() expect(stub.captured.method).toBe('GET') - expect(stub.captured.url).toBe('/openapi/v1/workspaces') - expect(res.workspaces[0]?.id).toBe('ws-1') + expect(stub.captured.url).toBe('/openapi/v1/workspaces?page=1&limit=200') + expect(res.data[0]?.id).toBe('ws-1') }) it('maps 401 to a classified BaseError', async () => { diff --git a/cli/src/api/workspaces.ts b/cli/src/api/workspaces.ts index 3aad3276f4a..c846a81ca9e 100644 --- a/cli/src/api/workspaces.ts +++ b/cli/src/api/workspaces.ts @@ -5,6 +5,7 @@ import type { import type { OpenApiClient } from '@/http/orpc' import type { HttpClient } from '@/http/types' import { createOpenApiClient } from '@/http/orpc' +import { fetchAllPages, LIMIT_MAX } from '@/limit/limit' export class WorkspacesClient { private readonly orpc: OpenApiClient @@ -16,8 +17,12 @@ export class WorkspacesClient { this.orpc = createOpenApiClient(http) } + /** Every workspace the caller belongs to, all pages merged into one envelope. */ async list(): Promise { - return this.orpc.workspaces.get() + const data = await fetchAllPages((page) => + this.orpc.workspaces.get({ query: { page, limit: LIMIT_MAX } }), + ) + return { page: 1, limit: LIMIT_MAX, total: data.length, has_more: false, data } } /** diff --git a/cli/src/commands/auth/devices/_shared/devices.ts b/cli/src/commands/auth/devices/_shared/devices.ts index 94c7dac1ff7..43c8557cb96 100644 --- a/cli/src/commands/auth/devices/_shared/devices.ts +++ b/cli/src/commands/auth/devices/_shared/devices.ts @@ -6,7 +6,7 @@ import type { IOStreams } from '@/sys/io/streams' import { AccountSessionsClient } from '@/api/account-sessions' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' -import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '@/limit/limit' +import { fetchAllPages, LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '@/limit/limit' import { colorEnabled, colorScheme } from '@/sys/io/color' import { promptConfirm } from '@/sys/io/prompt' import { runWithSpinner } from '@/sys/io/spinner' @@ -53,17 +53,7 @@ function resolveLimit(raw: string | undefined, env: (k: string) => string | unde export async function listAllSessions( client: AccountSessionsClient, ): Promise { - const out: SessionRow[] = [] - let page = 1 - // Hard guard against a misbehaving server that lies about has_more. - const MAX_PAGES = 100 - while (page <= MAX_PAGES) { - const env = await client.list({ page, limit: LIMIT_MAX }) - out.push(...env.data) - if (!env.has_more) return out - page++ - } - return out + return fetchAllPages((page) => client.list({ page, limit: LIMIT_MAX })) } export type DevicesRevokeOptions = { diff --git a/cli/src/commands/get/app/run.ts b/cli/src/commands/get/app/run.ts index e05973bec5d..72fcbaac68f 100644 --- a/cli/src/commands/get/app/run.ts +++ b/cli/src/commands/get/app/run.ts @@ -149,10 +149,10 @@ async function runAllWorkspaces( limit: number, ): Promise { const wsResp = await ws.list() - if (wsResp.workspaces.length === 0) return { page: 1, limit, total: 0, has_more: false, data: [] } + if (wsResp.data.length === 0) return { page: 1, limit, total: 0, has_more: false, data: [] } const merged: AppListResponse = { page: 1, limit, total: 0, has_more: false, data: [] } - const queue = [...wsResp.workspaces] + const queue = [...wsResp.data] const workers: Promise[] = [] const fetchOne = async (wsId: string): Promise => { @@ -175,7 +175,7 @@ async function runAllWorkspaces( } } - const N = Math.min(ALL_WORKSPACES_CONCURRENCY, wsResp.workspaces.length) + const N = Math.min(ALL_WORKSPACES_CONCURRENCY, wsResp.data.length) for (let i = 0; i < N; i++) workers.push(runner()) await Promise.all(workers) diff --git a/cli/src/commands/get/workspace/handlers.test.ts b/cli/src/commands/get/workspace/handlers.test.ts index ccc7904d624..d761ddbc589 100644 --- a/cli/src/commands/get/workspace/handlers.test.ts +++ b/cli/src/commands/get/workspace/handlers.test.ts @@ -4,7 +4,11 @@ import { WorkspaceListOutput, WorkspaceRow } from './handlers' function env(): WorkspaceListResponse { return { - workspaces: [ + page: 1, + limit: 20, + total: 2, + has_more: false, + data: [ { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, { id: '00000000-0000-0000-0000-000000000002', @@ -43,6 +47,6 @@ describe('get/workspace handlers', () => { ]) expect(output.tableRows()).toEqual([['ws-1', 'Default', 'owner', 'normal', '*']]) expect(output.name()).toBe('ws-1') - expect(output.json().workspaces).toHaveLength(2) + expect(output.json().data).toHaveLength(2) }) }) diff --git a/cli/src/commands/get/workspace/run.test.ts b/cli/src/commands/get/workspace/run.test.ts index b9eae4be270..2e1529b1d0d 100644 --- a/cli/src/commands/get/workspace/run.test.ts +++ b/cli/src/commands/get/workspace/run.test.ts @@ -89,20 +89,20 @@ describe('runGetWorkspace', () => { it('-o json emits a parseable workspaces envelope', async () => { const out = await render('json') const parsed = JSON.parse(out) as { - workspaces: Array<{ id: string; status: string; current: boolean }> + data: Array<{ id: string; status: string; current: boolean }> } - expect(parsed.workspaces).toHaveLength(2) - expect(parsed.workspaces.map((w) => w.id).sort()).toEqual([ + expect(parsed.data).toHaveLength(2) + expect(parsed.data.map((w) => w.id).sort()).toEqual([ '550e8400-e29b-41d4-a716-446655440000', '550e8400-e29b-41d4-a716-446655440001', ]) - expect(parsed.workspaces[0]?.status).toBe('normal') - expect(parsed.workspaces[0]?.current).toBe(true) + expect(parsed.data[0]?.status).toBe('normal') + expect(parsed.data[0]?.current).toBe(true) }) - it('-o yaml emits "workspaces:" header', async () => { + it('-o yaml emits "data:" header', async () => { const out = await render('yaml') - expect(out).toContain('workspaces:') + expect(out).toContain('data:') expect(out).toContain('550e8400-e29b-41d4-a716-446655440000') }) diff --git a/cli/src/commands/get/workspace/run.ts b/cli/src/commands/get/workspace/run.ts index 1232cd0aca0..0c9de9ca663 100644 --- a/cli/src/commands/get/workspace/run.ts +++ b/cli/src/commands/get/workspace/run.ts @@ -33,12 +33,12 @@ export async function runGetWorkspace( const env = await runWithSpinner({ io, label: 'Fetching workspaces' }, () => wsFactory(deps.http).list(), ) - if (env.workspaces.length === 0) return { kind: 'empty', message: EMPTY_WORKSPACES_MESSAGE } + if (env.data.length === 0) return { kind: 'empty', message: EMPTY_WORKSPACES_MESSAGE } const currentId = deps.active.ctx.workspace?.id ?? '' return { kind: 'output', data: new WorkspaceListOutput( - env.workspaces.map( + env.data.map( (w) => new WorkspaceRow( w.id, diff --git a/cli/src/commands/use/workspace/use.test.ts b/cli/src/commands/use/workspace/use.test.ts index 938a74520b8..f09ddd2e607 100644 --- a/cli/src/commands/use/workspace/use.test.ts +++ b/cli/src/commands/use/workspace/use.test.ts @@ -56,7 +56,11 @@ function fakeClient(opts: { opts.list ?? (() => Promise.resolve({ - workspaces: [ + page: 1, + limit: 20, + total: 2, + has_more: false, + data: [ { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, { id: '00000000-0000-0000-0000-000000000002', diff --git a/cli/src/commands/use/workspace/use.ts b/cli/src/commands/use/workspace/use.ts index ef0303cccac..7003482e25f 100644 --- a/cli/src/commands/use/workspace/use.ts +++ b/cli/src/commands/use/workspace/use.ts @@ -67,7 +67,7 @@ async function pickWorkspaceId(client: WorkspacesClient, deps: UseWorkspaceDeps) const list = await runWithSpinner({ io: deps.io, label: 'Loading workspaces' }, () => client.list(), ) - const items = list.workspaces.map((w) => ({ id: w.id, name: w.name, role: w.role })) + const items = list.data.map((w) => ({ id: w.id, name: w.name, role: w.role })) if (items.length === 0) { throw new BaseError({ code: ErrorCode.AccessDenied, diff --git a/cli/src/http/client.test.ts b/cli/src/http/client.test.ts index df817a471cb..6f981418203 100644 --- a/cli/src/http/client.test.ts +++ b/cli/src/http/client.test.ts @@ -44,8 +44,8 @@ describe('http client', () => { it('GET returns parsed JSON when bearer is valid', async () => { const client = createHttpClient({ baseURL: base(mock.url), bearer: 'dfoa_test' }) - const body = await client.get<{ workspaces: unknown[] }>('workspaces') - expect(body.workspaces).toHaveLength(2) + const body = await client.get<{ data: unknown[] }>('workspaces') + expect(body.data).toHaveLength(2) }) it('omits Authorization header when bearer is undefined', async () => { @@ -189,8 +189,8 @@ describe('http client', () => { it('respects insecure URL trim (trailing slash on baseURL is normalized)', async () => { const client = createHttpClient({ baseURL: openAPIBase(`${mock.url}/`), bearer: 'dfoa_test' }) - const body = await client.get<{ workspaces: unknown[] }>('workspaces') - expect(body.workspaces).toHaveLength(2) + const body = await client.get<{ data: unknown[] }>('workspaces') + expect(body.data).toHaveLength(2) }) it('preserves error envelope hint when server returns one', async () => { @@ -652,8 +652,8 @@ describe('extend()', () => { timeoutMs: 1, }) // 1ms client default would always fail the mock GET; per-call override of 5s lets it succeed. - const body = await parent.get<{ workspaces: unknown[] }>('workspaces', { timeoutMs: 5_000 }) - expect(body.workspaces.length).toBeGreaterThan(0) + const body = await parent.get<{ data: unknown[] }>('workspaces', { timeoutMs: 5_000 }) + expect(body.data.length).toBeGreaterThan(0) }) }) @@ -868,8 +868,8 @@ describe('request() entrypoint (oRPC OpenAPILink socket)', () => { }) const res = await client.request(new Request(`${base(mock.url)}workspaces`)) expect(res.status).toBe(200) - const body = (await res.json()) as { workspaces: unknown[] } - expect(body.workspaces).toHaveLength(2) + const body = (await res.json()) as { data: unknown[] } + expect(body.data).toHaveLength(2) } finally { await mock.stop() } diff --git a/cli/src/limit/limit.ts b/cli/src/limit/limit.ts index c1f194b03c6..0684496f078 100644 --- a/cli/src/limit/limit.ts +++ b/cli/src/limit/limit.ts @@ -20,3 +20,20 @@ export function parseLimit(raw: string, source: string): number { } return n } + +const MAX_PAGES = 100 + +export type Page = { readonly data: readonly T[]; readonly has_more: boolean } + +/** Walks pages from 1 until has_more is false; MAX_PAGES caps a server that never says so. */ +export async function fetchAllPages( + fetchPage: (page: number) => Promise>, +): Promise { + const out: T[] = [] + for (let page = 1; page <= MAX_PAGES; page++) { + const env = await fetchPage(page) + out.push(...env.data) + if (!env.has_more) break + } + return out +} diff --git a/cli/test/fixtures/dify-mock/server.test.ts b/cli/test/fixtures/dify-mock/server.test.ts index e7a3a12c3dd..1c0cabe3130 100644 --- a/cli/test/fixtures/dify-mock/server.test.ts +++ b/cli/test/fixtures/dify-mock/server.test.ts @@ -56,7 +56,7 @@ describe('dify-mock fixture server', () => { }) expect(r.status).toBe(200) const body = (await r.json()) as { - workspaces: Array<{ + data: Array<{ id: string name: string role: string @@ -64,11 +64,11 @@ describe('dify-mock fixture server', () => { current: boolean }> } - expect(body.workspaces).toHaveLength(2) - expect(body.workspaces[0]?.id).toBe('550e8400-e29b-41d4-a716-446655440000') - expect(body.workspaces[0]?.status).toBe('normal') - expect(body.workspaces[0]?.current).toBe(true) - expect(body.workspaces[1]?.current).toBe(false) + expect(body.data).toHaveLength(2) + expect(body.data[0]?.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(body.data[0]?.status).toBe('normal') + expect(body.data[0]?.current).toBe(true) + expect(body.data[1]?.current).toBe(false) }) it('GET /openapi/v1/workspaces returns empty list under sso scenario', async () => { @@ -77,8 +77,8 @@ describe('dify-mock fixture server', () => { headers: { Authorization: 'Bearer dfoa_test' }, }) expect(r.status).toBe(200) - const body = (await r.json()) as { workspaces: unknown[] } - expect(body.workspaces).toHaveLength(0) + const body = (await r.json()) as { data: unknown[] } + expect(body.data).toHaveLength(0) }) it('GET /openapi/v1/account returns the seeded account envelope', async () => { diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts index 42f4d561f6b..47c81186b3f 100644 --- a/cli/test/fixtures/dify-mock/server.ts +++ b/cli/test/fixtures/dify-mock/server.ts @@ -223,15 +223,25 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { }) app.get('/openapi/v1/workspaces', (c) => { - if (getScenario() === 'sso') return c.json({ workspaces: [] }) + const page = Number(c.req.query('page') ?? '1') + const limit = Number(c.req.query('limit') ?? '20') + const rows = + getScenario() === 'sso' + ? [] + : WORKSPACES.map((w) => ({ + id: w.id, + name: w.name, + role: w.role, + status: w.status, + current: w.is_current, + })) + const total = rows.length return c.json({ - workspaces: WORKSPACES.map((w) => ({ - id: w.id, - name: w.name, - role: w.role, - status: w.status, - current: w.is_current, - })), + page, + limit, + total, + has_more: page * limit < total, + data: rows.slice((page - 1) * limit, page * limit), }) }) diff --git a/packages/contracts/generated/api/openapi/orpc.gen.ts b/packages/contracts/generated/api/openapi/orpc.gen.ts index 0640eeeb6a4..416e9dc9f34 100644 --- a/packages/contracts/generated/api/openapi/orpc.gen.ts +++ b/packages/contracts/generated/api/openapi/orpc.gen.ts @@ -26,6 +26,7 @@ import { zGetAppsByAppIdTasksByTaskIdEventsResponse, zGetAppsQuery, zGetAppsResponse, + zGetCatalogResponse, zGetHealthResponse, zGetOauthDeviceLookupQuery, zGetOauthDeviceLookupResponse, @@ -40,20 +41,31 @@ import { zGetWorkspacesByWorkspaceIdMembersResponse, zGetWorkspacesByWorkspaceIdPath, zGetWorkspacesByWorkspaceIdResponse, + zGetWorkspacesQuery, zGetWorkspacesResponse, zPatchWorkspacesByWorkspaceIdMembersByMemberIdBody, zPatchWorkspacesByWorkspaceIdMembersByMemberIdPath, zPatchWorkspacesByWorkspaceIdMembersByMemberIdResponse, + zPostAppsByAppIdAdvancedChatRunBody, + zPostAppsByAppIdAdvancedChatRunPath, + zPostAppsByAppIdAdvancedChatRunResponse, + zPostAppsByAppIdChatRunBody, + zPostAppsByAppIdChatRunPath, + zPostAppsByAppIdChatRunResponse, + zPostAppsByAppIdCompletionRunBody, + zPostAppsByAppIdCompletionRunPath, + zPostAppsByAppIdCompletionRunResponse, + zPostAppsByAppIdFilesBody, zPostAppsByAppIdFilesPath, zPostAppsByAppIdFilesResponse, zPostAppsByAppIdHumanInputFormsByFormTokenSubmitBody, zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath, zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse, - zPostAppsByAppIdRunBody, - zPostAppsByAppIdRunPath, - zPostAppsByAppIdRunResponse, zPostAppsByAppIdTasksByTaskIdStopPath, zPostAppsByAppIdTasksByTaskIdStopResponse, + zPostAppsByAppIdWorkflowRunBody, + zPostAppsByAppIdWorkflowRunPath, + zPostAppsByAppIdWorkflowRunResponse, zPostOauthDeviceApproveBody, zPostOauthDeviceApproveResponse, zPostOauthDeviceCodeBody, @@ -74,7 +86,25 @@ import { zPostWorkspacesByWorkspaceIdSwitchResponse, } from './zod.gen' +/** + * Machine-readable catalog of every op on this surface + */ export const get = oc + .route({ + description: 'Machine-readable catalog of every op on this surface', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getCatalog', + path: '/_catalog', + tags: ['openapi'], + }) + .output(zGetCatalogResponse) + +export const catalog = { + get, +} + +export const get2 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -85,10 +115,10 @@ export const get = oc .output(zGetHealthResponse) export const health = { - get, + get: get2, } -export const get2 = oc +export const get3 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -99,7 +129,7 @@ export const get2 = oc .output(zGetVersionResponse) export const version = { - get: get2, + get: get3, } export const delete_ = oc @@ -131,7 +161,7 @@ export const bySessionId = { delete: delete2, } -export const get3 = oc +export const get4 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -143,12 +173,12 @@ export const get3 = oc .output(zGetAccountSessionsResponse) export const sessions = { - get: get3, + get: get4, self, bySessionId, } -export const get4 = oc +export const get5 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -159,11 +189,78 @@ export const get4 = oc .output(zGetAccountResponse) export const account = { - get: get4, + get: get5, sessions, } -export const get5 = oc +export const post = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdAdvancedChatRun', + path: '/apps/{app_id}/advanced-chat:run', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPostAppsByAppIdAdvancedChatRunBody, + params: zPostAppsByAppIdAdvancedChatRunPath, + }), + ) + .output(zPostAppsByAppIdAdvancedChatRunResponse) + +export const run = { + post, +} + +export const advancedChat = { + run, +} + +export const post2 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdChatRun', + path: '/apps/{app_id}/chat:run', + tags: ['openapi'], + }) + .input(z.object({ body: zPostAppsByAppIdChatRunBody, params: zPostAppsByAppIdChatRunPath })) + .output(zPostAppsByAppIdChatRunResponse) + +export const run2 = { + post: post2, +} + +export const chat = { + run: run2, +} + +export const post3 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdCompletionRun', + path: '/apps/{app_id}/completion:run', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPostAppsByAppIdCompletionRunBody, + params: zPostAppsByAppIdCompletionRunPath, + }), + ) + .output(zPostAppsByAppIdCompletionRunResponse) + +export const run3 = { + post: post3, +} + +export const completion = { + run: run3, +} + +export const get6 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -175,14 +272,14 @@ export const get5 = oc .output(zGetAppsByAppIdDependenciesCheckResponse) export const check = { - get: get5, + get: get6, } export const dependencies = { check, } -export const get6 = oc +export const get7 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -194,13 +291,13 @@ export const get6 = oc .output(zGetAppsByAppIdDslResponse) export const dsl = { - get: get6, + get: get7, } /** * Upload a file to use as an input variable when running the app */ -export const post = oc +export const post4 = oc .route({ description: 'Upload a file to use as an input variable when running the app', inputStructure: 'detailed', @@ -210,14 +307,14 @@ export const post = oc successStatus: 201, tags: ['openapi'], }) - .input(z.object({ params: zPostAppsByAppIdFilesPath })) + .input(z.object({ body: zPostAppsByAppIdFilesBody, params: zPostAppsByAppIdFilesPath })) .output(zPostAppsByAppIdFilesResponse) export const files = { - post, + post: post4, } -export const post2 = oc +export const post5 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -234,10 +331,10 @@ export const post2 = oc .output(zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse) export const submit = { - post: post2, + post: post5, } -export const get7 = oc +export const get8 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -249,7 +346,7 @@ export const get7 = oc .output(zGetAppsByAppIdHumanInputFormsByFormTokenResponse) export const byFormToken = { - get: get7, + get: get8, submit, } @@ -257,7 +354,7 @@ export const humanInputForms = { byFormToken, } -export const get8 = oc +export const get9 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -274,10 +371,10 @@ export const get8 = oc .output(zGetAppsByAppIdTasksByTaskIdEventsResponse) export const events = { - get: get8, + get: get9, } -export const post3 = oc +export const post6 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -289,7 +386,7 @@ export const post3 = oc .output(zPostAppsByAppIdTasksByTaskIdStopResponse) export const stop = { - post: post3, + post: post6, } export const byTaskId = { @@ -301,22 +398,28 @@ export const tasks = { byTaskId, } -export const post4 = oc +export const post7 = oc .route({ inputStructure: 'detailed', method: 'POST', - operationId: 'postAppsByAppIdRun', - path: '/apps/{app_id}:run', + operationId: 'postAppsByAppIdWorkflowRun', + path: '/apps/{app_id}/workflow:run', tags: ['openapi'], }) - .input(z.object({ body: zPostAppsByAppIdRunBody, params: zPostAppsByAppIdRunPath })) - .output(zPostAppsByAppIdRunResponse) + .input( + z.object({ body: zPostAppsByAppIdWorkflowRunBody, params: zPostAppsByAppIdWorkflowRunPath }), + ) + .output(zPostAppsByAppIdWorkflowRunResponse) -export const run = { - post: post4, +export const run4 = { + post: post7, } -export const get9 = oc +export const workflow = { + run: run4, +} + +export const get10 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -328,16 +431,19 @@ export const get9 = oc .output(zGetAppsByAppIdResponse) export const byAppId = { - get: get9, + get: get10, + advancedChat, + chat, + completion, dependencies, dsl, files, humanInputForms, tasks, - run, + workflow, } -export const get10 = oc +export const get11 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -349,11 +455,11 @@ export const get10 = oc .output(zGetAppsResponse) export const apps = { - get: get10, + get: get11, byAppId, } -export const post5 = oc +export const post8 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -365,10 +471,10 @@ export const post5 = oc .output(zPostOauthDeviceApproveResponse) export const approve = { - post: post5, + post: post8, } -export const post6 = oc +export const post9 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -380,10 +486,10 @@ export const post6 = oc .output(zPostOauthDeviceCodeResponse) export const code = { - post: post6, + post: post9, } -export const post7 = oc +export const post10 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -395,10 +501,10 @@ export const post7 = oc .output(zPostOauthDeviceDenyResponse) export const deny = { - post: post7, + post: post10, } -export const get11 = oc +export const get12 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -410,10 +516,10 @@ export const get11 = oc .output(zGetOauthDeviceLookupResponse) export const lookup = { - get: get11, + get: get12, } -export const post8 = oc +export const post11 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -425,7 +531,7 @@ export const post8 = oc .output(zPostOauthDeviceTokenResponse) export const token = { - post: post8, + post: post11, } export const device = { @@ -440,7 +546,7 @@ export const oauth = { device, } -export const get12 = oc +export const get13 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -457,10 +563,10 @@ export const get12 = oc .output(zGetPermittedExternalAppsByAppIdResponse) export const byAppId2 = { - get: get12, + get: get13, } -export const get13 = oc +export const get14 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -472,11 +578,11 @@ export const get13 = oc .output(zGetPermittedExternalAppsResponse) export const permittedExternalApps = { - get: get13, + get: get14, byAppId: byAppId2, } -export const post9 = oc +export const post12 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -488,14 +594,14 @@ export const post9 = oc .output(zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse) export const confirm = { - post: post9, + post: post12, } export const byImportId = { confirm, } -export const post10 = oc +export const post13 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -512,7 +618,7 @@ export const post10 = oc .output(zPostWorkspacesByWorkspaceIdAppsImportsResponse) export const imports = { - post: post10, + post: post13, byImportId, } @@ -552,7 +658,7 @@ export const byMemberId = { patch, } -export const get14 = oc +export const get15 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -568,7 +674,7 @@ export const get14 = oc ) .output(zGetWorkspacesByWorkspaceIdMembersResponse) -export const post11 = oc +export const post14 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -586,12 +692,12 @@ export const post11 = oc .output(zPostWorkspacesByWorkspaceIdMembersResponse) export const members = { - get: get14, - post: post11, + get: get15, + post: post14, byMemberId, } -export const post12 = oc +export const post15 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -603,10 +709,10 @@ export const post12 = oc .output(zPostWorkspacesByWorkspaceIdSwitchResponse) export const switch_ = { - post: post12, + post: post15, } -export const get15 = oc +export const get16 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -618,13 +724,13 @@ export const get15 = oc .output(zGetWorkspacesByWorkspaceIdResponse) export const byWorkspaceId = { - get: get15, + get: get16, apps: apps2, members, switch: switch_, } -export const get16 = oc +export const get17 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -632,14 +738,16 @@ export const get16 = oc path: '/workspaces', tags: ['openapi'], }) + .input(z.object({ query: zGetWorkspacesQuery.optional() })) .output(zGetWorkspacesResponse) export const workspaces = { - get: get16, + get: get17, byWorkspaceId, } export const contract = { + catalog, health, version, account, diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 7aa67a61c26..b6242c85432 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -19,6 +19,21 @@ export type AccountResponse = { workspaces?: Array } +export type AdvancedChatRunPayload = { + attachments?: Array | null + auto_generate_name?: boolean + conversation_id?: string | null + files?: { + [key: string]: Blob | File | Array + } | null + inputs: { + [key: string]: unknown + } + query: string + workflow_id?: string | null + workspace_id?: string | null +} + export type AppDescribeInfo = { description?: string | null id: string @@ -64,6 +79,19 @@ export type AppDslImportPayload = { yaml_url?: string | null } +export type AppDslImportResponse = { + app_id?: string | null + app_mode?: string | null + current_dsl_version?: string + error?: string + hints?: Array + id: string + imported_dsl_version?: string + permission_keys?: Array + status: ImportStatus + warnings?: Array +} + export type AppInfo = { description?: string | null id: string @@ -82,6 +110,7 @@ export type AppListQuery = { export type AppListResponse = { data: Array has_more: boolean + hints?: Array limit: number page: number total: number @@ -107,17 +136,17 @@ export type AppMode = | 'rag-pipeline' | 'workflow' -export type AppRunRequest = { +export type ChatRunPayload = { + attachments?: Array | null auto_generate_name?: boolean conversation_id?: string | null - files?: Array<{ - [key: string]: unknown - }> | null + files?: { + [key: string]: Blob | File | Array + } | null inputs: { [key: string]: unknown } - query?: string | null - workflow_id?: string | null + query: string workspace_id?: string | null } @@ -125,6 +154,18 @@ export type CheckDependenciesResult = { leaked_dependencies?: Array } +export type CompletionRunPayload = { + attachments?: Array | null + files?: { + [key: string]: Blob | File | Array + } | null + inputs: { + [key: string]: unknown + } + query?: string + workspace_id?: string | null +} + export type DeploymentEdition = 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE' export type DeviceCodeRequest = { @@ -218,6 +259,10 @@ export type FileResponse = { user_id?: string | null } +export type FileUploadPayload = { + file: Blob | File +} + export type FormSubmitResponse = { [key: string]: never } @@ -233,6 +278,17 @@ export type HealthResponse = { ok: boolean } +export type Hint = { + form?: Array<{ + [key: string]: unknown + }> | null + input: { + [key: string]: unknown + } + op: string + summary: string +} + export type HumanInputFormDefinitionResponse = { expiration_time?: number | null form_content: string @@ -247,13 +303,6 @@ export type HumanInputFormDefinitionResponse = { }> } -export type HumanInputFormSubmitPayload = { - action: string - inputs: { - [key: string]: JsonValue - } -} - export type Import = { app_id?: string | null app_mode?: string | null @@ -301,6 +350,7 @@ export type MemberListQuery = { export type MemberListResponse = { data: Array has_more: boolean + hints?: Array limit: number page: number total: number @@ -331,6 +381,7 @@ export type OpenApiErrorCode = | 'app_unavailable' | 'bad_gateway' | 'bad_request' + | 'catalog_stale' | 'completion_request_error' | 'conflict' | 'conversation_completed' @@ -360,7 +411,16 @@ export type OpenApiErrorCode = | 'unknown' | 'unsupported_file_type' | 'unsupported_media_type' - | 'upgrade_required' + +export type OpenApiFormSubmitPayload = { + action: string + files?: { + [key: string]: Blob | File | Array + } | null + inputs: { + [key: string]: JsonValue + } +} export type Package = { plugin_unique_identifier: string @@ -377,6 +437,7 @@ export type PermittedExternalAppsListQuery = { export type PermittedExternalAppsListResponse = { data: Array has_more: boolean + hints?: Array limit: number page: number total: number @@ -407,6 +468,7 @@ export type SessionListQuery = { export type SessionListResponse = { data: Array has_more: boolean + hints?: Array limit: number page: number total: number @@ -455,6 +517,18 @@ export type WorkflowRunData = { workflow_id: string } +export type WorkflowRunPayload = { + attachments?: Array | null + files?: { + [key: string]: Blob | File | Array + } | null + inputs: { + [key: string]: unknown + } + workflow_id?: string | null + workspace_id?: string | null +} + export type WorkspaceDetailResponse = { created_at?: string | null current: boolean @@ -464,8 +538,18 @@ export type WorkspaceDetailResponse = { status: string } +export type WorkspaceListQuery = { + limit?: number + page?: number +} + export type WorkspaceListResponse = { - workspaces: Array + data: Array + has_more: boolean + hints?: Array + limit: number + page: number + total: number } export type WorkspacePayload = { @@ -482,6 +566,21 @@ export type WorkspaceSummaryResponse = { status: string } +export type GetCatalogData = { + body?: never + path?: never + query?: never + url: '/_catalog' +} + +export type GetCatalogResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetCatalogResponse = GetCatalogResponses[keyof GetCatalogResponses] + export type GetHealthData = { body?: never path?: never @@ -657,6 +756,78 @@ export type GetAppsByAppIdResponses = { export type GetAppsByAppIdResponse = GetAppsByAppIdResponses[keyof GetAppsByAppIdResponses] +export type PostAppsByAppIdAdvancedChatRunData = { + body: AdvancedChatRunPayload + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/advanced-chat:run' +} + +export type PostAppsByAppIdAdvancedChatRunErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostAppsByAppIdAdvancedChatRunError = + PostAppsByAppIdAdvancedChatRunErrors[keyof PostAppsByAppIdAdvancedChatRunErrors] + +export type PostAppsByAppIdAdvancedChatRunResponses = { + 200: EventStreamResponse +} + +export type PostAppsByAppIdAdvancedChatRunResponse = + PostAppsByAppIdAdvancedChatRunResponses[keyof PostAppsByAppIdAdvancedChatRunResponses] + +export type PostAppsByAppIdChatRunData = { + body: ChatRunPayload + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/chat:run' +} + +export type PostAppsByAppIdChatRunErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostAppsByAppIdChatRunError = + PostAppsByAppIdChatRunErrors[keyof PostAppsByAppIdChatRunErrors] + +export type PostAppsByAppIdChatRunResponses = { + 200: EventStreamResponse +} + +export type PostAppsByAppIdChatRunResponse = + PostAppsByAppIdChatRunResponses[keyof PostAppsByAppIdChatRunResponses] + +export type PostAppsByAppIdCompletionRunData = { + body: CompletionRunPayload + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/completion:run' +} + +export type PostAppsByAppIdCompletionRunErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostAppsByAppIdCompletionRunError = + PostAppsByAppIdCompletionRunErrors[keyof PostAppsByAppIdCompletionRunErrors] + +export type PostAppsByAppIdCompletionRunResponses = { + 200: EventStreamResponse +} + +export type PostAppsByAppIdCompletionRunResponse = + PostAppsByAppIdCompletionRunResponses[keyof PostAppsByAppIdCompletionRunResponses] + export type GetAppsByAppIdDependenciesCheckData = { body?: never path: { @@ -706,7 +877,7 @@ export type GetAppsByAppIdDslResponses = { export type GetAppsByAppIdDslResponse = GetAppsByAppIdDslResponses[keyof GetAppsByAppIdDslResponses] export type PostAppsByAppIdFilesData = { - body?: never + body: FileUploadPayload path: { app_id: string } @@ -719,6 +890,7 @@ export type PostAppsByAppIdFilesErrors = { 401: unknown 413: unknown 415: unknown + 422: ErrorBody default: ErrorBody } @@ -756,7 +928,7 @@ export type GetAppsByAppIdHumanInputFormsByFormTokenResponse = GetAppsByAppIdHumanInputFormsByFormTokenResponses[keyof GetAppsByAppIdHumanInputFormsByFormTokenResponses] export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitData = { - body: HumanInputFormSubmitPayload + body: OpenApiFormSubmitPayload path: { app_id: string form_token: string @@ -794,6 +966,7 @@ export type GetAppsByAppIdTasksByTaskIdEventsData = { } export type GetAppsByAppIdTasksByTaskIdEventsErrors = { + 422: ErrorBody default: ErrorBody } @@ -831,28 +1004,29 @@ export type PostAppsByAppIdTasksByTaskIdStopResponses = { export type PostAppsByAppIdTasksByTaskIdStopResponse = PostAppsByAppIdTasksByTaskIdStopResponses[keyof PostAppsByAppIdTasksByTaskIdStopResponses] -export type PostAppsByAppIdRunData = { - body: AppRunRequest +export type PostAppsByAppIdWorkflowRunData = { + body: WorkflowRunPayload path: { app_id: string } query?: never - url: '/apps/{app_id}:run' + url: '/apps/{app_id}/workflow:run' } -export type PostAppsByAppIdRunErrors = { +export type PostAppsByAppIdWorkflowRunErrors = { 422: ErrorBody default: ErrorBody } -export type PostAppsByAppIdRunError = PostAppsByAppIdRunErrors[keyof PostAppsByAppIdRunErrors] +export type PostAppsByAppIdWorkflowRunError = + PostAppsByAppIdWorkflowRunErrors[keyof PostAppsByAppIdWorkflowRunErrors] -export type PostAppsByAppIdRunResponses = { +export type PostAppsByAppIdWorkflowRunResponses = { 200: EventStreamResponse } -export type PostAppsByAppIdRunResponse = - PostAppsByAppIdRunResponses[keyof PostAppsByAppIdRunResponses] +export type PostAppsByAppIdWorkflowRunResponse = + PostAppsByAppIdWorkflowRunResponses[keyof PostAppsByAppIdWorkflowRunResponses] export type PostOauthDeviceApproveData = { body: DeviceMutateRequest @@ -982,11 +1156,15 @@ export type GetPermittedExternalAppsByAppIdResponse = export type GetWorkspacesData = { body?: never path?: never - query?: never + query?: { + limit?: number + page?: number + } url: '/workspaces' } export type GetWorkspacesErrors = { + 422: ErrorBody default: ErrorBody } @@ -1031,7 +1209,7 @@ export type PostWorkspacesByWorkspaceIdAppsImportsData = { } export type PostWorkspacesByWorkspaceIdAppsImportsErrors = { - 400: Import + 400: AppDslImportResponse 422: ErrorBody default: ErrorBody } @@ -1040,8 +1218,8 @@ export type PostWorkspacesByWorkspaceIdAppsImportsError = PostWorkspacesByWorkspaceIdAppsImportsErrors[keyof PostWorkspacesByWorkspaceIdAppsImportsErrors] export type PostWorkspacesByWorkspaceIdAppsImportsResponses = { - 200: Import - 202: Import + 200: AppDslImportResponse + 202: AppDslImportResponse } export type PostWorkspacesByWorkspaceIdAppsImportsResponse = diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 722ff155c30..74078abe902 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -11,6 +11,32 @@ export const zAccountPayload = z.object({ name: z.string(), }) +/** + * AdvancedChatRunPayload + * + * A chat run against an advanced-chat (chatflow) app, which can also pin a workflow version. + */ +export const zAdvancedChatRunPayload = z.object({ + attachments: z + .array(z.custom((value) => value instanceof Blob || value instanceof File)) + .nullish(), + auto_generate_name: z.boolean().optional().default(true), + conversation_id: z.string().nullish(), + files: z + .record( + z.string(), + z.union([ + z.custom((value) => value instanceof Blob || value instanceof File), + z.array(z.custom((value) => value instanceof Blob || value instanceof File)), + ]), + ) + .nullish(), + inputs: z.record(z.string(), z.unknown()), + query: z.string(), + workflow_id: z.string().nullish(), + workspace_id: z.string().nullish(), +}) + /** * AppDescribeInfo */ @@ -118,26 +144,46 @@ export const zAppListRow = z.object({ }) /** - * AppListResponse + * ChatRunPayload */ -export const zAppListResponse = z.object({ - data: z.array(zAppListRow), - has_more: z.boolean(), - limit: z.int(), - page: z.int(), - total: z.int(), +export const zChatRunPayload = z.object({ + attachments: z + .array(z.custom((value) => value instanceof Blob || value instanceof File)) + .nullish(), + auto_generate_name: z.boolean().optional().default(true), + conversation_id: z.string().nullish(), + files: z + .record( + z.string(), + z.union([ + z.custom((value) => value instanceof Blob || value instanceof File), + z.array(z.custom((value) => value instanceof Blob || value instanceof File)), + ]), + ) + .nullish(), + inputs: z.record(z.string(), z.unknown()), + query: z.string(), + workspace_id: z.string().nullish(), }) /** - * AppRunRequest + * CompletionRunPayload */ -export const zAppRunRequest = z.object({ - auto_generate_name: z.boolean().optional().default(true), - conversation_id: z.string().nullish(), - files: z.array(z.record(z.string(), z.unknown())).nullish(), +export const zCompletionRunPayload = z.object({ + attachments: z + .array(z.custom((value) => value instanceof Blob || value instanceof File)) + .nullish(), + files: z + .record( + z.string(), + z.union([ + z.custom((value) => value instanceof Blob || value instanceof File), + z.array(z.custom((value) => value instanceof Blob || value instanceof File)), + ]), + ) + .nullish(), inputs: z.record(z.string(), z.unknown()), - query: z.string().nullish(), - workflow_id: z.string().nullish(), + query: z.string().optional().default(''), workspace_id: z.string().nullish(), }) @@ -270,6 +316,13 @@ export const zFileResponse = z.object({ user_id: z.uuid().nullish(), }) +/** + * FileUploadPayload + */ +export const zFileUploadPayload = z.object({ + file: z.custom((value) => value instanceof Blob || value instanceof File), +}) + /** * FormSubmitResponse * @@ -298,6 +351,30 @@ export const zHealthResponse = z.object({ ok: z.boolean(), }) +/** + * Hint + * + * A next step the caller can hand straight to `call --input `. + */ +export const zHint = z.object({ + form: z.array(z.record(z.string(), z.unknown())).nullish(), + input: z.record(z.string(), z.unknown()), + op: z.string(), + summary: z.string(), +}) + +/** + * AppListResponse + */ +export const zAppListResponse = z.object({ + data: z.array(zAppListRow), + has_more: z.boolean(), + hints: z.array(zHint).optional(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * HumanInputFormDefinitionResponse */ @@ -314,6 +391,24 @@ export const zHumanInputFormDefinitionResponse = z.object({ */ export const zImportStatus = z.enum(['completed', 'completed-with-warnings', 'failed', 'pending']) +/** + * AppDslImportResponse + * + * `Import` plus the server-built next step for a pending import. + */ +export const zAppDslImportResponse = z.object({ + app_id: z.string().nullish(), + app_mode: z.string().nullish(), + current_dsl_version: z.string().optional().default('0.7.0'), + error: z.string().optional().default(''), + hints: z.array(zHint).optional(), + id: z.string(), + imported_dsl_version: z.string().optional().default(''), + permission_keys: z.array(z.string()).optional(), + status: zImportStatus, + warnings: z.array(zDslImportWarning).optional(), +}) + /** * Import */ @@ -331,14 +426,6 @@ export const zImport = z.object({ export const zJsonValue = z.unknown() -/** - * HumanInputFormSubmitPayload - */ -export const zHumanInputFormSubmitPayload = z.object({ - action: z.string(), - inputs: z.record(z.string(), zJsonValue), -}) - /** * Marketplace */ @@ -380,7 +467,7 @@ export const zMemberInviteResponse = z.object({ * Strict (extra='forbid'). */ export const zMemberListQuery = z.object({ - limit: z.int().gte(1).lte(200).optional().default(20), + limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).optional().default(1), }) @@ -402,6 +489,7 @@ export const zMemberResponse = z.object({ export const zMemberListResponse = z.object({ data: z.array(zMemberResponse), has_more: z.boolean(), + hints: z.array(zHint).optional(), limit: z.int(), page: z.int(), total: z.int(), @@ -422,6 +510,7 @@ export const zOpenApiErrorCode = z.enum([ 'app_unavailable', 'bad_gateway', 'bad_request', + 'catalog_stale', 'completion_request_error', 'conflict', 'conversation_completed', @@ -451,9 +540,27 @@ export const zOpenApiErrorCode = z.enum([ 'unknown', 'unsupported_file_type', 'unsupported_media_type', - 'upgrade_required', ]) +/** + * OpenApiFormSubmitPayload + * + * The console payload plus local file parts; `_files.merge_files` sets them on `inputs`. + */ +export const zOpenApiFormSubmitPayload = z.object({ + action: z.string(), + files: z + .record( + z.string(), + z.union([ + z.custom((value) => value instanceof Blob || value instanceof File), + z.array(z.custom((value) => value instanceof Blob || value instanceof File)), + ]), + ) + .nullish(), + inputs: z.record(z.string(), zJsonValue), +}) + /** * Package */ @@ -468,6 +575,7 @@ export const zPackage = z.object({ export const zPermittedExternalAppsListResponse = z.object({ data: z.array(zAppListRow), has_more: z.boolean(), + hints: z.array(zHint).optional(), limit: z.int(), page: z.int(), total: z.int(), @@ -517,7 +625,7 @@ export const zServerVersionResponse = z.object({ * Pagination for GET /account/sessions. Strict (extra='forbid'). */ export const zSessionListQuery = z.object({ - limit: z.int().gte(1).lte(200).optional().default(100), + limit: z.int().gte(1).lte(100).optional().default(100), page: z.int().gte(1).optional().default(1), }) @@ -540,6 +648,7 @@ export const zSessionRow = z.object({ export const zSessionListResponse = z.object({ data: z.array(zSessionRow), has_more: z.boolean(), + hints: z.array(zHint).optional(), limit: z.int(), page: z.int(), total: z.int(), @@ -586,7 +695,7 @@ export const zSupportedAppType = z.enum([ * mode is a closed enum of listable app types. */ export const zAppListQuery = z.object({ - limit: z.int().gte(1).lte(200).optional().default(20), + limit: z.int().gte(1).lte(100).optional().default(20), mode: zSupportedAppType.nullish(), name: z.string().max(200).nullish(), page: z.int().gte(1).optional().default(1), @@ -599,7 +708,7 @@ export const zAppListQuery = z.object({ * Strict (extra='forbid'). */ export const zPermittedExternalAppsListQuery = z.object({ - limit: z.int().gte(1).lte(200).optional().default(20), + limit: z.int().gte(1).lte(100).optional().default(20), mode: zSupportedAppType.nullish(), name: z.string().max(200).nullish(), page: z.int().gte(1).optional().default(1), @@ -649,6 +758,27 @@ export const zWorkflowRunData = z.object({ workflow_id: z.string(), }) +/** + * WorkflowRunPayload + */ +export const zWorkflowRunPayload = z.object({ + attachments: z + .array(z.custom((value) => value instanceof Blob || value instanceof File)) + .nullish(), + files: z + .record( + z.string(), + z.union([ + z.custom((value) => value instanceof Blob || value instanceof File), + z.array(z.custom((value) => value instanceof Blob || value instanceof File)), + ]), + ) + .nullish(), + inputs: z.record(z.string(), z.unknown()), + workflow_id: z.string().nullish(), + workspace_id: z.string().nullish(), +}) + /** * WorkspaceDetailResponse */ @@ -661,6 +791,16 @@ export const zWorkspaceDetailResponse = z.object({ status: z.string(), }) +/** + * WorkspaceListQuery + * + * Strict (extra='forbid'). + */ +export const zWorkspaceListQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), +}) + /** * WorkspacePayload */ @@ -712,9 +852,19 @@ export const zWorkspaceSummaryResponse = z.object({ * WorkspaceListResponse */ export const zWorkspaceListResponse = z.object({ - workspaces: z.array(zWorkspaceSummaryResponse), + data: z.array(zWorkspaceSummaryResponse), + has_more: z.boolean(), + hints: z.array(zHint).optional(), + limit: z.int(), + page: z.int(), + total: z.int(), }) +/** + * Success + */ +export const zGetCatalogResponse = z.record(z.string(), z.unknown()) + /** * Health check */ @@ -731,7 +881,7 @@ export const zGetVersionResponse = zServerVersionResponse export const zGetAccountResponse = zAccountResponse export const zGetAccountSessionsQuery = z.object({ - limit: z.int().gte(1).lte(200).optional().default(100), + limit: z.int().gte(1).lte(100).optional().default(100), page: z.int().gte(1).optional().default(1), }) @@ -755,7 +905,7 @@ export const zDeleteAccountSessionsBySessionIdPath = z.object({ export const zDeleteAccountSessionsBySessionIdResponse = zRevokeResponse export const zGetAppsQuery = z.object({ - limit: z.int().gte(1).lte(200).optional().default(20), + limit: z.int().gte(1).lte(100).optional().default(20), mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']).optional(), name: z.string().max(200).optional(), page: z.int().gte(1).optional().default(1), @@ -780,6 +930,39 @@ export const zGetAppsByAppIdQuery = z.object({ */ export const zGetAppsByAppIdResponse = zAppDescribeResponse +export const zPostAppsByAppIdAdvancedChatRunBody = zAdvancedChatRunPayload + +export const zPostAppsByAppIdAdvancedChatRunPath = z.object({ + app_id: z.string(), +}) + +/** + * Run result (SSE stream) + */ +export const zPostAppsByAppIdAdvancedChatRunResponse = zEventStreamResponse + +export const zPostAppsByAppIdChatRunBody = zChatRunPayload + +export const zPostAppsByAppIdChatRunPath = z.object({ + app_id: z.string(), +}) + +/** + * Run result (SSE stream) + */ +export const zPostAppsByAppIdChatRunResponse = zEventStreamResponse + +export const zPostAppsByAppIdCompletionRunBody = zCompletionRunPayload + +export const zPostAppsByAppIdCompletionRunPath = z.object({ + app_id: z.string(), +}) + +/** + * Run result (SSE stream) + */ +export const zPostAppsByAppIdCompletionRunResponse = zEventStreamResponse + export const zGetAppsByAppIdDependenciesCheckPath = z.object({ app_id: z.string(), }) @@ -803,6 +986,8 @@ export const zGetAppsByAppIdDslQuery = z.object({ */ export const zGetAppsByAppIdDslResponse = zAppDslExportResponse +export const zPostAppsByAppIdFilesBody = zFileUploadPayload + export const zPostAppsByAppIdFilesPath = z.object({ app_id: z.string(), }) @@ -822,7 +1007,7 @@ export const zGetAppsByAppIdHumanInputFormsByFormTokenPath = z.object({ */ export const zGetAppsByAppIdHumanInputFormsByFormTokenResponse = zHumanInputFormDefinitionResponse -export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitBody = zHumanInputFormSubmitPayload +export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitBody = zOpenApiFormSubmitPayload export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath = z.object({ app_id: z.string(), @@ -859,16 +1044,16 @@ export const zPostAppsByAppIdTasksByTaskIdStopPath = z.object({ */ export const zPostAppsByAppIdTasksByTaskIdStopResponse = zTaskStopResponse -export const zPostAppsByAppIdRunBody = zAppRunRequest +export const zPostAppsByAppIdWorkflowRunBody = zWorkflowRunPayload -export const zPostAppsByAppIdRunPath = z.object({ +export const zPostAppsByAppIdWorkflowRunPath = z.object({ app_id: z.string(), }) /** * Run result (SSE stream) */ -export const zPostAppsByAppIdRunResponse = zEventStreamResponse +export const zPostAppsByAppIdWorkflowRunResponse = zEventStreamResponse export const zPostOauthDeviceApproveBody = zDeviceMutateRequest @@ -908,7 +1093,7 @@ export const zPostOauthDeviceTokenBody = zDevicePollRequest export const zPostOauthDeviceTokenResponse = zDeviceTokenResponse export const zGetPermittedExternalAppsQuery = z.object({ - limit: z.int().gte(1).lte(200).optional().default(20), + limit: z.int().gte(1).lte(100).optional().default(20), mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']).optional(), name: z.string().max(200).optional(), page: z.int().gte(1).optional().default(1), @@ -932,6 +1117,11 @@ export const zGetPermittedExternalAppsByAppIdQuery = z.object({ */ export const zGetPermittedExternalAppsByAppIdResponse = zAppDescribeResponse +export const zGetWorkspacesQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), +}) + /** * Workspace list */ @@ -955,7 +1145,7 @@ export const zPostWorkspacesByWorkspaceIdAppsImportsPath = z.object({ /** * Import completed */ -export const zPostWorkspacesByWorkspaceIdAppsImportsResponse = zImport +export const zPostWorkspacesByWorkspaceIdAppsImportsResponse = zAppDslImportResponse export const zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath = z.object({ import_id: z.string(), @@ -972,7 +1162,7 @@ export const zGetWorkspacesByWorkspaceIdMembersPath = z.object({ }) export const zGetWorkspacesByWorkspaceIdMembersQuery = z.object({ - limit: z.int().gte(1).lte(200).optional().default(20), + limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).optional().default(1), })