增加宽容的schema验证

This commit is contained in:
gongwenxin 2025-06-27 19:46:53 +08:00
parent f003fbbbd1
commit 39effa9461
36 changed files with 24456 additions and 68425 deletions

View File

@ -0,0 +1,81 @@
from typing import Dict, Any, List, Optional
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext
from ddms_compliance_suite.utils.response_utils import extract_data_for_validation
from ddms_compliance_suite.utils.schema_provider import SchemaProvider
import logging
class FlexibleSchemaValidationCase(BaseAPITestCase):
"""
一个灵活的Schema验证测试用例能够处理非标准的响应结构和动态的Schema来源
"""
id = "TC-CORE-FUNC-002"
name = "灵活的返回体JSON Schema验证"
description = (
"验证API响应体是否符合预期的JSON Schema。此用例能够智能处理被包装的响应{code, data}"
"并支持从列表响应中验证每个元素。它依赖于SchemaProvider获取schema并设计为处理需要动态获取schema的场景。"
)
severity = TestSeverity.CRITICAL
tags = ["core-functionality", "schema-validation", "flexible"]
execution_order = 110 # 略高于标准Schema验证以便在适用时优先执行
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service)
# We need to initialize the schema_provider here, as it's no longer injected.
self.schema_provider = SchemaProvider(global_api_spec) if global_api_spec else None
self.logger.info(f"测试用例 '{self.id}' 已为端点 '{self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}' 初始化。")
def execute(self, request_context: APIRequestContext) -> List[ValidationResult]:
"""
执行灵活的schema验证
"""
results = []
response_context = self.api_caller.call_api(request_context)
if not response_context:
return [self.failed("API调用失败无法获取响应进行验证。")]
# 1. 使用 SchemaProvider 获取 Schema
if not self.schema_provider:
return [self.failed("SchemaProvider 未被初始化,无法执行此测试用例。")]
expected_schema = self.schema_provider.get_schema(self.endpoint_spec, response_context.status_code)
if not expected_schema:
# 如果是成功响应但找不到schema这可能是一个问题
if 200 <= response_context.status_code < 300:
return [self.failed(f"成功响应(状态码 {response_context.status_code})但无法为其找到或生成JSON Schema。")]
else:
return [self.passed(f"非成功响应(状态码 {response_context.status_code})且未定义Schema跳过验证。")]
# 2. 使用 response_utils 提取待验证的数据列表
if not response_context.json_content:
return [self.failed(f"响应内容不是有效的JSON格式无法进行Schema验证。响应文本: {response_context.text_content[:200]}...")]
data_to_validate_list = extract_data_for_validation(response_context.json_content)
if not data_to_validate_list:
# extract_data_for_validation 在找不到数据或遇到空列表时返回空列表
return [self.passed("未从响应中提取到需要验证的数据项(可能为空列表),跳过验证。")]
# 3. 遍历列表,对每个数据项进行验证
all_items_passed = True
for i, item in enumerate(data_to_validate_list):
item_context_prefix = f"响应列表中的第 {i+1} 个元素"
validation_results = self.validate_data_against_schema(
data_to_validate=item,
schema_definition=expected_schema,
context_message_prefix=item_context_prefix
)
for res in validation_results:
if not res.passed:
all_items_passed = False
# 为错误信息添加更多上下文
res.message = f"{item_context_prefix} {res.message}"
results.append(res)
if all_items_passed:
results.append(self.passed(f"成功验证了响应中的 {len(data_to_validate_list)} 个数据项均符合Schema。"))
return results

View File

