From 35d9b6a0f8100483011987b8b32aab1a183d46d5 Mon Sep 17 00:00:00 2001 From: GareArc Date: Tue, 5 May 2026 23:50:50 -0700 Subject: [PATCH] feat(openapi): merge /apps//{info,parameters} into /describe + ?fields Collapse the openapi-namespace per-app reads into one canonical endpoint GET /openapi/v1/apps//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/) and AppParametersApi (/apps//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). --- api/controllers/openapi/__init__.py | 2 - api/controllers/openapi/_input_schema.py | 143 ++++++++++++ api/controllers/openapi/_models.py | 5 +- api/controllers/openapi/app_info.py | 13 -- api/controllers/openapi/apps.py | 203 ++++++++++-------- api/services/app_service.py | 4 +- .../controllers/openapi/test_apps.py | 162 ++++++++++---- .../openapi/test_app_describe_query.py | 48 +++++ .../controllers/openapi/test_app_info.py | 57 ----- .../controllers/openapi/test_app_payloads.py | 19 -- .../controllers/openapi/test_input_schema.py | 182 ++++++++++++++++ .../controllers/openapi/test_models.py | 17 ++ 12 files changed, 625 insertions(+), 230 deletions(-) create mode 100644 api/controllers/openapi/_input_schema.py delete mode 100644 api/controllers/openapi/app_info.py create mode 100644 api/tests/unit_tests/controllers/openapi/test_app_describe_query.py delete mode 100644 api/tests/unit_tests/controllers/openapi/test_app_info.py create mode 100644 api/tests/unit_tests/controllers/openapi/test_input_schema.py diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index baa481785d..f1a23df8c2 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -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", diff --git a/api/controllers/openapi/_input_schema.py b/api/controllers/openapi/_input_schema.py new file mode 100644 index 0000000000..2ff99f6dfc --- /dev/null +++ b/api/controllers/openapi/_input_schema.py @@ -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, + } diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index 8ff218b536..7304c83044 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -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 diff --git a/api/controllers/openapi/app_info.py b/api/controllers/openapi/app_info.py deleted file mode 100644 index d5af480f74..0000000000 --- a/api/controllers/openapi/app_info.py +++ /dev/null @@ -1,13 +0,0 @@ -"""GET /openapi/v1/apps//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//info") -class AppInfoApi(AppReadResource): - def get(self, app_id: str): - app, _ = self._load(app_id) - return app_info_payload(app), 200 diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index 3019bf7026..fed1eb237c 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -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//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/") -class AppByIdApi(AppReadResource): - def get(self, app_id: str): - app, _ = self._load(app_id) - return app_info_payload(app), 200 - - -@openapi_ns.route("/apps//parameters") -class AppParametersApi(AppReadResource): - def get(self, app_id: str): - app, _ = self._load(app_id) - return parameters_payload(app), 200 - - @openapi_ns.route("/apps//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 diff --git a/api/services/app_service.py b/api/services/app_service.py index a046b909b3..d7d3d04498 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -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"): diff --git a/api/tests/integration_tests/controllers/openapi/test_apps.py b/api/tests/integration_tests/controllers/openapi/test_apps.py index abf2597901..20ac46fbbd 100644 --- a/api/tests/integration_tests/controllers/openapi/test_apps.py +++ b/api/tests/integration_tests/controllers/openapi/test_apps.py @@ -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, diff --git a/api/tests/unit_tests/controllers/openapi/test_app_describe_query.py b/api/tests/unit_tests/controllers/openapi/test_app_describe_query.py new file mode 100644 index 0000000000..a6abdc95eb --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_app_describe_query.py @@ -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"}) diff --git a/api/tests/unit_tests/controllers/openapi/test_app_info.py b/api/tests/unit_tests/controllers/openapi/test_app_info.py deleted file mode 100644 index fec1424589..0000000000 --- a/api/tests/unit_tests/controllers/openapi/test_app_info.py +++ /dev/null @@ -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//info" in rules - - -def test_app_info_dispatches_to_class(): - app = _openapi_app() - rule = _rule(app, "/openapi/v1/apps//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"}], - } diff --git a/api/tests/unit_tests/controllers/openapi/test_app_payloads.py b/api/tests/unit_tests/controllers/openapi/test_app_payloads.py index a952579608..64cdc38250 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_payloads.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_payloads.py @@ -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)) diff --git a/api/tests/unit_tests/controllers/openapi/test_input_schema.py b/api/tests/unit_tests/controllers/openapi/test_input_schema.py new file mode 100644 index 0000000000..73cb978ac1 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_input_schema.py @@ -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"] == [] diff --git a/api/tests/unit_tests/controllers/openapi/test_models.py b/api/tests/unit_tests/controllers/openapi/test_models.py index 5cca6131cc..d29b592f6a 100644 --- a/api/tests/unit_tests/controllers/openapi/test_models.py +++ b/api/tests/unit_tests/controllers/openapi/test_models.py @@ -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