mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 10:38:32 +08:00
302 lines
14 KiB
Python
302 lines
14 KiB
Python
"""Unit tests for the standalone OpenAPI export helper."""
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def _walk_values(value):
|
|
yield value
|
|
match value:
|
|
case dict():
|
|
for child in value.values():
|
|
yield from _walk_values(child)
|
|
case list():
|
|
for child in value:
|
|
yield from _walk_values(child)
|
|
|
|
|
|
def _load_generate_swagger_specs_module():
|
|
api_dir = Path(__file__).resolve().parents[3]
|
|
script_path = api_dir / "dev" / "generate_swagger_specs.py"
|
|
|
|
spec = importlib.util.spec_from_file_location("generate_swagger_specs", script_path)
|
|
assert spec
|
|
assert spec.loader
|
|
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module) # type: ignore[attr-defined]
|
|
return module
|
|
|
|
|
|
def _operation_ids(payload):
|
|
methods = {"delete", "get", "head", "options", "patch", "post", "put", "trace"}
|
|
for path_item in payload["paths"].values():
|
|
for method, operation in path_item.items():
|
|
if method in methods and isinstance(operation, dict) and "operationId" in operation:
|
|
yield operation["operationId"]
|
|
|
|
|
|
def _get_operations(payload):
|
|
for path_item in payload["paths"].values():
|
|
operation = path_item.get("get")
|
|
if isinstance(operation, dict):
|
|
yield operation
|
|
|
|
|
|
def _response_schema(operation, status="200"):
|
|
return operation["responses"][status]["content"]["application/json"]["schema"]
|
|
|
|
|
|
def _request_schema(operation, content_type="application/json"):
|
|
return operation["requestBody"]["content"][content_type]["schema"]
|
|
|
|
|
|
def _nullable_schema_ref(schema):
|
|
if "$ref" in schema:
|
|
return schema["$ref"]
|
|
return next(item["$ref"] for item in schema["anyOf"] if "$ref" in item)
|
|
|
|
|
|
def test_generate_specs_writes_console_web_and_service_openapi_files(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
|
|
assert [path.name for path in written_paths] == [
|
|
"console-openapi.json",
|
|
"web-openapi.json",
|
|
"service-openapi.json",
|
|
"openapi-openapi.json",
|
|
]
|
|
|
|
for path in written_paths:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert payload["openapi"].startswith("3.")
|
|
assert "paths" in payload
|
|
|
|
|
|
def test_generate_specs_writes_openapi_with_resolvable_references_and_no_nulls(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
|
|
for path in written_paths:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
schemas = payload["components"]["schemas"]
|
|
refs = {
|
|
item["$ref"].removeprefix("#/components/schemas/")
|
|
for item in _walk_values(payload)
|
|
if isinstance(item, dict)
|
|
and isinstance(item.get("$ref"), str)
|
|
and item["$ref"].startswith("#/components/schemas/")
|
|
}
|
|
|
|
assert refs <= set(schemas)
|
|
assert all(value is not None for value in _walk_values(payload))
|
|
|
|
|
|
def test_generate_specs_writes_unique_operation_ids(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
|
|
for path in written_paths:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
operation_ids = list(_operation_ids(payload))
|
|
|
|
assert len(operation_ids) == len(set(operation_ids))
|
|
|
|
|
|
def test_generate_specs_writes_get_operations_without_request_bodies(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
|
|
for path in written_paths:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
assert all("requestBody" not in operation for operation in _get_operations(payload))
|
|
|
|
|
|
def test_generate_specs_writes_service_api_reference_descriptions(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
service_path = next(path for path in written_paths if path.name == "service-openapi.json")
|
|
payload = json.loads(service_path.read_text(encoding="utf-8"))
|
|
|
|
chat_operation = payload["paths"]["/chat-messages"]["post"]
|
|
assert chat_operation["summary"] == "Send Chat Message"
|
|
assert chat_operation["description"] == "Send a request to the chat application."
|
|
assert chat_operation["tags"] == ["Chatflows", "Chats"]
|
|
|
|
rename_operation = payload["paths"]["/conversations/{c_id}/name"]["post"]
|
|
assert rename_operation["summary"] == "Rename Conversation"
|
|
|
|
|
|
def test_standalone_inline_model_name_includes_list_constraints():
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
from flask_restx import fields
|
|
|
|
cases = (
|
|
({"min_items": 1}, {"min_items": 2}),
|
|
({"max_items": 1}, {"max_items": 2}),
|
|
({"unique": True}, {"unique": False}),
|
|
)
|
|
for first_kwargs, second_kwargs in cases:
|
|
first_inline_model = {"items": fields.List(fields.String, **first_kwargs)}
|
|
second_inline_model = {"items": fields.List(fields.String, **second_kwargs)}
|
|
|
|
assert module._inline_model_name(first_inline_model) != module._inline_model_name(second_inline_model)
|
|
|
|
|
|
def test_generate_specs_is_idempotent(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
first_paths = module.generate_specs(tmp_path / "first")
|
|
second_paths = module.generate_specs(tmp_path / "second")
|
|
|
|
assert [path.name for path in first_paths] == [path.name for path in second_paths]
|
|
for first_path, second_path in zip(first_paths, second_paths):
|
|
assert first_path.read_text(encoding="utf-8") == second_path.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_generate_specs_include_agent_v2_knowledge_set_schema_and_query_enums(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
console_path = next(path for path in written_paths if path.name == "console-openapi.json")
|
|
payload = json.loads(console_path.read_text(encoding="utf-8"))
|
|
schemas = payload["components"]["schemas"]
|
|
|
|
assert "AgentKnowledgeSetConfig" in schemas
|
|
assert schemas["AgentSoulKnowledgeConfig"]["properties"]["sets"]["items"]["$ref"] == (
|
|
"#/components/schemas/AgentKnowledgeSetConfig"
|
|
)
|
|
assert schemas["AgentKnowledgeQueryMode"]["enum"] == ["generated_query", "user_query"]
|
|
|
|
|
|
def test_generate_specs_include_console_contract_shapes_for_schema_migration(tmp_path):
|
|
module = _load_generate_swagger_specs_module()
|
|
|
|
written_paths = module.generate_specs(tmp_path)
|
|
console_path = next(path for path in written_paths if path.name == "console-openapi.json")
|
|
payload = json.loads(console_path.read_text(encoding="utf-8"))
|
|
schemas = payload["components"]["schemas"]
|
|
paths = payload["paths"]
|
|
|
|
file_upload_schema = _request_schema(paths["/files/upload"]["post"], "multipart/form-data")
|
|
assert file_upload_schema["required"] == ["file"]
|
|
assert file_upload_schema["properties"]["file"]["format"] == "binary"
|
|
assert file_upload_schema["properties"]["file"]["type"] == "string"
|
|
assert file_upload_schema["properties"]["source"]["enum"] == ["datasets"]
|
|
|
|
invoices_schema_ref = _response_schema(paths["/billing/invoices"]["get"])["$ref"].removeprefix(
|
|
"#/components/schemas/"
|
|
)
|
|
assert schemas[invoices_schema_ref]["properties"]["url"]["type"] == "string"
|
|
|
|
app_detail_schema = schemas["RecommendedAppDetailResponse"]
|
|
assert app_detail_schema["properties"]["id"]["type"] == "string"
|
|
assert app_detail_schema["properties"]["export_data"]["type"] == "string"
|
|
assert {"type": "boolean"} in app_detail_schema["properties"]["can_trial"]["anyOf"]
|
|
app_detail_nullable_schema = schemas["RecommendedAppDetailNullableResponse"]
|
|
assert _response_schema(paths["/explore/apps/{app_id}"]["get"])["$ref"] == (
|
|
"#/components/schemas/RecommendedAppDetailNullableResponse"
|
|
)
|
|
assert {"$ref": "#/components/schemas/RecommendedAppDetailResponse"} in app_detail_nullable_schema["anyOf"]
|
|
assert {"type": "null"} in app_detail_nullable_schema["anyOf"]
|
|
assert schemas["RecommendedAppInfoResponse"]["properties"]["icon_url"]["readOnly"] is True
|
|
assert schemas["InstalledAppInfoResponse"]["properties"]["icon_url"]["readOnly"] is True
|
|
tool_icon_schema = schemas["ExploreAppMetaResponse"]["properties"]["tool_icons"]["additionalProperties"]
|
|
assert {"type": "string"} in tool_icon_schema["anyOf"]
|
|
assert {"additionalProperties": True, "type": "object"} in tool_icon_schema["anyOf"]
|
|
assert "ToolIconResponse" not in schemas
|
|
|
|
plugin_versions = schemas["PluginVersionsResponse"]["properties"]["versions"]
|
|
assert plugin_versions["additionalProperties"]["anyOf"][0]["$ref"] == "#/components/schemas/LatestPluginCache"
|
|
assert plugin_versions["additionalProperties"]["anyOf"][1]["type"] == "null"
|
|
plugin_installations = schemas["PluginInstallationsResponse"]["properties"]["plugins"]
|
|
assert plugin_installations["items"]["$ref"] == "#/components/schemas/PluginInstallationItemResponse"
|
|
|
|
rbac_whitelist_request = _request_schema(paths["/workspaces/current/rbac/apps/{app_id}/whitelist"]["put"])
|
|
assert rbac_whitelist_request["$ref"] == "#/components/schemas/_ResourceAccessScopeRequest"
|
|
app_access_policy_params = paths["/workspaces/current/rbac/apps/{app_id}/access-policy"]["get"]["parameters"]
|
|
language_param = next(param for param in app_access_policy_params if param["name"] == "language")
|
|
assert language_param["schema"]["enum"] == ["en", "ja", "zh"]
|
|
|
|
trigger_list_schema = _response_schema(paths["/workspaces/current/triggers"]["get"])
|
|
assert trigger_list_schema["$ref"] == "#/components/schemas/TriggerProviderListResponse"
|
|
trigger_builder_create_schema = _response_schema(
|
|
paths["/workspaces/current/trigger-provider/{provider}/subscriptions/builder/create"]["post"]
|
|
)
|
|
assert trigger_builder_create_schema["$ref"] == "#/components/schemas/TriggerSubscriptionBuilderCreateResponse"
|
|
assert (
|
|
schemas["TriggerSubscriptionBuilderCreateResponse"]["properties"]["subscription_builder"]["$ref"]
|
|
== "#/components/schemas/SubscriptionBuilderApiEntity"
|
|
)
|
|
|
|
conversation_variables = schemas["ConversationVariableUpdatePayload"]["properties"]["conversation_variables"]
|
|
assert conversation_variables["items"]["$ref"] == "#/components/schemas/ConversationVariableItemPayload"
|
|
workflow_features = schemas["WorkflowFeaturesPayload"]["properties"]["features"]
|
|
assert workflow_features["$ref"] == "#/components/schemas/WorkflowFeaturesConfigPayload"
|
|
workflow_feature_properties = schemas["WorkflowFeaturesConfigPayload"]["properties"]
|
|
assert _nullable_schema_ref(workflow_feature_properties["suggested_questions_after_answer"]) == (
|
|
"#/components/schemas/WorkflowSuggestedQuestionsAfterAnswerPayload"
|
|
)
|
|
assert _nullable_schema_ref(workflow_feature_properties["text_to_speech"]) == (
|
|
"#/components/schemas/WorkflowTextToSpeechPayload"
|
|
)
|
|
assert _nullable_schema_ref(workflow_feature_properties["sensitive_word_avoidance"]) == (
|
|
"#/components/schemas/WorkflowSensitiveWordAvoidancePayload"
|
|
)
|
|
assert {"enabled", "model", "prompt"} <= set(schemas["WorkflowSuggestedQuestionsAfterAnswerPayload"]["properties"])
|
|
assert {"enabled", "language", "voice", "autoPlay"} <= set(schemas["WorkflowTextToSpeechPayload"]["properties"])
|
|
assert {"enabled", "type", "config"} <= set(schemas["WorkflowSensitiveWordAvoidancePayload"]["properties"])
|
|
file_upload = schemas["WorkflowFileUploadPayload"]["properties"]
|
|
assert {"document", "audio", "video", "custom", "preview_config"} <= set(file_upload)
|
|
assert "detail" in schemas["WorkflowFileUploadImagePayload"]["properties"]
|
|
assert {"mode", "file_type_list"} <= set(schemas["WorkflowFileUploadPreviewConfigPayload"]["properties"])
|
|
assert schemas["AccountWithRole"]["properties"]["avatar_url"]["readOnly"] is True
|
|
|
|
|
|
def test_checked_in_agent_v2_knowledge_openapi_and_generated_contracts_are_in_sync():
|
|
api_dir = Path(__file__).resolve().parents[3]
|
|
repo_root = api_dir.parent
|
|
|
|
markdown = (api_dir / "openapi" / "markdown" / "console-openapi.md").read_text(encoding="utf-8")
|
|
agent_types = (
|
|
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "types.gen.ts"
|
|
).read_text(encoding="utf-8")
|
|
apps_types = (
|
|
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "types.gen.ts"
|
|
).read_text(encoding="utf-8")
|
|
agent_zod = (
|
|
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "zod.gen.ts"
|
|
).read_text(encoding="utf-8")
|
|
apps_zod = (
|
|
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "zod.gen.ts"
|
|
).read_text(encoding="utf-8")
|
|
|
|
assert "#### AgentKnowledgeSetConfig" in markdown
|
|
assert "#### AgentSoulKnowledgeConfig" in markdown
|
|
assert "#### AgentKnowledgeQueryMode" in markdown
|
|
|
|
for content in (agent_types, apps_types):
|
|
assert "export type AgentKnowledgeSetConfig = {" in content
|
|
assert "export type AgentSoulKnowledgeConfig = {" in content
|
|
assert "AgentKnowledgeQueryMode" in content
|
|
assert "generated_query" in content
|
|
assert "user_query" in content
|
|
|
|
for content in (agent_zod, apps_zod):
|
|
assert "export const zAgentKnowledgeSetConfig = z.object({" in content
|
|
assert "export const zAgentSoulKnowledgeConfig = z.object({" in content
|
|
assert "zAgentKnowledgeQueryMode = z.enum([" in content
|
|
assert "generated_query" in content
|
|
assert "user_query" in content
|