@ -1,6 +1,7 @@
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext from ddms_compliance_suite.test_framework_core import BaseAPITestCase, TestSeverity, ValidationResult, APIRequestContext, APIResponseContext
import json import json
from ddms_compliance_suite.utils.schema_provider import SchemaProvider
class ResponseSchemaValidationCase(BaseAPITestCase): class ResponseSchemaValidationCase(BaseAPITestCase):
id = "TC-CORE-FUNC-001" id = "TC-CORE-FUNC-001"
@ -10,11 +11,10 @@ class ResponseSchemaValidationCase(BaseAPITestCase):
tags = ["core-functionality", "schema-validation", "output-format"] tags = ["core-functionality", "schema-validation", "output-format"]
execution_order = 100 # Default, can be adjusted execution_order = 100 # Default, can be adjusted
# This test is generally applicable, especially for GET requests or successful POST/PUT.
# It might need refinement based on specific endpoint characteristics (e.g., no response body for DELETE)
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None): def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service) super().__init__(endpoint_spec, global_api_spec, json_schema_validator, llm_service=llm_service)
self.schema_provider = SchemaProvider(global_api_spec) if global_api_spec else None
self.logger.info(f"测试用例 '{self.id}' 已为端点 '{self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}' 初始化。") self.logger.info(f"测试用例 '{self.id}' 已为端点 '{self.endpoint_spec.get('method')} {self.endpoint_spec.get('path')}' 初始化。")
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]: def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
@ -22,37 +22,13 @@ class ResponseSchemaValidationCase(BaseAPITestCase):
method = request_context.method.upper() method = request_context.method.upper()
status_code = response_context.status_code status_code = response_context.status_code
# Determine the expected response schema based on method and status code if not self.schema_provider:
# This logic might need to be more sophisticated depending on how schemas are structured in your API spec (YAPI/Swagger) return [self.failed("SchemaProvider 未被初始化,无法执行此测试用例。")]
expected_schema = None
response_spec_key = None
if 'responses' in self.endpoint_spec: # OpenAPI/Swagger style
if str(status_code) in self.endpoint_spec['responses']:
response_def = self.endpoint_spec['responses'][str(status_code)]
if 'content' in response_def and 'application/json' in response_def['content']:
expected_schema = response_def['content']['application/json'].get('schema')
response_spec_key = f"responses.{status_code}.content.application/json.schema"
elif 'default' in self.endpoint_spec['responses']: # Fallback to default response
response_def = self.endpoint_spec['responses']['default']
if 'content' in response_def and 'application/json' in response_def['content']:
expected_schema = response_def['content']['application/json'].get('schema')
response_spec_key = f"responses.default.content.application/json.schema"
elif 'res_body_type' in self.endpoint_spec and self.endpoint_spec['res_body_type'] == 'json': # YAPI style (simplified)
if 'res_body_is_json_schema' in self.endpoint_spec and self.endpoint_spec['res_body_is_json_schema']:
if self.endpoint_spec.get('res_body'):
try:
# YAPI often stores schema as a JSON string
expected_schema = json.loads(self.endpoint_spec['res_body'])
response_spec_key = "res_body (从JSON字符串解析)"
except json.JSONDecodeError as e:
self.logger.error(f"从YAPI res_body解析JSON schema失败: {e}")
results.append(self.failed(f"无法从YAPI规范解析响应schema: {e}"))
return results
# Only proceed with schema validation if we have a schema and a JSON response body expected_schema = self.schema_provider.get_schema(self.endpoint_spec, status_code)
if expected_schema and response_context.json_content is not None: if expected_schema and response_context.json_content is not None:
self.logger.info(f"将根据路径 '{response_spec_key or '未知位置'}' 的schema验证响应体。") self.logger.info(f"将根据从API规范中获取的schema验证响应体。")
schema_validation_results = self.validate_data_against_schema( schema_validation_results = self.validate_data_against_schema(
data_to_validate=response_context.json_content, data_to_validate=response_context.json_content,
schema_definition=expected_schema, schema_definition=expected_schema,
@ -60,22 +36,18 @@ class ResponseSchemaValidationCase(BaseAPITestCase):
) )
results.extend(schema_validation_results) results.extend(schema_validation_results)
elif response_context.json_content is None and method not in ["DELETE", "HEAD", "OPTIONS"] and status_code in [200, 201, 202]: elif response_context.json_content is None and method not in ["DELETE", "HEAD", "OPTIONS"] and status_code in [200, 201, 202]:
# If we expected a JSON body (e.g. for successful GET/POST) but got none if expected_schema:
if expected_schema: # and if a schema was defined
results.append(self.failed( results.append(self.failed(
message=f"根据schema期望一个JSON响应体但未收到可解析的JSON内容。", message=f"根据schema期望一个JSON响应体但未收到可解析的JSON内容。",
details={"status_code": status_code, "response_text_sample": (response_context.text_content or "")[:200]} details={"status_code": status_code, "response_text_sample": (response_context.text_content or "")[:200]}
)) ))
self.logger.warning(f"期望 {method} {request_context.url} 返回JSON响应体但未收到或非JSON格式。") self.logger.warning(f"期望 {method} {request_context.url} 返回JSON响应体但未收到或非JSON格式。")
elif not expected_schema and response_context.json_content is not None and status_code // 100 == 2: elif not expected_schema and response_context.json_content is not None and status_code // 100 == 2:
# If there is a JSON body but no schema was found for successful responses
self.logger.info(f"响应包含JSON体但在API规范中未找到针对状态码 {status_code} 的JSON schema。跳过schema验证。") self.logger.info(f"响应包含JSON体但在API规范中未找到针对状态码 {status_code} 的JSON schema。跳过schema验证。")
# Optionally, add an informational validation result:
# results.append(ValidationResult(passed=True, message="Response has JSON body, but no schema defined for validation.", details={"status_code": status_code}))
elif not expected_schema and response_context.json_content is None: elif not expected_schema and response_context.json_content is None:
self.logger.info(f"状态码 {status_code} 的响应无JSON体也无定义的schema。跳过schema验证。") self.logger.info(f"状态码 {status_code} 的响应无JSON体也无定义的schema。跳过schema验证。")
if not results: # If no specific validation was added (e.g. schema not found but not an error) if not results:
results.append(self.passed("Schema验证步骤完成未发现问题或schema不适用/未为此响应定义)。")) results.append(self.passed("标准Schema验证步骤完成未发现问题或schema不适用/未为此响应定义)。"))
return results return results

