feat(openapi): merge /apps/<id>/{info,parameters} into /describe + ?fields

Collapse the openapi-namespace per-app reads into one canonical endpoint
GET /openapi/v1/apps/<id>/describe[?fields=info,parameters,input_schema]
returning a single AppDescribeResponse with all blocks Optional and a new
JSON-Schema input_schema block derived server-side from user_input_form +
app mode.

- AppDescribeQuery (Pydantic, extra=forbid) parses the ?fields allow-list;
  unknown member -> 422.
- _input_schema.build_input_schema(app) derives Draft 2020-12 JSON Schema:
  chat-family modes carry top-level query (string, minLength=1, required);
  workflow / completion only carry inputs. AppUnavailableError -> empty
  sentinel (EMPTY_INPUT_SCHEMA).
- Drop AppByIdApi (/apps/<id>) and AppParametersApi (/apps/<id>/parameters)
  route classes; delete app_info.py module + app_info_payload helper.
- AppDescribeResponse.{info,parameters,input_schema} now Optional[None].

Lock-step deploy with difyctl Phase B (/describe consumer migration).
This commit is contained in:
GareArc 2026-05-05 23:50:50 -07:00
parent d1c1c04615
commit 35d9b6a0f8
No known key found for this signature in database
12 changed files with 625 additions and 230 deletions

View File

