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>
This commit is contained in:
Xiyuan Chen
2026-09-22 10:44:50 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent dced031c95
commit dfc3bbe513
75 changed files with 3324 additions and 803 deletions
-16
View File
@@ -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(),
)
+1
View File
@@ -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"
+14
View File
@@ -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.<mode>.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`.
+33 -5
View File
@@ -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,
+173
View File
@@ -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
+75 -5
View File
@@ -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):
+14 -1
View File
@@ -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"
+89
View File
@@ -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[<name>]``
becomes ``inputs[<name>]``, 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
+92
View File
@@ -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()
+126 -24
View File
@@ -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 <op> --input <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[<name>]`. Send a list (part name `files[<name>][]`) 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/<id>/human-input-forms/<token>: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)
+68
View File
@@ -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<field>[^\[\]]+)(?:\[(?P<key>[^\[\]]+)\])?(?P<many>\[\])?$")
_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
+95
View File
@@ -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"]
-69
View File
@@ -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/<semver> (<os>; <arch>; <channel>)`.
_DIFYCTL_UA_RE = re.compile(r"^difyctl/(\d+\.\d+\.\d+(?:-[\w.]+)?)")
_PREFIX: Final = "/openapi/v1/"
# Paths a too-old client must still reach to discover that it is outdated.
_ALLOWLIST: Final = frozenset({"/openapi/v1/_version", "/openapi/v1/_health"})
def _upgrade_required_response(client_version: str, min_version: str) -> Response:
body = ErrorBody(
code=OpenApiErrorCode.UPGRADE_REQUIRED,
message=f"difyctl {client_version} is no longer supported; upgrade to >= {min_version}.",
status=426,
hint=_UPGRADE_HINT,
)
return Response(body.model_dump_json(exclude_none=True), status=426, mimetype="application/json")
def attach_version_gate(bp: Blueprint) -> None:
"""Reject difyctl clients older than ``[tool.dify] min_difyctl_version`` with 426.
Registered app-wide (``before_app_request``) rather than blueprint-scoped so it
also fires for requests to *removed* paths — those no longer match an openapi
route and would 404 before a blueprint-scoped ``before_request`` ever runs. The
prefix guard scopes it back to ``/openapi/v1``. Fails open for non-difyctl or
unparseable User-Agents (only a confidently-too-old difyctl is blocked).
"""
@bp.before_app_request
def _enforce_min_client_version() -> Response | None: # pyright: ignore[reportUnusedFunction]
if not request.path.startswith(_PREFIX):
return None
if request.path in _ALLOWLIST:
return None
match = _DIFYCTL_UA_RE.match(request.headers.get("User-Agent", ""))
if match is None:
return None
try:
client_version = Version(match.group(1))
except InvalidVersion:
return None
# Compare the numeric core (major.minor.patch) only — a pre-release build
# like 0.2.0-rc.1 must not sort below the 0.2.0 floor.
min_version = dify_config.tool.dify.min_difyctl_version
if client_version.release[:3] < Version(min_version).release[:3]:
return _upgrade_required_response(match.group(1), min_version)
return None
+17 -1
View File
@@ -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/<string:session_id>")
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": "<session_id>"}),),
requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.FULL)),
returns=(200, RevokeResponse, "Session revoked"),
)
+63 -8
View File
@@ -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/<string:workspace_id>/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": "<yaml text of the app DSL>"},
),
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": "<yaml text of the app DSL>", "app_id": "<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/<string:workspace_id>/apps/imports/<string:import_id>: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": "<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": "<app_id>"}),
Example(
title="Export a published version including secrets",
input={"app_id": "<app_id>", "workflow_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": "<app_id>"}),),
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckAppApiEnabled(),
+237 -74
View File
@@ -1,11 +1,12 @@
"""POST /openapi/v1/apps/<app_id>: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/<string:app_id>: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": "<app_id>", "inputs": {"topic": "quarterly report", "language": "en"}},
),
Example(
title="Run a workflow with a local file variable",
input={"app_id": "<app_id>", "inputs": {}, "files": {"document": "./report.pdf"}},
),
Example(
title="Run a pinned published workflow version",
input={"app_id": "<app_id>", "inputs": {}, "workflow_id": "<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": "<app_id>", "query": "Summarise the latest release", "inputs": {}},
),
Example(
title="Reply in an existing conversation",
input={
"app_id": "<app_id>",
"query": "Make it shorter",
"inputs": {},
"conversation_id": "<conversation_id>",
},
),
Example(
title="Ask about a local file attached to the message",
input={
"app_id": "<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": "<app_id>", "query": "Summarise the latest release", "inputs": {}},
),
Example(
title="Reply in an existing conversation",
input={
"app_id": "<app_id>",
"query": "Make it shorter",
"inputs": {},
"conversation_id": "<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": "<app_id>", "inputs": {"text": "The quick brown fox"}},
),
Example(
title="Run a completion app with prompt text",
input={"app_id": "<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/<string:app_id>/{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/<string:app_id>/tasks/<string:task_id>: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": "<app_id>", "task_id": "<task_id>"}),),
requirements=_RUN_GUARDS,
returns=(200, TaskStopResponse, "Task stopped"),
)
def post(self, ctx: Context, app_id: str, task_id: str):
+21 -1
View File
@@ -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/<string:app_id>")
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": "<app_id>"}),
Example(
title="Only the runtime input_schema of an app",
input={"app_id": "<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),
@@ -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/<string:app_id>")
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": "<app_id>"}),),
requirements=(
CheckSubject(allowed=(ExternalSsoSubject,)),
CheckAppApiEnabled(),
+22 -2
View File
@@ -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(),
)
+1 -1
View File
@@ -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()
+60 -1
View File
@@ -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
+14 -43
View File
@@ -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": "<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)
+83 -10
View File
@@ -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/<string:app_id>/human-input-forms/<string:form_token>")
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": "<app_id>", "form_token": "<form_token>"},
),
),
requirements=(
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
CheckAppApiEnabled(),
@@ -111,6 +126,30 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
@openapi_ns.route("/apps/<string:app_id>/human-input-forms/<string:form_token>: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": "<app_id>",
"form_token": "<form_token>",
"action": "approve",
"inputs": {"comment": "Looks good"},
},
),
Example(
title="Submit a form answer that includes a local file",
input={
"app_id": "<app_id>",
"form_token": "<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)
+8
View File
@@ -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()
+18 -8
View File
@@ -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/<string:app_id>/tasks/<string:task_id>/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": "<app_id>", "task_id": "<task_id>"}),
Example(
title="Keep the stream open across a human-input pause",
input={"app_id": "<app_id>", "task_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"},
)
+44 -13
View File
@@ -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/<string:workspace_id>")
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": "<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": "<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": "<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": "<member_id>", "role": "admin"}),
Example(title="Demote an admin to a normal member", input={"member_id": "<member_id>", "role": "normal"}),
),
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckScope(Scope.WORKSPACE_WRITE),
+4 -3
View File
@@ -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
+5
View File
@@ -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:
+16 -3
View File
@@ -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
+201 -23
View File
@@ -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)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [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)<br>**multipart/form-data**: [AdvancedChatRunPayload](#advancedchatrunpayload)<br> | **application/json**: [AdvancedChatRunPayload](#advancedchatrunpayload)<br>**multipart/form-data**: [AdvancedChatRunPayload](#advancedchatrunpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [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)<br>**multipart/form-data**: [ChatRunPayload](#chatrunpayload)<br> | **application/json**: [ChatRunPayload](#chatrunpayload)<br>**multipart/form-data**: [ChatRunPayload](#chatrunpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [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)<br>**multipart/form-data**: [CompletionRunPayload](#completionrunpayload)<br> | **application/json**: [CompletionRunPayload](#completionrunpayload)<br>**multipart/form-data**: [CompletionRunPayload](#completionrunpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [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)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | File uploaded successfully | **application/json**: [FileResponse](#fileresponse)<br> |
| 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)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [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)<br> |
| Yes | **application/json**: [OpenApiFormSubmitPayload](#openapiformsubmitpayload)<br>**multipart/form-data**: [OpenApiFormSubmitPayload](#openapiformsubmitpayload)<br> | **application/json**: [OpenApiFormSubmitPayload](#openapiformsubmitpayload)<br>**multipart/form-data**: [OpenApiFormSubmitPayload](#openapiformsubmitpayload)<br> |
#### 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)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [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)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [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)<br> |
| Yes | **application/json**: [WorkflowRunPayload](#workflowrunpayload)<br>**multipart/form-data**: [WorkflowRunPayload](#workflowrunpayload)<br> | **application/json**: [WorkflowRunPayload](#workflowrunpayload)<br>**multipart/form-data**: [WorkflowRunPayload](#workflowrunpayload)<br> |
#### Responses
@@ -350,11 +430,19 @@ Upload a file to use as an input variable when running the app
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /workspaces
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| limit | query | | No | integer, <br>**Default:** 20 |
| page | query | | No | integer, <br>**Default:** 1 |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace list | **application/json**: [WorkspaceListResponse](#workspacelistresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [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)<br> |
| 202 | Import pending confirmation | **application/json**: [Import](#import)<br> |
| 400 | Import failed | **application/json**: [Import](#import)<br> |
| 200 | Import completed | **application/json**: [AppDslImportResponse](#appdslimportresponse)<br> |
| 202 | Import pending confirmation | **application/json**: [AppDslImportResponse](#appdslimportresponse)<br> |
| 400 | Import failed | **application/json**: [AppDslImportResponse](#appdslimportresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
@@ -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) ], <br>**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, <br>**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[<name>]`. Send a list (part name `files[<name>][]`) 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/<workspace_id>/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, <br>**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, <br>**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, <br>**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[<name>]`. Send a list (part name `files[<name>][]`) 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[<name>]`. Send a list (part name `files[<name>][]`) 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/<id>/human-input-forms/<token>: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 <op> --input <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[<name>]`. Send a list (part name `files[<name>][]`) 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, <br>**Default:** 20 | | No |
| page | integer, <br>**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
+1 -5
View File
@@ -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",
]
############################################################
@@ -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():
@@ -1,4 +1,4 @@
"""Integration tests for POST /openapi/v1/apps/<id>:run."""
"""Integration tests for the per-mode run routes under POST /openapi/v1/apps/<id>."""
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
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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()
@@ -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]},
)
@@ -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)
@@ -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
@@ -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
@@ -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"
@@ -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]
@@ -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)
@@ -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
@@ -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):
@@ -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
@@ -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"),
]
@@ -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
@@ -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)
@@ -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
@@ -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"],
},
]
@@ -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)
@@ -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/<id>)."""
return f"/openapi/v1/apps/{uuid.uuid4()}"
class TestVersionGate:
def test_old_client_gets_426_with_upgrade_body(self, client):
res = client.get(_gated_path(), headers={"User-Agent": OLD_UA})
assert res.status_code == 426
body = res.get_json()
assert body["code"] == "upgrade_required"
assert body["status"] == 426
assert "0.1.0" in body["message"]
assert "0.2.0" in body["message"]
assert "docs.dify.ai" in body["hint"]
def test_removed_old_path_gets_426_not_404(self, client):
# /apps/<id>/run was renamed to /apps/<id>:run — the old path matches no
# route. The app-level gate must still turn it into 426, not a bare 404.
res = client.post(
f"/openapi/v1/apps/{uuid.uuid4()}/run",
headers={"User-Agent": OLD_UA},
json={"inputs": {}},
)
assert res.status_code == 426
assert res.get_json()["code"] == "upgrade_required"
def test_current_client_passes_gate(self, client):
# Gate passes → normal dispatch (auth rejects, never the gate's 426).
# 0.2.0-alpha == floor on the numeric core, so it passes despite the suffix.
res = client.get(_gated_path(), headers={"User-Agent": CURRENT_UA})
assert res.status_code != 426
def test_prerelease_at_floor_passes(self, client):
# Numeric-core comparison: a pre-release of the floor version (0.2.0-rc.1,
# core 0.2.0) passes, even though 0.2.0-rc.1 < 0.2.0 under naive ordering.
res = client.get(_gated_path(), headers={"User-Agent": "difyctl/0.2.0-rc.1 (darwin; arm64; rc)"})
assert res.status_code != 426
def test_prerelease_below_floor_gets_426(self, client):
# 0.1.9-rc.1 has core 0.1.9 < 0.2.0 floor → still blocked.
res = client.get(_gated_path(), headers={"User-Agent": "difyctl/0.1.9-rc.1 (darwin; arm64; rc)"})
assert res.status_code == 426
def test_non_difyctl_ua_passes(self, client):
res = client.get(_gated_path(), headers={"User-Agent": "curl/8.4.0"})
assert res.status_code != 426
def test_missing_ua_passes(self, client):
res = client.get(_gated_path())
assert res.status_code != 426
def test_unparseable_version_passes(self, client):
res = client.get(_gated_path(), headers={"User-Agent": "difyctl/notaversion (x; y; z)"})
assert res.status_code != 426
def test_version_probe_allowlisted(self, client):
res = client.get("/openapi/v1/_version", headers={"User-Agent": OLD_UA})
assert res.status_code == 200
def test_health_allowlisted(self, client):
res = client.get("/openapi/v1/_health", headers={"User-Agent": OLD_UA})
assert res.status_code == 200
@@ -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)
@@ -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()
-1
View File
@@ -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",
Generated
+14
View File
@@ -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"
+7 -5
View File
@@ -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 () => {
+6 -1
View File
@@ -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<WorkspaceListResponse> {
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 }
}
/**
@@ -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<readonly SessionRow[]> {
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 = {
+3 -3
View File
@@ -149,10 +149,10 @@ async function runAllWorkspaces(
limit: number,
): Promise<AppListResponse> {
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<void>[] = []
const fetchOne = async (wsId: string): Promise<void> => {
@@ -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)
@@ -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)
})
})
+7 -7
View File
@@ -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')
})
+2 -2
View File
@@ -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,
+5 -1
View File
@@ -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',
+1 -1
View File
@@ -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<Workspace>((w) => ({ id: w.id, name: w.name, role: w.role }))
const items = list.data.map<Workspace>((w) => ({ id: w.id, name: w.name, role: w.role }))
if (items.length === 0) {
throw new BaseError({
code: ErrorCode.AccessDenied,
+8 -8
View File
@@ -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()
}
+17
View File
@@ -20,3 +20,20 @@ export function parseLimit(raw: string, source: string): number {
}
return n
}
const MAX_PAGES = 100
export type Page<T> = { 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<T>(
fetchPage: (page: number) => Promise<Page<T>>,
): Promise<T[]> {
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
}
+8 -8
View File
@@ -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 () => {
+18 -8
View File
@@ -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),
})
})
@@ -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,
@@ -19,6 +19,21 @@ export type AccountResponse = {
workspaces?: Array<WorkspacePayload>
}
export type AdvancedChatRunPayload = {
attachments?: Array<Blob | File> | null
auto_generate_name?: boolean
conversation_id?: string | null
files?: {
[key: string]: Blob | File | Array<Blob | File>
} | 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<Hint>
id: string
imported_dsl_version?: string
permission_keys?: Array<string>
status: ImportStatus
warnings?: Array<DslImportWarning>
}
export type AppInfo = {
description?: string | null
id: string
@@ -82,6 +110,7 @@ export type AppListQuery = {
export type AppListResponse = {
data: Array<AppListRow>
has_more: boolean
hints?: Array<Hint>
limit: number
page: number
total: number
@@ -107,17 +136,17 @@ export type AppMode =
| 'rag-pipeline'
| 'workflow'
export type AppRunRequest = {
export type ChatRunPayload = {
attachments?: Array<Blob | File> | null
auto_generate_name?: boolean
conversation_id?: string | null
files?: Array<{
[key: string]: unknown
}> | null
files?: {
[key: string]: Blob | File | Array<Blob | File>
} | 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<PluginDependency>
}
export type CompletionRunPayload = {
attachments?: Array<Blob | File> | null
files?: {
[key: string]: Blob | File | Array<Blob | File>
} | 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<MemberResponse>
has_more: boolean
hints?: Array<Hint>
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<Blob | File>
} | null
inputs: {
[key: string]: JsonValue
}
}
export type Package = {
plugin_unique_identifier: string
@@ -377,6 +437,7 @@ export type PermittedExternalAppsListQuery = {
export type PermittedExternalAppsListResponse = {
data: Array<AppListRow>
has_more: boolean
hints?: Array<Hint>
limit: number
page: number
total: number
@@ -407,6 +468,7 @@ export type SessionListQuery = {
export type SessionListResponse = {
data: Array<SessionRow>
has_more: boolean
hints?: Array<Hint>
limit: number
page: number
total: number
@@ -455,6 +517,18 @@ export type WorkflowRunData = {
workflow_id: string
}
export type WorkflowRunPayload = {
attachments?: Array<Blob | File> | null
files?: {
[key: string]: Blob | File | Array<Blob | File>
} | 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<WorkspaceSummaryResponse>
data: Array<WorkspaceSummaryResponse>
has_more: boolean
hints?: Array<Hint>
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 =
@@ -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<Blob | File>((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<Blob | File>((value) => value instanceof Blob || value instanceof File),
z.array(z.custom<Blob | File>((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<Blob | File>((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<Blob | File>((value) => value instanceof Blob || value instanceof File),
z.array(z.custom<Blob | File>((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<Blob | File>((value) => value instanceof Blob || value instanceof File))
.nullish(),
files: z
.record(
z.string(),
z.union([
z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
z.array(z.custom<Blob | File>((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<Blob | File>((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 <op> --input <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<Blob | File>((value) => value instanceof Blob || value instanceof File),
z.array(z.custom<Blob | File>((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<Blob | File>((value) => value instanceof Blob || value instanceof File))
.nullish(),
files: z
.record(
z.string(),
z.union([
z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
z.array(z.custom<Blob | File>((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),
})