mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
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:
co-authored by
autofix-ci[bot]
parent
dced031c95
commit
dfc3bbe513
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user