View File

@ -2,6 +2,7 @@ from enum import Enum
from typing import Any, Dict, Optional, List, Tuple, Type, Union from typing import Any, Dict, Optional, List, Tuple, Type, Union
import logging import logging
from .utils import schema_utils from .utils import schema_utils
from pydantic import BaseModel, Field
class TestSeverity(Enum): class TestSeverity(Enum):
"""测试用例的严重程度""" """测试用例的严重程度"""
@ -114,7 +115,11 @@ class BaseAPITestCase:
use_llm_for_query_params: bool = False use_llm_for_query_params: bool = False
use_llm_for_headers: bool = False use_llm_for_headers: bool = False
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None): def __init__(self,
endpoint_spec: Dict[str, Any],
global_api_spec: Dict[str, Any],
json_schema_validator: Optional[Any] = None,
llm_service: Optional[Any] = None):
""" """
初始化测试用例 初始化测试用例
Args: Args:

View File

@ -42,6 +42,8 @@ except ImportError:
LLMService = None LLMService = None
logging.getLogger(__name__).info("LLMService 未找到LLM 相关功能将不可用。") logging.getLogger(__name__).info("LLMService 未找到LLM 相关功能将不可用。")
from ddms_compliance_suite.utils.schema_provider import SchemaProvider
_dynamic_model_cache: Dict[str, Type[BaseModel]] = {} _dynamic_model_cache: Dict[str, Type[BaseModel]] = {}
class ExecutedTestCaseResult: class ExecutedTestCaseResult:
@ -510,6 +512,10 @@ class APITestOrchestrator:
logging.info(f"strictness_level: {self.strictness_level}") logging.info(f"strictness_level: {self.strictness_level}")
elif strictness_level: elif strictness_level:
logging.warning(f"提供了无效的严格等级 '{strictness_level}'。将使用默认行为。有效值: {', '.join([e.name for e in TestSeverity])}") logging.warning(f"提供了无效的严格等级 '{strictness_level}'。将使用默认行为。有效值: {', '.join([e.name for e in TestSeverity])}")
# 将这些属性的初始化移到此处并设为None避免在_execute_tests_from_parsed_spec之前被错误使用
self.json_schema_validator: Optional[JSONSchemaValidator] = None
self.schema_provider: Optional[SchemaProvider] = None
def get_api_call_details(self) -> List[APICallDetail]: def get_api_call_details(self) -> List[APICallDetail]:
"""Returns the collected list of API call details.""" """Returns the collected list of API call details."""
@ -981,7 +987,7 @@ class APITestOrchestrator:
endpoint_spec=endpoint_spec_dict, endpoint_spec=endpoint_spec_dict,
global_api_spec=global_spec_dict, global_api_spec=global_spec_dict,
json_schema_validator=self.json_validator, json_schema_validator=self.json_validator,
llm_service=self.llm_service # Pass the orchestrator's LLM service instance llm_service=self.llm_service
) )
self.logger.info(f"开始执行测试用例 '{test_case_instance.id}' ({test_case_instance.name}) for endpoint '{endpoint_spec_dict.get('method', 'N/A')} {endpoint_spec_dict.get('path', 'N/A')}'") self.logger.info(f"开始执行测试用例 '{test_case_instance.id}' ({test_case_instance.name}) for endpoint '{endpoint_spec_dict.get('method', 'N/A')} {endpoint_spec_dict.get('path', 'N/A')}'")

