mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 05:41:09 +08:00
1007 lines
39 KiB
Python
1007 lines
39 KiB
Python
import importlib.util
|
|
import json as _schema_json
|
|
import re
|
|
import sys
|
|
from collections.abc import Iterator
|
|
from datetime import datetime
|
|
from enum import StrEnum
|
|
from pathlib import Path
|
|
from types import ModuleType, SimpleNamespace
|
|
from typing import Annotated, Literal, cast
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from flask import Blueprint, Flask, Response, request_finished
|
|
from flask import json as _error_response_contract_check_json
|
|
from flask_restx import Namespace, Resource, fields
|
|
from jsonschema import Draft202012Validator, ValidationError
|
|
from pydantic import BaseModel, ConfigDict, Field, create_model
|
|
|
|
from controllers.common.schema import register_response_schema_models
|
|
from libs.exception import BaseHTTPException, RelayedHTTPError
|
|
from libs.external_api import ExternalApi, error_responses
|
|
from tests.helpers.unit.controllers import error_contract
|
|
from tests.helpers.unit.controllers.error_contract import check_response, violation
|
|
|
|
|
|
class UserModel(BaseModel):
|
|
id: int
|
|
name: str
|
|
|
|
|
|
class ProductModel(BaseModel):
|
|
id: int
|
|
price: float
|
|
|
|
|
|
class ChildModel(BaseModel):
|
|
value: str
|
|
|
|
|
|
class ParentModel(BaseModel):
|
|
child: ChildModel
|
|
|
|
|
|
class StatusEnum(StrEnum):
|
|
ACTIVE = "active"
|
|
INACTIVE = "inactive"
|
|
|
|
|
|
class PriorityEnum(StrEnum):
|
|
HIGH = "high"
|
|
LOW = "low"
|
|
|
|
|
|
class QueryModel(BaseModel):
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
page: int = Field(default=1, ge=1, le=100, description="Page number")
|
|
keyword: str | None = Field(default=None, min_length=1, max_length=50, description="Search keyword")
|
|
status: Literal["active", "inactive"] | None = Field(default=None, description="Status filter")
|
|
enum_status: StatusEnum | None = Field(default=None, description="Enum status filter")
|
|
created_at: datetime | None = Field(default=None, description="Creation time")
|
|
app_id: str = Field(..., alias="appId", description="Application ID")
|
|
tag_ids: list[Annotated[str, Field(min_length=1, max_length=255)]] = Field(
|
|
default_factory=list,
|
|
min_length=1,
|
|
max_length=3,
|
|
description="Tag IDs",
|
|
)
|
|
ambiguous: int | str | None = Field(default=None, description="Ambiguous query parameter")
|
|
|
|
|
|
class HelperQueryModel(BaseModel):
|
|
page: int = 1
|
|
limit: int = 20
|
|
status: list[str] = Field(default_factory=list)
|
|
keyword: str | None = None
|
|
|
|
|
|
class NullableSchemaModel(BaseModel):
|
|
name: str | None = None
|
|
tags: list[str] | None = None
|
|
owner: UserModel | None = None
|
|
ambiguous: int | str | None = None
|
|
|
|
|
|
class ResponseAliasModel(BaseModel):
|
|
public_name: str = Field(validation_alias="internal_name")
|
|
|
|
|
|
def _make_same_named_model() -> type[BaseModel]:
|
|
"""Build a model whose qualified name differs from UserModel but whose bare name matches."""
|
|
|
|
return create_model("UserModel", __module__="other_module", email=(str, ...))
|
|
|
|
|
|
class TestSchemaCases:
|
|
@staticmethod
|
|
@pytest.fixture(autouse=True)
|
|
def console_ns() -> Iterator[Namespace]:
|
|
"""Isolate the console import while exercising a real namespace."""
|
|
namespace = Namespace("console")
|
|
with patch.dict(sys.modules, {"controllers.console": SimpleNamespace(console_ns=namespace)}):
|
|
yield namespace
|
|
|
|
@staticmethod
|
|
def test_default_ref_template_value() -> None:
|
|
from controllers.common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0
|
|
|
|
assert DEFAULT_REF_TEMPLATE_OPENAPI_3_0 == "#/components/schemas/{model}"
|
|
|
|
@staticmethod
|
|
def test_register_schema_model_calls_namespace_schema_model() -> None:
|
|
from controllers.common.schema import register_schema_model
|
|
|
|
namespace = Namespace("test")
|
|
|
|
register_schema_model(namespace, UserModel)
|
|
|
|
assert list(namespace.models) == ["UserModel"]
|
|
schema = namespace.models["UserModel"].__schema__
|
|
assert isinstance(schema, dict)
|
|
assert "properties" in schema
|
|
|
|
@staticmethod
|
|
def test_register_schema_model_passes_schema_from_pydantic() -> None:
|
|
from controllers.common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0, register_schema_model
|
|
|
|
namespace = Namespace("test")
|
|
|
|
register_schema_model(namespace, UserModel)
|
|
|
|
schema = namespace.models["UserModel"].__schema__
|
|
|
|
expected_schema = UserModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0)
|
|
|
|
assert schema == expected_schema
|
|
|
|
@staticmethod
|
|
def test_register_schema_model_promotes_nested_pydantic_definitions() -> None:
|
|
from controllers.common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0, register_schema_model
|
|
|
|
namespace = Namespace("test")
|
|
|
|
register_schema_model(namespace, ParentModel)
|
|
|
|
called_schemas = {name: model.__schema__ for name, model in namespace.models.items()}
|
|
parent_schema = ParentModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0)
|
|
|
|
assert set(called_schemas) == {"ParentModel", "ChildModel"}
|
|
assert "$defs" not in called_schemas["ParentModel"]
|
|
assert called_schemas["ParentModel"]["properties"]["child"]["$ref"] == "#/components/schemas/ChildModel"
|
|
assert called_schemas["ChildModel"] == parent_schema["$defs"]["ChildModel"]
|
|
|
|
@staticmethod
|
|
def test_register_schema_model_allows_reregistering_the_same_model() -> None:
|
|
from controllers.common.schema import register_schema_model
|
|
|
|
namespace = Namespace("test")
|
|
|
|
register_schema_model(namespace, UserModel)
|
|
register_schema_model(namespace, UserModel)
|
|
|
|
assert list(namespace.models) == ["UserModel"]
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("strict", [False, True])
|
|
def test_register_schema_model_keeps_annotated_enum_names_stable(strict: bool) -> None:
|
|
from controllers.common.schema import register_schema_model
|
|
|
|
enum_types = [StrEnum("StableStatus", {"ACTIVE": "active"}, module=__name__) for _ in range(2)]
|
|
snapshots = []
|
|
for enum_type in enum_types:
|
|
model = create_model("StatusPayload", status=(enum_type, Field(strict=strict)))
|
|
namespace = Namespace("test")
|
|
|
|
register_schema_model(namespace, model)
|
|
|
|
snapshots.append({name: registered.__schema__ for name, registered in namespace.models.items()})
|
|
|
|
assert snapshots[0] == snapshots[1]
|
|
|
|
@staticmethod
|
|
def test_register_schema_model_rejects_different_model_with_same_name() -> None:
|
|
from controllers.common.schema import SchemaNameCollisionError, register_schema_model
|
|
|
|
namespace = Namespace("test")
|
|
register_schema_model(namespace, UserModel)
|
|
|
|
with pytest.raises(SchemaNameCollisionError, match="'UserModel'.*produced by both"):
|
|
register_schema_model(namespace, _make_same_named_model())
|
|
|
|
@staticmethod
|
|
def test_register_schema_model_rejects_nested_definition_with_same_name() -> None:
|
|
from controllers.common.schema import SchemaNameCollisionError, register_schema_model
|
|
|
|
other_child = create_model("ChildModel", __module__="other_module", other=(int, ...))
|
|
other_parent = create_model("OtherParentModel", child=(other_child, ...))
|
|
namespace = Namespace("test")
|
|
register_schema_model(namespace, ParentModel)
|
|
|
|
with pytest.raises(SchemaNameCollisionError, match="'ChildModel'"):
|
|
register_schema_model(namespace, other_parent)
|
|
|
|
@staticmethod
|
|
def test_register_schema_model_rejects_same_named_types_inside_one_schema() -> None:
|
|
from controllers.common.schema import SchemaNameCollisionError, register_schema_model
|
|
|
|
other_child = create_model("ChildModel", __module__="other_module", other=(int, ...))
|
|
both_children = create_model("BothChildrenModel", first=(ChildModel, ...), second=(other_child, ...))
|
|
|
|
with pytest.raises(SchemaNameCollisionError, match="'ChildModel'"):
|
|
register_schema_model(Namespace("test"), both_children)
|
|
|
|
@staticmethod
|
|
def test_register_schema_model_rejects_model_registered_in_both_modes_with_different_shapes() -> None:
|
|
from controllers.common.schema import (
|
|
SchemaNameCollisionError,
|
|
register_response_schema_model,
|
|
register_schema_model,
|
|
)
|
|
|
|
namespace = Namespace("test")
|
|
register_response_schema_model(namespace, ResponseAliasModel)
|
|
|
|
with pytest.raises(SchemaNameCollisionError, match="two different shapes"):
|
|
register_schema_model(namespace, ResponseAliasModel)
|
|
|
|
@staticmethod
|
|
def test_register_schema_model_rejects_names_that_collide_after_normalization() -> None:
|
|
from controllers.common.schema import SchemaNameCollisionError, register_schema_model
|
|
|
|
namespace = Namespace("test")
|
|
register_schema_model(namespace, create_model("JSONPayload", value=(str, ...)))
|
|
|
|
with pytest.raises(SchemaNameCollisionError, match="once normalized"):
|
|
register_schema_model(namespace, create_model("JsonPayload", value=(str, ...)))
|
|
|
|
@staticmethod
|
|
def test_register_schema_model_keeps_namespaces_independent() -> None:
|
|
from controllers.common.schema import register_schema_model
|
|
|
|
register_schema_model(Namespace("first"), UserModel)
|
|
register_schema_model(Namespace("second"), _make_same_named_model())
|
|
|
|
@staticmethod
|
|
def test_register_schema_model_applies_schema_name_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from controllers.common import schema as schema_module
|
|
|
|
monkeypatch.setattr(
|
|
schema_module,
|
|
"SCHEMA_NAME_OVERRIDES",
|
|
{f"{ChildModel.__module__}.{ChildModel.__qualname__}": "RenamedChildModel"},
|
|
)
|
|
namespace = Namespace("test")
|
|
|
|
schema_module.register_schema_model(namespace, ParentModel)
|
|
|
|
assert set(namespace.models) == {"ParentModel", "RenamedChildModel"}
|
|
parent_schema = namespace.models["ParentModel"].__schema__
|
|
assert parent_schema["properties"]["child"]["$ref"] == "#/components/schemas/RenamedChildModel"
|
|
|
|
@staticmethod
|
|
def test_register_schema_models_registers_multiple_models() -> None:
|
|
from controllers.common.schema import register_schema_models
|
|
|
|
namespace = Namespace("test")
|
|
|
|
register_schema_models(namespace, UserModel, ProductModel)
|
|
|
|
called_names = list(namespace.models)
|
|
assert called_names == ["UserModel", "ProductModel"]
|
|
|
|
@staticmethod
|
|
def test_register_schema_models_calls_register_schema_model(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from controllers.common.schema import register_schema_models
|
|
|
|
namespace = Namespace("test")
|
|
|
|
calls = []
|
|
|
|
def fake_register(ns: Namespace, model: type[BaseModel]) -> None:
|
|
calls.append((ns, model))
|
|
|
|
monkeypatch.setattr(
|
|
"controllers.common.schema.register_schema_model",
|
|
fake_register,
|
|
)
|
|
|
|
register_schema_models(namespace, UserModel, ProductModel)
|
|
|
|
assert calls == [
|
|
(namespace, UserModel),
|
|
(namespace, ProductModel),
|
|
]
|
|
|
|
@staticmethod
|
|
def test_register_response_schema_model_uses_serialized_field_names() -> None:
|
|
from controllers.common.schema import register_response_schema_model
|
|
|
|
namespace = Namespace("test")
|
|
|
|
register_response_schema_model(namespace, ResponseAliasModel)
|
|
|
|
assert list(namespace.models) == ["ResponseAliasModel"]
|
|
schema = namespace.models["ResponseAliasModel"].__schema__
|
|
assert "public_name" in schema["properties"]
|
|
assert "internal_name" not in schema["properties"]
|
|
|
|
@staticmethod
|
|
def test_register_schema_model_preserves_openapi_nullable_unions() -> None:
|
|
from controllers.common.schema import register_schema_model
|
|
|
|
namespace = Namespace("test")
|
|
|
|
register_schema_model(namespace, NullableSchemaModel)
|
|
|
|
called_schemas = {name: model.__schema__ for name, model in namespace.models.items()}
|
|
properties = called_schemas["NullableSchemaModel"]["properties"]
|
|
|
|
assert properties["name"]["anyOf"] == [{"type": "string"}, {"type": "null"}]
|
|
assert properties["tags"]["anyOf"] == [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}]
|
|
assert properties["owner"]["anyOf"] == [{"$ref": "#/components/schemas/UserModel"}, {"type": "null"}]
|
|
assert "anyOf" in properties["ambiguous"]
|
|
|
|
@staticmethod
|
|
def test_get_or_create_model_returns_existing_model(console_ns: Namespace) -> None:
|
|
from controllers.common.schema import get_or_create_model
|
|
|
|
existing_model = console_ns.model("TestModel", {"name": fields.String})
|
|
|
|
result = get_or_create_model("TestModel", {"key": "value"})
|
|
|
|
assert result is existing_model
|
|
assert len(console_ns.models) == 1
|
|
|
|
@staticmethod
|
|
def test_get_or_create_model_creates_new_model_when_not_exists(console_ns: Namespace) -> None:
|
|
from controllers.common.schema import get_or_create_model
|
|
|
|
field_def = {"name": fields.String}
|
|
|
|
result = get_or_create_model("NewModel", field_def)
|
|
|
|
assert result is console_ns.models["NewModel"]
|
|
assert result.__schema__["properties"] == {"name": {"type": "string"}}
|
|
|
|
@staticmethod
|
|
def test_get_or_create_model_does_not_call_model_if_exists(console_ns: Namespace) -> None:
|
|
from controllers.common.schema import get_or_create_model
|
|
|
|
existing_model = console_ns.model("ExistingModel", {"name": fields.String})
|
|
|
|
result = get_or_create_model("ExistingModel", {"key": "value"})
|
|
|
|
assert result is existing_model
|
|
assert len(console_ns.models) == 1
|
|
|
|
@staticmethod
|
|
def test_register_enum_models_registers_single_enum() -> None:
|
|
from controllers.common.schema import register_enum_models
|
|
|
|
namespace = Namespace("test")
|
|
|
|
register_enum_models(namespace, StatusEnum)
|
|
|
|
assert list(namespace.models) == ["StatusEnum"]
|
|
schema = namespace.models["StatusEnum"].__schema__
|
|
assert isinstance(schema, dict)
|
|
|
|
@staticmethod
|
|
def test_register_enum_models_registers_multiple_enums() -> None:
|
|
from controllers.common.schema import register_enum_models
|
|
|
|
namespace = Namespace("test")
|
|
|
|
register_enum_models(namespace, StatusEnum, PriorityEnum)
|
|
|
|
called_names = list(namespace.models)
|
|
assert called_names == ["StatusEnum", "PriorityEnum"]
|
|
|
|
@staticmethod
|
|
def test_register_enum_models_uses_correct_ref_template() -> None:
|
|
from controllers.common.schema import register_enum_models
|
|
|
|
namespace = Namespace("test")
|
|
|
|
register_enum_models(namespace, StatusEnum)
|
|
|
|
schema = namespace.models["StatusEnum"].__schema__
|
|
|
|
# Verify the schema contains enum values
|
|
assert "enum" in schema or "anyOf" in schema
|
|
|
|
@staticmethod
|
|
def test_query_params_from_model_builds_flask_restx_doc_params() -> None:
|
|
from controllers.common.schema import query_params_from_model
|
|
|
|
params = query_params_from_model(QueryModel)
|
|
|
|
assert params["page"] == {
|
|
"in": "query",
|
|
"required": False,
|
|
"description": "Page number",
|
|
"type": "integer",
|
|
"default": 1,
|
|
"minimum": 1,
|
|
"maximum": 100,
|
|
}
|
|
assert params["keyword"] == {
|
|
"in": "query",
|
|
"required": False,
|
|
"description": "Search keyword",
|
|
"type": "string",
|
|
"minLength": 1,
|
|
"maxLength": 50,
|
|
}
|
|
assert params["status"] == {
|
|
"in": "query",
|
|
"required": False,
|
|
"description": "Status filter",
|
|
"type": "string",
|
|
"enum": ["active", "inactive"],
|
|
}
|
|
assert params["enum_status"] == {
|
|
"in": "query",
|
|
"required": False,
|
|
"description": "Enum status filter",
|
|
"type": "string",
|
|
"enum": ["active", "inactive"],
|
|
}
|
|
assert params["created_at"] == {
|
|
"in": "query",
|
|
"required": False,
|
|
"description": "Creation time",
|
|
"type": "string",
|
|
"format": "date-time",
|
|
}
|
|
assert params["appId"] == {
|
|
"in": "query",
|
|
"required": True,
|
|
"description": "Application ID",
|
|
"type": "string",
|
|
}
|
|
assert params["tag_ids"] == {
|
|
"in": "query",
|
|
"required": False,
|
|
"description": "Tag IDs",
|
|
"type": "array",
|
|
"items": {"type": "string", "minLength": 1, "maxLength": 255},
|
|
"minItems": 1,
|
|
"maxItems": 3,
|
|
}
|
|
assert params["ambiguous"] == {
|
|
"in": "query",
|
|
"required": False,
|
|
"description": "Ambiguous query parameter",
|
|
}
|
|
|
|
@staticmethod
|
|
def test_query_params_from_request_preserves_repeated_list_params() -> None:
|
|
from controllers.common.schema import query_params_from_request
|
|
|
|
app = Flask(__name__)
|
|
with app.test_request_context("/?page=2&limit=30&status=active&status=inactive&keyword=hello"):
|
|
query = query_params_from_request(HelperQueryModel, list_fields=("status",))
|
|
|
|
assert query.page == 2
|
|
assert query.limit == 30
|
|
assert query.status == ["active", "inactive"]
|
|
assert query.keyword == "hello"
|
|
|
|
@staticmethod
|
|
def test_query_params_from_request_raises_for_malformed_ints_by_default() -> None:
|
|
from controllers.common.schema import query_params_from_request
|
|
|
|
app = Flask(__name__)
|
|
with app.test_request_context("/?page=bad&limit="):
|
|
with pytest.raises(ValueError, match="2 validation errors for HelperQueryModel\\\npage"):
|
|
query_params_from_request(HelperQueryModel, list_fields=("status",))
|
|
|
|
@staticmethod
|
|
def test_query_params_from_request_can_use_model_default_for_malformed_defaulted_ints() -> None:
|
|
from controllers.common.schema import query_params_from_request
|
|
|
|
app = Flask(__name__)
|
|
with app.test_request_context("/?page=bad&limit="):
|
|
query = query_params_from_request(
|
|
HelperQueryModel,
|
|
list_fields=("status",),
|
|
use_defaults_for_malformed_ints=True,
|
|
)
|
|
|
|
assert query.page == 1
|
|
assert query.limit == 20
|
|
assert query.status == []
|
|
|
|
|
|
"""Coverage regressions for the enterprise inner API OpenAPI document."""
|
|
|
|
|
|
type Json = dict[str, object]
|
|
|
|
HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put", "trace"}
|
|
WRITE_METHODS = {"patch", "post", "put"}
|
|
ENTERPRISE_PREFIX = "/enterprise/"
|
|
|
|
EXPECTED_ENTERPRISE_OPERATION_IDS = {
|
|
"create_enterprise_workspace",
|
|
"create_enterprise_workspace_ownerless",
|
|
"delete_enterprise_workspace_resources",
|
|
"enterprise_app_detail",
|
|
"enterprise_app_dsl_export",
|
|
"enterprise_app_dsl_import",
|
|
"enterprise_app_list",
|
|
"enterprise_mint_file_grant",
|
|
"enterprise_runtime_credentials_resolve",
|
|
"join_enterprise_workspace_member",
|
|
"send_enterprise_mail",
|
|
}
|
|
|
|
|
|
def _obj(value: object) -> Json:
|
|
return cast(Json, value)
|
|
|
|
|
|
def _at(value: object, *keys: str) -> Json:
|
|
"""Walk nested JSON objects by key; each step is cast because the spec is untyped JSON."""
|
|
node = _obj(value)
|
|
for key in keys:
|
|
node = _obj(node[key])
|
|
return node
|
|
|
|
|
|
def _load_generate_swagger_specs_module() -> ModuleType:
|
|
api_dir = Path(__file__).resolve().parents[4]
|
|
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 _enterprise_operations(spec: Json) -> Iterator[tuple[str, str, Json, Json]]:
|
|
for path, path_item in _obj(spec["paths"]).items():
|
|
if not path.startswith(ENTERPRISE_PREFIX):
|
|
continue
|
|
item = _obj(path_item)
|
|
for method, operation in item.items():
|
|
if method in HTTP_METHODS:
|
|
yield path, method, item, _obj(operation)
|
|
|
|
|
|
def _operation(spec: Json, path: str, method: str) -> Json:
|
|
return _at(spec, "paths", path, method)
|
|
|
|
|
|
def _schema(spec: Json, name: str) -> Json:
|
|
return _at(spec, "components", "schemas", name)
|
|
|
|
|
|
def _refs(value: object) -> Iterator[str]:
|
|
if isinstance(value, dict):
|
|
mapping = _obj(value)
|
|
ref = mapping.get("$ref")
|
|
if isinstance(ref, str):
|
|
yield ref
|
|
for child in mapping.values():
|
|
yield from _refs(child)
|
|
elif isinstance(value, list):
|
|
for child in cast(list[object], value):
|
|
yield from _refs(child)
|
|
|
|
|
|
def _validator(spec: Json, name: str) -> Draft202012Validator:
|
|
return Draft202012Validator({"$ref": f"#/components/schemas/{name}", "components": spec["components"]})
|
|
|
|
|
|
ERROR_RESPONSE_REF = {"$ref": "#/components/schemas/ErrorResponse"}
|
|
LEGACY_SIMPLE_MESSAGE_RESPONSES = {
|
|
"create_enterprise_workspace": ("404",),
|
|
"enterprise_app_dsl_import": ("404",),
|
|
"enterprise_runtime_credentials_resolve": ("400", "404"),
|
|
"join_enterprise_workspace_member": ("400", "404"),
|
|
}
|
|
FILE_GRANT_BUSINESS_COMPONENTS = {
|
|
"400": {"InvalidRequestError", "GrantTtlTooLongError", "InvalidSubjectError"},
|
|
"404": {"AppNotFoundError", "FileNotFoundError"},
|
|
}
|
|
|
|
|
|
def _response_schema(operation: Json, code: str) -> Json:
|
|
return _at(operation, "responses", code, "content", "application/json", "schema")
|
|
|
|
|
|
def _alternatives(schema: Json) -> list[Json]:
|
|
return [_obj(item) for item in cast(list[object], schema["anyOf"])] if "anyOf" in schema else [schema]
|
|
|
|
|
|
def _operations_by_id(spec: Json) -> dict[str, Json]:
|
|
return {str(operation["operationId"]): operation for _, _, _, operation in _enterprise_operations(spec)}
|
|
|
|
|
|
class TestSchemaCasesInnerApi:
|
|
@staticmethod
|
|
@pytest.fixture(scope="module")
|
|
def inner_spec(tmp_path_factory: pytest.TempPathFactory) -> Json:
|
|
module = _load_generate_swagger_specs_module()
|
|
written_paths = module.generate_specs(tmp_path_factory.mktemp("inner_api_spec"))
|
|
inner_path = next(path for path in written_paths if path.name == "inner-openapi.json")
|
|
return _obj(_schema_json.loads(inner_path.read_text(encoding="utf-8")))
|
|
|
|
@staticmethod
|
|
def test_enterprise_operations_declare_typed_request_bodies(inner_spec: Json) -> None:
|
|
operations = list(_enterprise_operations(inner_spec))
|
|
assert operations
|
|
|
|
for path, method, _, operation in operations:
|
|
if method in WRITE_METHODS:
|
|
media = _at(operation, "requestBody", "content", "application/json")
|
|
assert "schema" in media, f"{method} {path} request body has no schema"
|
|
if method == "get":
|
|
assert "requestBody" not in operation, f"GET {path} must not declare a request body"
|
|
|
|
@staticmethod
|
|
def test_enterprise_success_responses_are_typed(inner_spec: Json) -> None:
|
|
for path, method, _, operation in _enterprise_operations(inner_spec):
|
|
for code, response in _obj(operation["responses"]).items():
|
|
if not code.startswith("2"):
|
|
continue
|
|
content = _obj(_obj(response).get("content"))
|
|
assert content, f"{method} {path} {code} has no content"
|
|
for media_type, media in content.items():
|
|
assert "schema" in _obj(media), f"{method} {path} {code} {media_type} has no schema"
|
|
|
|
@staticmethod
|
|
def test_enterprise_error_responses_are_typed(inner_spec: Json) -> None:
|
|
for path, method, _, operation in _enterprise_operations(inner_spec):
|
|
for code, response in _obj(operation["responses"]).items():
|
|
if code.startswith("2"):
|
|
continue
|
|
content = _obj(_obj(response).get("content"))
|
|
assert content, f"{method} {path} {code} is not typed"
|
|
json_media = _obj(content.get("application/json"))
|
|
assert "schema" in json_media, f"{method} {path} {code} has no JSON schema"
|
|
|
|
@staticmethod
|
|
def test_enterprise_path_placeholders_have_required_path_parameters(inner_spec: Json) -> None:
|
|
for path, method, path_item, operation in _enterprise_operations(inner_spec):
|
|
parameters = [
|
|
*cast(list[Json], path_item.get("parameters", [])),
|
|
*cast(list[Json], operation.get("parameters", [])),
|
|
]
|
|
declared = {
|
|
parameter["name"]
|
|
for parameter in parameters
|
|
if parameter.get("in") == "path" and parameter.get("required") is True
|
|
}
|
|
for placeholder in re.findall(r"{(\w+)}", path):
|
|
assert placeholder in declared, f"{method} {path} missing required path parameter {placeholder}"
|
|
|
|
@staticmethod
|
|
def test_enterprise_schema_references_resolve(inner_spec: Json) -> None:
|
|
schemas = _at(inner_spec, "components", "schemas")
|
|
for _, _, _, operation in _enterprise_operations(inner_spec):
|
|
for ref in _refs(operation):
|
|
assert ref.removeprefix("#/components/schemas/") in schemas, ref
|
|
|
|
@staticmethod
|
|
def test_enterprise_operation_ids_are_unchanged(inner_spec: Json) -> None:
|
|
operation_ids = [operation["operationId"] for _, _, _, operation in _enterprise_operations(inner_spec)]
|
|
|
|
assert len(operation_ids) == len(set(operation_ids))
|
|
assert set(operation_ids) == EXPECTED_ENTERPRISE_OPERATION_IDS
|
|
|
|
@staticmethod
|
|
def test_enterprise_mail_documents_simple_message_response(inner_spec: Json) -> None:
|
|
media = _at(
|
|
_operation(inner_spec, "/enterprise/mail", "post"), "responses", "200", "content", "application/json"
|
|
)
|
|
|
|
assert media["schema"] == {"$ref": "#/components/schemas/SimpleMessageResponse"}
|
|
|
|
@staticmethod
|
|
def test_ownerless_workspace_custom_config_is_object(inner_spec: Json) -> None:
|
|
schema = _schema(inner_spec, "WorkspaceOwnerlessTenantResponse")
|
|
custom_config = _at(schema, "properties", "custom_config")
|
|
|
|
assert custom_config["type"] == "object"
|
|
assert custom_config["additionalProperties"] is True
|
|
assert "custom_config" in cast(list[str], schema["required"])
|
|
|
|
@staticmethod
|
|
def test_owner_workspace_variant_excludes_custom_config_and_public_key(inner_spec: Json) -> None:
|
|
owner = _obj(_schema(inner_spec, "WorkspaceResponse")["properties"])
|
|
|
|
assert {"custom_config", "encrypt_public_key"}.isdisjoint(owner)
|
|
|
|
@staticmethod
|
|
def test_runtime_credential_values_accept_arbitrary_fields(inner_spec: Json) -> None:
|
|
values = _at(_schema(inner_spec, "InnerRuntimeCredentialResponse"), "properties", "values")
|
|
|
|
assert values["type"] == "object"
|
|
assert values["additionalProperties"] is True
|
|
|
|
@staticmethod
|
|
def test_import_response_excludes_app_created(inner_spec: Json) -> None:
|
|
assert "app_created" not in _obj(_schema(inner_spec, "Import")["properties"])
|
|
|
|
@staticmethod
|
|
def test_dsl_export_documents_yaml_data_string(inner_spec: Json) -> None:
|
|
assert _at(_schema(inner_spec, "AppExportResponse"), "properties", "data")["type"] == "string"
|
|
|
|
@staticmethod
|
|
def test_ownerless_create_sample_validates_custom_config_object(inner_spec: Json) -> None:
|
|
tenant = {
|
|
"id": "t",
|
|
"name": "n",
|
|
"plan": "p",
|
|
"status": "normal",
|
|
"encrypt_public_key": None,
|
|
"custom_config": {"remove_webapp_brand": True},
|
|
"created_at": None,
|
|
"updated_at": None,
|
|
}
|
|
validator = _validator(inner_spec, "WorkspaceOwnerlessCreateResponse")
|
|
|
|
validator.validate({"message": "ok", "tenant": tenant})
|
|
validator.validate({"message": "ok", "tenant": {**tenant, "custom_config": {}}})
|
|
with pytest.raises(ValidationError):
|
|
validator.validate({"message": "ok", "tenant": {**tenant, "custom_config": _schema_json.dumps({"a": 1})}})
|
|
|
|
@staticmethod
|
|
def test_credentials_sample_accepts_arbitrary_values(inner_spec: Json) -> None:
|
|
_validator(inner_spec, "InnerRuntimeCredentialsResolveResponse").validate(
|
|
{
|
|
"credentials": [
|
|
{"credential_id": "c", "provider": "p", "kind": "model", "values": {"api_key": "k", "x": 1}}
|
|
]
|
|
}
|
|
)
|
|
|
|
@staticmethod
|
|
def test_enterprise_operations_document_default_error_response(inner_spec: Json) -> None:
|
|
for path, method, _, operation in _enterprise_operations(inner_spec):
|
|
assert _response_schema(operation, "default") == ERROR_RESPONSE_REF, f"{method} {path}"
|
|
|
|
@staticmethod
|
|
def test_enterprise_documented_errors_include_standard_error_body(inner_spec: Json) -> None:
|
|
for path, method, _, operation in _enterprise_operations(inner_spec):
|
|
for code in _obj(operation["responses"]):
|
|
if code == "default" or not code.startswith(("4", "5")):
|
|
continue
|
|
assert ERROR_RESPONSE_REF in _alternatives(_response_schema(operation, code)), f"{method} {path} {code}"
|
|
|
|
@staticmethod
|
|
def test_legacy_message_statuses_document_simple_message(inner_spec: Json) -> None:
|
|
operations = _operations_by_id(inner_spec)
|
|
for operation_id, codes in LEGACY_SIMPLE_MESSAGE_RESPONSES.items():
|
|
for code in codes:
|
|
assert {"$ref": "#/components/schemas/SimpleMessageResponse"} in _alternatives(
|
|
_response_schema(operations[operation_id], code)
|
|
), f"{operation_id} {code}"
|
|
|
|
@staticmethod
|
|
def test_dsl_export_not_found_union_documents_simple_message(inner_spec: Json) -> None:
|
|
operation = _operations_by_id(inner_spec)["enterprise_app_dsl_export"]
|
|
assert _alternatives(_response_schema(operation, "404"))[0] == {
|
|
"$ref": "#/components/schemas/EnterpriseAppDSLExportNotFoundResponse"
|
|
}
|
|
union = _alternatives(_schema(inner_spec, "EnterpriseAppDSLExportNotFoundResponse"))
|
|
assert {"$ref": "#/components/schemas/SimpleMessageResponse"} in union
|
|
|
|
@staticmethod
|
|
def test_file_grants_document_business_error_components(inner_spec: Json) -> None:
|
|
operation = _operations_by_id(inner_spec)["enterprise_mint_file_grant"]
|
|
for code, expected in FILE_GRANT_BUSINESS_COMPONENTS.items():
|
|
refs = {
|
|
str(item["$ref"]).rsplit("/", 1)[-1]
|
|
for item in _alternatives(_response_schema(operation, code))
|
|
if "$ref" in item
|
|
}
|
|
assert expected <= refs, (code, refs)
|
|
for name in expected:
|
|
error_body = cast(list[object], _schema(inner_spec, name)["allOf"])[1]
|
|
assert _at(error_body, "properties", "status")["const"] == int(code), name
|
|
|
|
|
|
class _ItemGoneError(BaseHTTPException):
|
|
error_code = "item_gone"
|
|
code = 404
|
|
|
|
|
|
class _ItemLockedDetails(BaseModel):
|
|
locked_by: str
|
|
|
|
|
|
class _ItemLockedError(BaseHTTPException):
|
|
error_code = "item_locked"
|
|
code = 404
|
|
details_model = _ItemLockedDetails
|
|
|
|
|
|
class _LegacyConflict(BaseModel):
|
|
error: str
|
|
|
|
|
|
def _api() -> tuple[Flask, ExternalApi]:
|
|
app = Flask(__name__)
|
|
blueprint = Blueprint("contract_check", __name__, url_prefix="/check")
|
|
api = ExternalApi(blueprint)
|
|
ns = Namespace("items", path="/")
|
|
register_response_schema_models(ns, _LegacyConflict)
|
|
|
|
@ns.route("/items")
|
|
class Items(Resource):
|
|
@ns.response(409, "Legacy conflict", ns.models[_LegacyConflict.__name__])
|
|
@error_responses(_ItemGoneError, _ItemLockedError)
|
|
def get(self) -> dict[str, str]:
|
|
return {}
|
|
|
|
@ns.route("/relayed")
|
|
class Relayed(Resource):
|
|
@error_responses(_ItemGoneError, RelayedHTTPError)
|
|
def get(self) -> dict[str, str]:
|
|
return {}
|
|
|
|
api.add_namespace(ns)
|
|
app.register_blueprint(blueprint)
|
|
return app, api
|
|
|
|
|
|
def _check(body: object, status: int, path: str = "/check/items") -> str | None:
|
|
app, api = _api()
|
|
with app.test_request_context(path):
|
|
return violation(
|
|
api, Response(_error_response_contract_check_json.dumps(body), status=status, mimetype="application/json")
|
|
)
|
|
|
|
|
|
class TestErrorResponseContractCheckCases:
|
|
@staticmethod
|
|
def test_rejects_a_body_without_the_standard_fields() -> None:
|
|
found = _check({"error": "gone"}, 404)
|
|
assert found is not None
|
|
assert "lacks" in found
|
|
|
|
@staticmethod
|
|
def test_rejects_a_code_the_operation_does_not_document_for_the_status() -> None:
|
|
found = _check({"code": "surprise", "message": "m", "status": 404}, 404)
|
|
assert found is not None
|
|
assert "'surprise' is not documented" in found
|
|
|
|
@staticmethod
|
|
def test_accepts_documented_and_implied_codes() -> None:
|
|
assert _check({"code": "item_gone", "message": "m", "status": 404}, 404) is None
|
|
assert _check({"code": "not_found", "message": "m", "status": 404}, 404) is None
|
|
|
|
@staticmethod
|
|
def test_generic_codes_satisfy_the_full_schema_of_a_status_that_documents_business_errors() -> None:
|
|
# A generic 404 answers an operation that documents its own 404 codes: the status's complete
|
|
# schema (not only ``default``) must admit it, so the open error body stays in the ``anyOf``.
|
|
app, api = _api()
|
|
with app.test_request_context("/check/items"):
|
|
schema = error_contract._document(api)["paths"]["/items"]["get"]["responses"]["404"]["content"][ # type: ignore[index]
|
|
"application/json"
|
|
]["schema"]
|
|
assert schema["anyOf"][-1] == {"$ref": "#/components/schemas/ErrorResponse"}
|
|
assert _check({"code": "not_found", "message": "m", "status": 404}, 404) is None
|
|
|
|
@staticmethod
|
|
def test_validates_a_documented_code_against_its_component() -> None:
|
|
assert (
|
|
_check({"code": "item_locked", "message": "m", "status": 404, "details": {"locked_by": "a"}}, 404) is None
|
|
)
|
|
|
|
found = _check({"code": "item_locked", "message": "m", "status": 404, "details": {}}, 404)
|
|
assert found is not None
|
|
assert "'item_locked' component" in found
|
|
assert "locked_by" in found
|
|
|
|
@staticmethod
|
|
def test_accepts_a_body_matching_a_legacy_model_the_status_declares() -> None:
|
|
assert _check({"error": "legacy"}, 409) is None
|
|
assert _check({"code": "conflict", "message": "m", "status": 409}, 409) is None
|
|
|
|
@staticmethod
|
|
def test_rejects_a_documented_code_served_with_another_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# On the surfaces the lint covers every served code must be documented or generic.
|
|
monkeypatch.setattr(error_contract, "_documents_business_errors", lambda _api: True)
|
|
found = _check({"code": "item_gone", "message": "m", "status": 409}, 409)
|
|
assert found is not None
|
|
assert "'item_gone' is not documented" in found
|
|
|
|
@staticmethod
|
|
def test_accepts_any_code_from_an_operation_that_relays_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(error_contract, "_documents_business_errors", lambda _api: True)
|
|
assert _check({"code": "downstream_busy", "message": "m", "status": 503}, 503, "/check/relayed") is None
|
|
assert _check({"code": "item_gone", "message": "m", "status": 404}, 404, "/check/relayed") is None
|
|
# Relaying never excuses a body that is not the error body.
|
|
found = _check({"error": "x"}, 503, "/check/relayed")
|
|
assert found is not None
|
|
assert "lacks" in found
|
|
|
|
@staticmethod
|
|
def test_only_checks_documented_statuses_on_apis_without_business_errors() -> None:
|
|
# A test-built API (like /openapi/v1) documents nothing, so an undocumented status stays open.
|
|
assert _check({"code": "item_gone", "message": "m", "status": 409}, 409) is None
|
|
|
|
@staticmethod
|
|
def test_rejects_a_body_matching_neither_the_legacy_nor_the_standard_model() -> None:
|
|
found = _check({"code": "conflict", "message": "m"}, 409)
|
|
assert found is not None
|
|
assert "lacks ['status']" in found
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize(
|
|
("body", "field"),
|
|
[
|
|
({"code": "item_gone", "message": {"nested": True}, "status": 404}, "message"),
|
|
({"code": ["item_gone"], "message": "m", "status": 404}, "code"),
|
|
({"code": "item_gone", "message": "m", "status": "404"}, "status"),
|
|
],
|
|
)
|
|
def test_rejects_standard_fields_of_the_wrong_type(body: dict[str, object], field: str) -> None:
|
|
found = _check(body, 404)
|
|
assert found is not None
|
|
assert "does not match ErrorResponse" in found
|
|
assert f"{field}:" in found
|
|
|
|
@staticmethod
|
|
def test_documents_only_the_surface_models_a_test_api_references(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# Copying every surface model into each test API made every document carry thousands of schemas.
|
|
surface = ExternalApi(Flask("surface"))
|
|
surface.model("BorrowedItem", {"value": fields.String})
|
|
surface.model("UnrelatedItem", {"value": fields.String})
|
|
monkeypatch.setattr(error_contract, "_module_apis", lambda *_names: iter([surface]))
|
|
app = Flask(__name__)
|
|
api = ExternalApi(app)
|
|
|
|
@api.route("/items")
|
|
class Items(Resource):
|
|
@api.response(200, "OK", "BorrowedItem")
|
|
def get(self) -> dict[str, str]:
|
|
return {}
|
|
|
|
own_models = dict(api.models)
|
|
with app.test_request_context("/items"):
|
|
schemas = error_contract._document(api)["components"]["schemas"] # type: ignore[index]
|
|
|
|
assert "BorrowedItem" in schemas
|
|
assert "UnrelatedItem" not in schemas
|
|
assert dict(api.models) == own_models
|
|
|
|
@staticmethod
|
|
@pytest.mark.allow_error_contract_violations
|
|
def test_checks_responses_served_by_an_api_mounted_on_the_app() -> None:
|
|
app = Flask(__name__)
|
|
api = ExternalApi(app)
|
|
|
|
@api.route("/items")
|
|
class Items(Resource):
|
|
def get(self) -> tuple[dict[str, str], int]:
|
|
return {"wrong": "shape"}, 400
|
|
|
|
violations: list[str] = []
|
|
|
|
def receiver(sender: Flask, response: Response, **_: object) -> None:
|
|
check_response(sender, response, violations)
|
|
|
|
with request_finished.connected_to(receiver, app):
|
|
app.test_client().get("/items")
|
|
|
|
assert len(violations) == 1
|
|
assert "lacks" in violations[0]
|
|
|
|
@staticmethod
|
|
@pytest.mark.allow_error_contract_violations
|
|
def test_reports_responses_it_cannot_check() -> None:
|
|
app = Flask(__name__)
|
|
api = ExternalApi(app)
|
|
|
|
class _Unregistered(BaseModel):
|
|
value: str
|
|
|
|
@api.route("/items")
|
|
class Items(Resource):
|
|
@api.response(200, "OK", _Unregistered.__name__)
|
|
def get(self) -> tuple[dict[str, str], int]:
|
|
return {"wrong": "shape"}, 400
|
|
|
|
violations: list[str] = []
|
|
|
|
def receiver(sender: Flask, response: Response, **_: object) -> None:
|
|
check_response(sender, response, violations)
|
|
|
|
with request_finished.connected_to(receiver, app):
|
|
app.test_client().get("/items")
|
|
|
|
assert len(violations) == 1
|
|
assert "cannot check the error contract" in violations[0]
|