@ -18,7 +18,6 @@ openapi_ns = Namespace("openapi", description="User-scoped operations", path="/"
from . import (
account,
app_info,
apps,
apps_permitted,
chat_messages,
@ -32,7 +31,6 @@ from . import (
__all__ = [
"account",
"app_info",
"apps",
"apps_permitted",
"chat_messages",

View File

@ -0,0 +1,143 @@
"""Server-side JSON Schema derivation from Dify `user_input_form`."""
from __future__ import annotations
from typing import Any, cast
from controllers.service_api.app.error import AppUnavailableError
from models import App
from models.model import AppMode
JSON_SCHEMA_DRAFT = "https://json-schema.org/draft/2020-12/schema"
EMPTY_INPUT_SCHEMA: dict[str, Any] = {
"$schema": JSON_SCHEMA_DRAFT,
"type": "object",
"properties": {},
"required": [],
}
_CHAT_FAMILY = frozenset({AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT})
def _file_object_shape() -> dict[str, Any]:
"""Single-file value shape. Forward-compat placeholder; refine when file-API contract pins."""
return {
"type": "object",
"properties": {
"type": {"type": "string"},
"transfer_method": {"type": "string"},
"url": {"type": "string"},
"upload_file_id": {"type": "string"},
},
"additionalProperties": True,
}
def _row_to_schema(row_type: str, row: dict[str, Any]) -> dict[str, Any] | None:
label = row.get("label") or row.get("variable", "")
base: dict[str, Any] = {"title": label} if label else {}
if row_type in ("text-input", "paragraph"):
out = {"type": "string"} | base
max_length = row.get("max_length")
if isinstance(max_length, int) and max_length > 0:
out["maxLength"] = max_length
return out
if row_type == "select":
return {"type": "string"} | base | {"enum": list(row.get("options") or [])}
if row_type == "number":
return {"type": "number"} | base
if row_type == "file":
return _file_object_shape() | base
if row_type == "file-list":
return {
"type": "array",
"items": _file_object_shape(),
} | base
return None
def _form_to_jsonschema(form: list[dict[str, Any]]) -> tuple[dict[str, Any], list[str]]:
"""Translate a user_input_form row list into (properties, required-list).
Each row is a single-key dict: `{"text-input": {variable, label, required, ...}}`.
Unknown variable types are skipped (forward-compat).
"""
properties: dict[str, Any] = {}
required: list[str] = []
for row in form:
if not isinstance(row, dict) or len(row) != 1:
continue
((row_type, row_body),) = row.items()
if not isinstance(row_body, dict):
continue
variable = row_body.get("variable")
if not variable:
continue
schema = _row_to_schema(row_type, row_body)
if schema is None:
continue
properties[variable] = schema
if row_body.get("required"):
required.append(variable)
return properties, required
def resolve_app_config(app: App) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Resolve `(features_dict, user_input_form)` for parameters / schema derivation.
Raises `AppUnavailableError` on misconfigured apps.
"""
if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app.workflow
if workflow is None:
raise AppUnavailableError()
return (
workflow.features_dict,
cast(list[dict[str, Any]], workflow.user_input_form(to_old_structure=True)),
)
app_model_config = app.app_model_config
if app_model_config is None:
raise AppUnavailableError()
features_dict = cast(dict[str, Any], app_model_config.to_dict())
return features_dict, cast(list[dict[str, Any]], features_dict.get("user_input_form", []))
def build_input_schema(app: App) -> dict[str, Any]:
"""Derive Draft 2020-12 JSON Schema from `user_input_form` + app mode.
chat / agent-chat / advanced-chat: top-level `query` (required, minLength=1) + `inputs` object.
completion / workflow: `inputs` object only.
Raises `AppUnavailableError` on misconfigured apps.
"""
_, user_input_form = resolve_app_config(app)
inputs_props, inputs_required = _form_to_jsonschema(user_input_form)
properties: dict[str, Any] = {}
required: list[str] = []
if app.mode in _CHAT_FAMILY:
properties["query"] = {"type": "string", "minLength": 1}
required.append("query")
properties["inputs"] = {
"type": "object",
"properties": inputs_props,
"required": inputs_required,
"additionalProperties": False,
}
required.append("inputs")
return {
"$schema": JSON_SCHEMA_DRAFT,
"type": "object",
"properties": properties,
"required": required,
}

View File

@ -64,5 +64,6 @@ class AppDescribeInfo(AppInfoResponse):
class AppDescribeResponse(BaseModel):
info: AppDescribeInfo
parameters: dict[str, Any]
info: AppDescribeInfo | None = None
parameters: dict[str, Any] | None = None
input_schema: dict[str, Any] | None = None

View File

@ -1,13 +0,0 @@
"""GET /openapi/v1/apps/<app_id>/info — port of service_api/app/app.py:AppInfoApi."""
from __future__ import annotations
from controllers.openapi import openapi_ns
from controllers.openapi.apps import AppReadResource, app_info_payload
@openapi_ns.route("/apps/<string:app_id>/info")
class AppInfoApi(AppReadResource):
def get(self, app_id: str):
app, _ = self._load(app_id)
return app_info_payload(app), 200

View File

@ -6,28 +6,27 @@ is last → outermost → sets `g.auth_ctx` before `require_scope` reads it.
from __future__ import annotations
from typing import Any, cast
from typing import Any
import sqlalchemy as sa
from flask import g, request
from flask_restx import Resource
from pydantic import BaseModel, Field, ValidationError
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
from werkzeug.exceptions import NotFound, UnprocessableEntity
from controllers.common.fields import Parameters
from controllers.openapi import openapi_ns
from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA, build_input_schema, resolve_app_config
from controllers.openapi._models import (
MAX_PAGE_LIMIT,
AppDescribeInfo,
AppDescribeResponse,
AppInfoResponse,
AppListRow,
PaginationEnvelope,
)
from controllers.service_api.app.error import AppUnavailableError
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from extensions.ext_database import db
from libs.helper import escape_like_pattern
from libs.oauth_bearer import (
ACCEPT_USER_ANY,
AuthContext,
@ -38,13 +37,42 @@ from libs.oauth_bearer import (
validate_bearer,
)
from models import App, Tenant
from models.model import AppMode, Tag, TagBinding
from models.model import AppMode
from services.app_service import AppService
from services.tag_service import TagService
_APPS_READ_DECORATORS = [
require_scope(Scope.APPS_READ),
validate_bearer(accept=ACCEPT_USER_ANY),
]
_ALLOWED_DESCRIBE_FIELDS: frozenset[str] = frozenset({"info", "parameters", "input_schema"})
class AppDescribeQuery(BaseModel):
"""`?fields=` allow-list for GET /apps/<id>/describe.
Empty / omitted all blocks. Unknown member ValidationError 422.
"""
model_config = ConfigDict(extra="forbid")
fields: set[str] | None = None
@field_validator("fields", mode="before")
@classmethod
def _parse_fields(cls, v: object) -> set[str] | None:
if v is None or v == "":
return None
if not isinstance(v, str):
raise ValueError("fields must be a comma-separated string")
members = {m.strip() for m in v.split(",") if m.strip()}
unknown = members - _ALLOWED_DESCRIBE_FIELDS
if unknown:
raise ValueError(f"unknown field(s): {sorted(unknown)}")
return members
_EMPTY_PARAMETERS: dict[str, Any] = {
"opening_statement": None,
"suggested_questions": [],
@ -73,70 +101,64 @@ class AppReadResource(Resource):
return app, ctx
def app_info_payload(app: App) -> dict:
return AppInfoResponse(
id=str(app.id),
name=app.name,
description=app.description,
mode=app.mode,
author=app.author_name,
tags=[{"name": t.name} for t in app.tags],
).model_dump(mode="json")
def parameters_payload(app: App) -> dict:
"""Mirrors service_api/app/app.py::AppParameterApi response body."""
if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app.workflow
if workflow is None:
raise AppUnavailableError()
features_dict: dict[str, Any] = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app.app_model_config
if app_model_config is None:
raise AppUnavailableError()
features_dict = cast(dict[str, Any], app_model_config.to_dict())
user_input_form = features_dict.get("user_input_form", [])
features_dict, user_input_form = resolve_app_config(app)
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
return Parameters.model_validate(parameters).model_dump(mode="json")
@openapi_ns.route("/apps/<string:app_id>")
class AppByIdApi(AppReadResource):
def get(self, app_id: str):
app, _ = self._load(app_id)
return app_info_payload(app), 200
@openapi_ns.route("/apps/<string:app_id>/parameters")
class AppParametersApi(AppReadResource):
def get(self, app_id: str):
app, _ = self._load(app_id)
return parameters_payload(app), 200
@openapi_ns.route("/apps/<string:app_id>/describe")
class AppDescribeApi(AppReadResource):
def get(self, app_id: str):
app, _ = self._load(app_id)
try:
parameters = parameters_payload(app)
except AppUnavailableError:
parameters = dict(_EMPTY_PARAMETERS)
info = AppDescribeInfo(
id=str(app.id),
name=app.name,
mode=app.mode,
description=app.description,
tags=[{"name": t.name} for t in app.tags],
author=app.author_name,
updated_at=app.updated_at.isoformat() if app.updated_at else None,
service_api_enabled=bool(app.enable_api),
try:
query = AppDescribeQuery.model_validate(request.args.to_dict(flat=True))
except ValidationError as exc:
raise UnprocessableEntity(exc.json())
requested = query.fields
want_info = requested is None or "info" in requested
want_params = requested is None or "parameters" in requested
want_schema = requested is None or "input_schema" in requested
info = (
AppDescribeInfo(
id=str(app.id),
name=app.name,
mode=app.mode,
description=app.description,
tags=[{"name": t.name} for t in app.tags],
author=app.author_name,
updated_at=app.updated_at.isoformat() if app.updated_at else None,
service_api_enabled=bool(app.enable_api),
)
if want_info
else None
)
parameters: dict[str, Any] | None = None
input_schema: dict[str, Any] | None = None
if want_params:
try:
parameters = parameters_payload(app)
except AppUnavailableError:
parameters = dict(_EMPTY_PARAMETERS)
if want_schema:
try:
input_schema = build_input_schema(app)
except AppUnavailableError:
input_schema = dict(EMPTY_INPUT_SCHEMA)
return (
AppDescribeResponse(
info=info,
parameters=parameters,
input_schema=input_schema,
).model_dump(mode="json", exclude_none=False),
200,
)
return AppDescribeResponse(info=info, parameters=parameters).model_dump(mode="json"), 200
class AppListQuery(BaseModel):
@ -157,7 +179,6 @@ class AppListApi(Resource):
def get(self):
ctx = g.auth_ctx
if ctx.subject_type != SubjectType.ACCOUNT or ctx.account_id is None:
# Defensive: dfoe_ lacks apps:read today, but guard against future scope shifts.
return PaginationEnvelope[AppListRow].build(page=1, limit=0, total=0, items=[]).model_dump(mode="json"), 200
try:
@ -168,42 +189,36 @@ class AppListApi(Resource):
workspace_id = query.workspace_id
require_workspace_member(ctx, workspace_id)
page = query.page
limit = query.limit
mode = query.mode.value if query.mode else None
name_filter = query.name
tag_name = query.tag
filters = [
App.tenant_id == workspace_id,
App.is_universal.is_(False),
App.status == "normal",
]
if mode:
filters.append(App.mode == mode)
if name_filter:
escaped = escape_like_pattern(name_filter[:30])
filters.append(App.name.ilike(f"%{escaped}%", escape="\\"))
if tag_name:
tag_app_ids = (
db.session.query(TagBinding.target_id)
.join(Tag, Tag.id == TagBinding.tag_id)
.filter(Tag.tenant_id == workspace_id, Tag.type == "app", Tag.name == tag_name)
.subquery()
)
filters.append(App.id.in_(sa.select(tag_app_ids.c.target_id)))
total = db.session.execute(sa.select(sa.func.count()).select_from(App).where(*filters)).scalar() or 0
rows = (
db.session.execute(
sa.select(App).where(*filters).order_by(App.created_at.desc()).limit(limit).offset((page - 1) * limit)
)
.scalars()
.all()
empty = (
PaginationEnvelope[AppListRow]
.build(page=query.page, limit=query.limit, total=0, items=[])
.model_dump(mode="json"),
200,
)
tag_ids: list[str] | None = None
if query.tag:
tags = TagService.get_tag_by_tag_name("app", workspace_id, query.tag)
if not tags:
return empty
tag_ids = [tag.id for tag in tags]
args: dict[str, Any] = {
"page": query.page,
"limit": query.limit,
"mode": query.mode.value if query.mode else "",
"name": query.name,
"status": "normal",
}
if tag_ids:
args["tag_ids"] = tag_ids
pagination = AppService().get_paginate_apps(ctx.account_id, workspace_id, args)
if pagination is None:
return empty
tenant_name: str | None = None
if rows:
if pagination.items:
tenant_name = db.session.execute(
sa.select(Tenant.name).where(Tenant.id == workspace_id)
).scalar_one_or_none()
@ -220,7 +235,9 @@ class AppListApi(Resource):
workspace_id=str(workspace_id),
workspace_name=tenant_name,
)
for r in rows
for r in pagination.items
]
env = PaginationEnvelope[AppListRow].build(page=page, limit=limit, total=int(total), items=items)
env = PaginationEnvelope[AppListRow].build(
page=query.page, limit=query.limit, total=int(pagination.total), items=items
)
return env.model_dump(mode="json"), 200

View File

@ -37,7 +37,7 @@ class AppService:
Get app list with pagination
:param user_id: user id
:param tenant_id: tenant id
:param args: request args
:param args: request args. Optional keys: status (e.g. "normal") restricts App.status.
:return:
"""
filters = [App.tenant_id == tenant_id, App.is_universal == False]
@ -53,6 +53,8 @@ class AppService:
elif args["mode"] == "agent-chat":
filters.append(App.mode == AppMode.AGENT_CHAT)
if args.get("status"):
filters.append(App.status == args["status"])
if args.get("is_created_by_me", False):
filters.append(App.created_by == user_id)
if args.get("name"):

View File

@ -2,62 +2,33 @@
from __future__ import annotations
import uuid
from flask.testing import FlaskClient
from models import App
def test_apps_get_single_returns_app_info(
test_client: FlaskClient,
app_in_workspace: App,
account_token: str,
):
res = test_client.get(
def test_apps_bare_id_route_404(test_client, app_in_workspace, account_token):
resp = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 200
body = res.json
assert body["id"] == app_in_workspace.id
assert body["mode"] == "chat"
assert resp.status_code == 404
def test_apps_get_single_rejects_external_sso(
test_client: FlaskClient,
app_in_workspace: App,
mint_token,
):
"""dfoe_ tokens hold only `apps:run` — `apps:read` routes 403."""
token = "dfoe_" + uuid.uuid4().hex
mint_token(
token,
account_id=None,
prefix="dfoe_",
subject_email="ext@example.com",
subject_issuer="https://idp.example.com",
)
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}",
headers={"Authorization": f"Bearer {token}"},
)
assert res.status_code == 403
assert "insufficient_scope" in res.json.get("message", "")
def test_apps_parameters_returns_form_schema(
test_client: FlaskClient,
app_in_workspace: App,
account_token: str,
):
res = test_client.get(
def test_apps_parameters_route_404(test_client, app_in_workspace, account_token):
resp = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/parameters",
headers={"Authorization": f"Bearer {account_token}"},
)
# Without an app_model_config, the chat app's parameters endpoint raises
# AppUnavailableError (503). Auth succeeded if status is NOT 401/403.
assert res.status_code in (200, 503)
assert resp.status_code == 404
def test_apps_info_route_404(test_client, app_in_workspace, account_token):
resp = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/info",
headers={"Authorization": f"Bearer {account_token}"},
)
assert resp.status_code == 404
def test_apps_describe_returns_merged_shape(
@ -76,6 +47,111 @@ def test_apps_describe_returns_merged_shape(
assert isinstance(body["parameters"], dict)
def test_apps_describe_full_includes_input_schema(
test_client: FlaskClient,
app_in_workspace: App,
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 200
body = res.json
assert body["info"] is not None
assert body["parameters"] is not None
assert body["input_schema"] is not None
assert body["input_schema"]["$schema"] == "https://json-schema.org/draft/2020-12/schema"
def test_apps_describe_fields_info_only(
test_client: FlaskClient,
app_in_workspace: App,
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 200
body = res.json
assert body["info"] is not None
assert body["parameters"] is None
assert body["input_schema"] is None
def test_apps_describe_fields_parameters_only(
test_client: FlaskClient,
app_in_workspace: App,
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=parameters",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 200
body = res.json
assert body["info"] is None
assert body["parameters"] is not None
assert body["input_schema"] is None
def test_apps_describe_fields_input_schema_only(
test_client: FlaskClient,
app_in_workspace: App,
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=input_schema",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 200
body = res.json
assert body["info"] is None
assert body["parameters"] is None
assert body["input_schema"] is not None
def test_apps_describe_fields_combined(
test_client: FlaskClient,
app_in_workspace: App,
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info,input_schema",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 200
body = res.json
assert body["info"] is not None
assert body["parameters"] is None
assert body["input_schema"] is not None
def test_apps_describe_fields_unknown_returns_422(
test_client: FlaskClient,
app_in_workspace: App,
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=garbage",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 422
def test_apps_describe_fields_extra_param_returns_422(
test_client: FlaskClient,
app_in_workspace: App,
account_token: str,
):
res = test_client.get(
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info&page=1",
headers={"Authorization": f"Bearer {account_token}"},
)
assert res.status_code == 422
def test_apps_list_returns_pagination_envelope(
test_client: FlaskClient,
workspace_account,

View File

@ -0,0 +1,48 @@
"""Unit tests for AppDescribeQuery (`?fields=` allow-list)."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from controllers.openapi.apps import AppDescribeQuery
def test_no_fields_returns_none() -> None:
q = AppDescribeQuery.model_validate({})
assert q.fields is None
def test_empty_string_returns_none() -> None:
q = AppDescribeQuery.model_validate({"fields": ""})
assert q.fields is None
def test_single_field() -> None:
q = AppDescribeQuery.model_validate({"fields": "info"})
assert q.fields == {"info"}
def test_comma_list() -> None:
q = AppDescribeQuery.model_validate({"fields": "info,parameters"})
assert q.fields == {"info", "parameters"}
def test_whitespace_tolerant() -> None:
q = AppDescribeQuery.model_validate({"fields": " info , input_schema "})
assert q.fields == {"info", "input_schema"}
def test_unknown_member_rejected() -> None:
with pytest.raises(ValidationError):
AppDescribeQuery.model_validate({"fields": "garbage"})
def test_unknown_among_known_rejected() -> None:
with pytest.raises(ValidationError):
AppDescribeQuery.model_validate({"fields": "info,garbage"})
def test_extra_param_forbidden() -> None:
with pytest.raises(ValidationError):
AppDescribeQuery.model_validate({"fields": "info", "page": "1"})

View File

@ -1,57 +0,0 @@
import builtins
from flask import Flask
from flask.views import MethodView
from controllers.openapi import bp as openapi_bp
from controllers.openapi.app_info import AppInfoApi
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
def _openapi_app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(openapi_bp)
return app
def _rule(app: Flask, path: str):
return next(r for r in app.url_map.iter_rules() if r.rule == path)
def test_app_info_route_registered():
rules = {r.rule for r in _openapi_app().url_map.iter_rules()}
assert "/openapi/v1/apps/<string:app_id>/info" in rules
def test_app_info_dispatches_to_class():
app = _openapi_app()
rule = _rule(app, "/openapi/v1/apps/<string:app_id>/info")
assert app.view_functions[rule.endpoint].view_class is AppInfoApi
assert "GET" in rule.methods
def test_app_info_payload_shape():
from types import SimpleNamespace
from controllers.openapi.apps import app_info_payload
app_obj = SimpleNamespace(
id="app1",
name="X",
description="d",
mode="chat",
author_name="alice",
tags=[SimpleNamespace(name="prod")],
)
payload = app_info_payload(app_obj)
assert payload == {
"id": "app1",
"name": "X",
"description": "d",
"mode": "chat",
"author": "alice",
"tags": [{"name": "prod"}],
}

View File

@ -10,7 +10,6 @@ import pytest
from controllers.openapi.apps import ( # pyright: ignore[reportPrivateUsage]
_EMPTY_PARAMETERS,
app_info_payload,
parameters_payload,
)
from controllers.service_api.app.error import AppUnavailableError
@ -33,24 +32,6 @@ def _fake_app(**overrides):
return SimpleNamespace(**base)
def test_app_info_payload_shape():
payload = app_info_payload(_fake_app())
assert payload == {
"id": "app1",
"name": "X",
"description": "d",
"mode": "chat",
"author": "alice",
"tags": [{"name": "prod"}],
}
def test_app_info_payload_handles_missing_description_and_author():
payload = app_info_payload(_fake_app(description=None, author_name=None))
assert payload["description"] is None
assert payload["author"] is None
def test_parameters_payload_raises_app_unavailable_when_no_config():
with pytest.raises(AppUnavailableError):
parameters_payload(_fake_app(mode="chat", app_model_config=None))

View File

@ -0,0 +1,182 @@
"""Unit tests for input_schema derivation."""
from __future__ import annotations
import pytest
from controllers.openapi._input_schema import _form_to_jsonschema
def _wrap(component: dict) -> list[dict]:
"""user_input_form rows are single-key dicts: {"text-input": {...}}."""
return [component]
def test_text_input_required() -> None:
form = _wrap({"text-input": {"variable": "industry", "label": "Industry", "required": True, "max_length": 200}})
props, required = _form_to_jsonschema(form)
assert props == {"industry": {"type": "string", "title": "Industry", "maxLength": 200}}
assert required == ["industry"]
def test_paragraph_optional() -> None:
form = _wrap({"paragraph": {"variable": "context", "label": "Context", "required": False, "max_length": 4000}})
props, required = _form_to_jsonschema(form)
assert props["context"] == {"type": "string", "title": "Context", "maxLength": 4000}
assert required == []
def test_select_enum() -> None:
form = _wrap(
{
"select": {
"variable": "tier",
"label": "Tier",
"required": True,
"options": ["free", "pro", "enterprise"],
}
}
)
props, required = _form_to_jsonschema(form)
assert props == {"tier": {"type": "string", "title": "Tier", "enum": ["free", "pro", "enterprise"]}}
assert required == ["tier"]
def test_number() -> None:
form = _wrap({"number": {"variable": "count", "label": "Count", "required": False}})
props, _required = _form_to_jsonschema(form)
assert props["count"] == {"type": "number", "title": "Count"}
def test_file() -> None:
form = _wrap({"file": {"variable": "doc", "label": "Doc", "required": True}})
props, required = _form_to_jsonschema(form)
assert props["doc"]["type"] == "object"
assert "title" in props["doc"]
assert required == ["doc"]
def test_file_list() -> None:
form = _wrap({"file-list": {"variable": "attachments", "label": "Attachments", "required": False}})
props, _required = _form_to_jsonschema(form)
assert props["attachments"]["type"] == "array"
assert props["attachments"]["items"]["type"] == "object"
def test_unknown_type_skipped() -> None:
"""Forward-compat: unknown variable types are skipped, not 500'd."""
form = _wrap({"future-type": {"variable": "x", "label": "X", "required": False}})
props, required = _form_to_jsonschema(form)
assert props == {}
assert required == []
def test_required_order_preserved() -> None:
form = [
{"text-input": {"variable": "a", "label": "A", "required": True}},
{"text-input": {"variable": "b", "label": "B", "required": False}},
{"text-input": {"variable": "c", "label": "C", "required": True}},
]
_props, required = _form_to_jsonschema(form)
assert required == ["a", "c"]
def test_max_length_omitted_when_zero() -> None:
form = _wrap({"text-input": {"variable": "x", "label": "X", "required": False, "max_length": 0}})
props, _ = _form_to_jsonschema(form)
assert "maxLength" not in props["x"]
from unittest.mock import MagicMock
from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA, build_input_schema
from controllers.service_api.app.error import AppUnavailableError
from models.model import AppMode
def _stub_app(mode: AppMode, *, form: list[dict] | None = None, has_workflow: bool | None = None):
"""Returns a MagicMock whose .mode + workflow / app_model_config branch is wired up."""
app = MagicMock()
app.mode = mode
if mode in (AppMode.WORKFLOW, AppMode.ADVANCED_CHAT):
if has_workflow is False:
app.workflow = None
else:
app.workflow = MagicMock()
app.workflow.user_input_form.return_value = form or []
app.workflow.features_dict = {}
else:
if has_workflow is False:
app.app_model_config = None
else:
app.app_model_config = MagicMock()
app.app_model_config.to_dict.return_value = {"user_input_form": form or []}
return app
def test_chat_mode_includes_query() -> None:
app = _stub_app(AppMode.CHAT, form=[{"text-input": {"variable": "x", "label": "X", "required": True}}])
schema = build_input_schema(app)
assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema"
assert "query" in schema["properties"]
assert schema["properties"]["query"]["type"] == "string"
assert schema["properties"]["query"]["minLength"] == 1
assert "query" in schema["required"]
assert "inputs" in schema["required"]
assert schema["properties"]["inputs"]["additionalProperties"] is False
def test_agent_chat_mode_includes_query() -> None:
app = _stub_app(AppMode.AGENT_CHAT, form=[])
schema = build_input_schema(app)
assert "query" in schema["properties"]
def test_advanced_chat_mode_includes_query() -> None:
app = _stub_app(AppMode.ADVANCED_CHAT, form=[])
schema = build_input_schema(app)
assert "query" in schema["properties"]
def test_workflow_mode_omits_query() -> None:
app = _stub_app(AppMode.WORKFLOW, form=[])
schema = build_input_schema(app)
assert "query" not in schema["properties"]
assert schema["required"] == ["inputs"]
def test_completion_mode_omits_query() -> None:
app = _stub_app(AppMode.COMPLETION, form=[])
schema = build_input_schema(app)
assert "query" not in schema["properties"]
assert schema["required"] == ["inputs"]
def test_inputs_required_driven_by_form() -> None:
app = _stub_app(
AppMode.CHAT,
form=[
{"text-input": {"variable": "industry", "label": "Industry", "required": True}},
{"text-input": {"variable": "context", "label": "Context", "required": False}},
],
)
schema = build_input_schema(app)
assert schema["properties"]["inputs"]["required"] == ["industry"]
def test_misconfigured_chat_raises_app_unavailable() -> None:
app = _stub_app(AppMode.CHAT, has_workflow=False)
with pytest.raises(AppUnavailableError):
build_input_schema(app)
def test_misconfigured_workflow_raises_app_unavailable() -> None:
app = _stub_app(AppMode.WORKFLOW, has_workflow=False)
with pytest.raises(AppUnavailableError):
build_input_schema(app)
def test_empty_input_schema_sentinel_shape() -> None:
assert EMPTY_INPUT_SCHEMA["type"] == "object"
assert EMPTY_INPUT_SCHEMA["properties"] == {}
assert EMPTY_INPUT_SCHEMA["required"] == []

View File

@ -12,3 +12,20 @@ def test_message_metadata_accepts_partial():
m = MessageMetadata(usage=UsageInfo(total_tokens=10))
assert m.usage.total_tokens == 10
assert m.retriever_resources == []
def test_describe_response_all_blocks_optional() -> None:
from controllers.openapi._models import AppDescribeResponse
payload = AppDescribeResponse().model_dump(mode="json", exclude_none=False)
assert payload == {"info": None, "parameters": None, "input_schema": None}
def test_describe_response_input_schema_field() -> None:
from controllers.openapi._models import AppDescribeResponse
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object"}
payload = AppDescribeResponse(input_schema=schema).model_dump(mode="json", exclude_none=False)
assert payload["input_schema"] == schema
assert payload["info"] is None
assert payload["parameters"] is None