View File

@ -0,0 +1,38 @@
from typing import Any, List
import logging
logger = logging.getLogger(__name__)
def extract_data_for_validation(response_json: Any) -> List[Any]:
"""
从原始API响应JSON中智能提取需要被验证的核心业务数据列表
即使只有一个对象也返回一个单元素的列表
策略:
1. 如果响应体是包含 'code' 'data' 的标准包装则提取 'data' 的内容
2. 如果处理后的数据是列表直接返回该列表
3. 如果处理后的数据是单个对象字典将其包装在单元素列表中返回
4. 如果数据为空或不适用返回空列表
"""
if not response_json:
return []
data_to_process = response_json
# 策略 1: 解开标准包装
if isinstance(response_json, dict) and 'code' in response_json and 'data' in response_json:
logger.debug("检测到标准响应包装,提取 'data' 字段内容进行处理。")
data_to_process = response_json['data']
# 策略 2: 统一返回列表
if isinstance(data_to_process, list):
logger.debug(f"数据本身为列表,包含 {len(data_to_process)} 个元素,直接返回。")
return data_to_process
if isinstance(data_to_process, dict):
logger.debug("数据为单个对象,将其包装在列表中返回。")
return [data_to_process]
# 对于其他情况如数据为None或非对象/列表类型),返回空列表
logger.warning(f"待处理的数据既不是列表也不是对象,无法提取进行验证。数据: {str(data_to_process)[:100]}")
return []

View File

