mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
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:
parent
d1c1c04615
commit
35d9b6a0f8
@ -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",
|
||||
|
||||
143
api/controllers/openapi/_input_schema.py
Normal file
143
api/controllers/openapi/_input_schema.py
Normal 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,
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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"):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"})
|
||||
@ -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"}],
|
||||
}
|
||||
@ -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))
|
||||
|
||||
182
api/tests/unit_tests/controllers/openapi/test_input_schema.py
Normal file
182
api/tests/unit_tests/controllers/openapi/test_input_schema.py
Normal 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"] == []
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user