@ -0,0 +1,60 @@
import logging
import json
from typing import Dict, Any, Optional
class SchemaProvider:
def __init__(self, global_api_spec: Dict[str, Any]):
self.global_api_spec = global_api_spec
self.logger = logging.getLogger(__name__)
def get_schema(self, endpoint_spec: Dict[str, Any], status_code: int) -> Optional[Dict]:
"""
获取端点在特定状态码下的响应Schema
当前实现从API规范中查找
未来可扩展优先从动态映射中获取如果失败或未配置则回退到当前实现
"""
# --- 预留的动态获取逻辑扩展点 ---
# if self._use_dynamic_provider(endpoint_spec):
# schema = self._fetch_dynamic_schema(endpoint_spec)
# if schema:
# return schema
# ---------------------------------
return self._get_schema_from_spec(endpoint_spec, status_code)
def _get_schema_from_spec(self, endpoint_spec: Dict[str, Any], status_code: int) -> Optional[Dict]:
"""
(私有方法) 从API规范中提取Schema这是当前版本的主要实现
"""
self.logger.debug(f"尝试从API规范中为状态码 {status_code} 查找Schema。")
expected_schema = None
# 兼容 OpenAPI/Swagger 格式
if 'responses' in endpoint_spec:
responses = endpoint_spec['responses']
# 优先匹配精确的状态码
if str(status_code) in responses:
response_def = responses[str(status_code)]
if 'content' in response_def and 'application/json' in response_def['content']:
expected_schema = response_def['content']['application/json'].get('schema')
# 回退到 'default'
elif 'default' in responses:
response_def = responses['default']
if 'content' in response_def and 'application/json' in response_def['content']:
expected_schema = response_def['content']['application/json'].get('schema')
# 兼容 YAPI 格式 (简化)
elif 'res_body_type' in endpoint_spec and endpoint_spec['res_body_type'] == 'json':
if endpoint_spec.get('res_body_is_json_schema') and endpoint_spec.get('res_body'):
try:
expected_schema = json.loads(endpoint_spec['res_body'])
except (json.JSONDecodeError, TypeError):
self.logger.error(f"从YAPI res_body解析JSON schema失败。res_body: {endpoint_spec['res_body']}")
return None # 解析失败
if not expected_schema:
self.logger.info(f"在API规范中未找到针对状态码 {status_code} 的JSON schema。")
return expected_schema

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,49 @@
# 架构重构机会
本文档记录了在开发过程中发现的、有价值的但因当前优先级或工作量而暂未实施的架构重构机会。旨在为未来的代码质量提升和迭代开发提供清晰的路线图。
## 1. 改进 `APITestOrchestrator` 的依赖注入和生命周期管理
### 当前问题与"不优雅"的根源
在为测试用例引入 `SchemaProvider` 作为核心依赖时,我们遇到了一个典型的 **对象生命周期与依赖注入时序问题**
1. **理想的设计** 是由框架(`APITestOrchestrator`)在初始化时创建并持有一个全局唯一的 `SchemaProvider` 实例,然后在创建测试用例时,将这个实例 **注入** 进去。
2. **现实的障碍**`SchemaProvider` 的创建依赖于 `global_api_spec`即解析后的API规范文件`global_api_spec` 本身是在 `APITestOrchestrator` 实例化很久之后,在 `run_tests_from_yapi/swagger` 等方法内部才被加载和创建的。
3. **导致的结果** 是我们无法在 `APITestOrchestrator``__init__` 方法中优雅地创建 `SchemaProvider`,这导致了 `AttributeError`
我们当前采用的临时方案是让每个测试用例自己在 `__init__` 中创建 `SchemaProvider` 实例。这虽然解决了功能问题,但存在代码重复,并且违背了依赖注入的最佳实践。
### 待实施的重构方案
#### 方案A: 内部逻辑提炼 (中期优化)
这是我们讨论过的、侵入性较小的优化方案。
1. 在 `APITestOrchestrator` 中创建一个新的私有方法,例如 `_initialize_spec_dependent_services(self, parsed_spec)`
2. 此方法负责获取 `parsed_spec`,设置 `self.global_api_spec`,并创建所有依赖它的服务(如 `SchemaProvider`),将实例存放在 `self` 的属性中。
3. 在 `run_tests_from_yapi``run_tests_from_swagger` 的开头,调用这个新的私有方法。
4. 改造 `BaseAPITestCase`,使其能够接收注入的 `schema_provider` 实例。
**优点**: 实现简单,风险小,能有效减少测试用例中的代码重复。
#### 方案B: 彻底分离业务阶段 (长期目标)
这是更理想、更彻底的重构方案,它将 `APITestOrchestrator` 的功能进行明确的阶段划分。
1. **重构 `APITestOrchestrator` 的公共API**,将其从一个大而全的 `run_tests_from_...` 方法,拆分为多个独立的、职责单一的公共方法,例如:
* `load_spec(file_path: str) -> ParsedAPISpec`: 只负责加载和解析文件,返回一个解析后的对象。
* `prepare_execution(parsed_spec: ParsedAPISpec)`: 接收解析后的对象,用它来初始化所有依赖的服务,如 `SchemaProvider`,并准备好待测试的端点列表。
* `execute() -> TestSummary`: 执行所有准备好的测试,并返回最终的摘要。
2. **调整调用流程**: `run_api_tests.py` 中的 `main` 函数也需要相应调整,以适配这种分阶段的调用方式。
**优点**:
* **完全符合单一职责原则**:每个方法只做一件事。
* **生命周期清晰**:数据和依赖的生命周期管理变得非常清晰、可控。
* **高度可测试**:可以单独对 `load_spec``prepare_execution` 进行单元测试。
* **更灵活**: 未来可以支持更复杂的场景比如加载多个API规范文件后合并测试。
### 重构收益
完成上述任一方案特别是方案B将使得测试框架的核心代码更加健壮、可维护和可扩展为未来添加更复杂的功能如测试依赖、多文件聚合测试等打下坚实的基础。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff