v0.1
This commit is contained in:
parent
01b044b35a
commit
1138668a72
Binary file not shown.
@ -1,7 +1,7 @@
|
|||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List, Union
|
||||||
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 copy
|
|
||||||
import logging
|
import logging
|
||||||
|
from ddms_compliance_suite.utils import schema_utils # 导入新的工具模块
|
||||||
|
|
||||||
class MissingRequiredFieldBodyCase(BaseAPITestCase):
|
class MissingRequiredFieldBodyCase(BaseAPITestCase):
|
||||||
id = "TC-ERROR-4003-BODY"
|
id = "TC-ERROR-4003-BODY"
|
||||||
@ -9,120 +9,60 @@ class MissingRequiredFieldBodyCase(BaseAPITestCase):
|
|||||||
description = "测试当请求体中缺少API规范定义的必填字段时,API是否按预期返回类似4003的错误(或通用400错误)。"
|
description = "测试当请求体中缺少API规范定义的必填字段时,API是否按预期返回类似4003的错误(或通用400错误)。"
|
||||||
severity = TestSeverity.HIGH
|
severity = TestSeverity.HIGH
|
||||||
tags = ["error-handling", "appendix-b", "4003", "required-fields", "request-body"]
|
tags = ["error-handling", "appendix-b", "4003", "required-fields", "request-body"]
|
||||||
execution_order = 210 # Before query, same as original combined
|
execution_order = 210
|
||||||
|
|
||||||
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.logger = logging.getLogger(f"testcase.{self.id}")
|
self.logger = logging.getLogger(f"testcase.{self.id}")
|
||||||
self.target_field_path: Optional[List[str]] = None
|
self.removed_field_path: Optional[List[Union[str, int]]] = None # Path can contain int for array indices
|
||||||
self.original_value_at_path: Any = None
|
self.original_value_at_path: Any = None
|
||||||
self.removed_field_path: Optional[List[str]] = None # Path to the removed field, e.g., ['level1', 'level2_field']
|
|
||||||
self.original_body_schema: Optional[Dict[str, Any]] = None
|
# --- Framework Utility Access ---
|
||||||
|
# orchestrator_placeholder 及其检查逻辑现在可以移除
|
||||||
|
# --- End Framework Utility Access ---
|
||||||
|
|
||||||
self._try_find_removable_body_field()
|
self._try_find_removable_body_field()
|
||||||
|
|
||||||
def _resolve_ref_if_present(self, schema_to_resolve: Dict[str, Any]) -> Dict[str, Any]:
|
def _get_request_body_schema(self) -> Optional[Dict[str, Any]]:
|
||||||
# 根据用户进一步要求,方法体简化为直接返回,不进行任何 $ref/$ $$ref 的检查。
|
"""
|
||||||
# self.logger.debug(f"_resolve_ref_if_present called. Returning schema as-is per new configuration.")
|
Helper to get the (already $ref-resolved) request body schema from self.endpoint_spec.
|
||||||
return schema_to_resolve
|
"""
|
||||||
|
|
||||||
def _find_required_field_in_schema_recursive(self, current_schema: Dict[str, Any], current_path: List[str]) -> Optional[List[str]]:
|
|
||||||
"""递归查找第一个可移除的必填字段的路径。
|
|
||||||
现在也会查找数组内对象中必填的字段。"""
|
|
||||||
resolved_schema = self._resolve_ref_if_present(current_schema)
|
|
||||||
|
|
||||||
if not isinstance(resolved_schema, dict) or resolved_schema.get("type") != "object":
|
|
||||||
# If not an object schema, cannot have 'required' or 'properties' in the way we expect.
|
|
||||||
return None
|
|
||||||
|
|
||||||
required_fields_at_current_level = resolved_schema.get("required", [])
|
|
||||||
properties = resolved_schema.get("properties", {})
|
|
||||||
self.logger.debug(f"递归查找路径: {current_path}, 当前层级必填字段: {required_fields_at_current_level}, 属性: {list(properties.keys())}")
|
|
||||||
|
|
||||||
# 策略1: 查找当前层级直接声明的必填字段 (简单类型或复杂类型均可)
|
|
||||||
if required_fields_at_current_level and properties:
|
|
||||||
for field_name in required_fields_at_current_level:
|
|
||||||
if field_name in properties:
|
|
||||||
# 任何在 'required' 数组中列出的字段,无论其类型,都可以作为目标
|
|
||||||
# (例如,移除一个必填的整个对象或数组也是一种有效的测试场景)
|
|
||||||
self.logger.info(f"策略1: 在路径 {'.'.join(current_path) if current_path else 'root'} 找到可直接移除的必填字段: '{field_name}'")
|
|
||||||
return current_path + [field_name]
|
|
||||||
|
|
||||||
# 策略2: 如果当前层级没有直接的必填字段可移除,则查找数组属性,看其内部item是否有必填字段
|
|
||||||
# 这种情况下,数组本身可能不是必填的,但如果提供了数组,其item需要满足条件
|
|
||||||
if properties: # 确保有属性可迭代
|
|
||||||
for prop_name, prop_schema_orig in properties.items():
|
|
||||||
prop_schema = self._resolve_ref_if_present(prop_schema_orig) # 解析属性自身的schema (可能也是ref)
|
|
||||||
if isinstance(prop_schema, dict) and prop_schema.get("type") == "array":
|
|
||||||
items_schema_orig = prop_schema.get("items")
|
|
||||||
if isinstance(items_schema_orig, dict):
|
|
||||||
items_schema = self._resolve_ref_if_present(items_schema_orig) # 解析 items 的 schema
|
|
||||||
if isinstance(items_schema, dict) and items_schema.get("type") == "object":
|
|
||||||
item_required_fields = items_schema.get("required", [])
|
|
||||||
item_properties = items_schema.get("properties", {})
|
|
||||||
if item_required_fields and item_properties:
|
|
||||||
first_required_field_in_item = None
|
|
||||||
for req_item_field in item_required_fields:
|
|
||||||
if req_item_field in item_properties: # 确保该必填字段在属性中定义
|
|
||||||
first_required_field_in_item = req_item_field
|
|
||||||
break
|
|
||||||
|
|
||||||
if first_required_field_in_item:
|
|
||||||
self.logger.info(f"策略2: 在数组属性 '{prop_name}' (路径 {'.'.join(current_path) if current_path else 'root'}) 的元素内找到必填字段: '{first_required_field_in_item}'. 将尝试移除路径: {current_path + [prop_name, 0, first_required_field_in_item]}")
|
|
||||||
# 将路径指向数组的第一个元素 (index 0) 内的那个必填字段
|
|
||||||
return current_path + [prop_name, 0, first_required_field_in_item]
|
|
||||||
|
|
||||||
# 策略3: (可选,如果需要更深层次的普通对象递归)
|
|
||||||
# 如果以上策略都未找到,并且希望深入到非必填的子对象中查找,可以启用以下逻辑。
|
|
||||||
# 但这通常不用于"顶层必填字段缺失"的测试目的,除非测试用例目标是验证任意深度的必填。
|
|
||||||
# for prop_name, prop_schema_orig_for_recurse in properties.items():
|
|
||||||
# prop_schema_for_recurse = self._resolve_ref_if_present(prop_schema_orig_for_recurse)
|
|
||||||
# if isinstance(prop_schema_for_recurse, dict) and prop_schema_for_recurse.get("type") == "object":
|
|
||||||
# # 确保不陷入无限循环,例如,如果一个对象属性是可选的但其内部有必填字段
|
|
||||||
# # 这里需要小心,因为我们可能已经检查过当前级别的required字段
|
|
||||||
# # 主要用于当某个对象不是顶层必填,但如果提供了它,它内部又有必填项的场景
|
|
||||||
# # 但这与当前测试用例的 primary goal 可能不完全一致
|
|
||||||
# self.logger.debug(f"策略3: 尝试递归进入对象属性 '{prop_name}' (路径 {'.'.join(current_path)}) (此对象本身在当前层级非必填或已检查)")
|
|
||||||
# found_path_deeper = self._find_required_field_in_schema_recursive(prop_schema_for_recurse, current_path + [prop_name])
|
|
||||||
# if found_path_deeper:
|
|
||||||
# # 确保返回的路径确实比当前路径深,并且该深层路径的父级(即prop_name)不是当前层级已知的必填字段
|
|
||||||
# # (以避免重复发现已被策略1覆盖的场景)
|
|
||||||
# # if prop_name not in required_fields_at_current_level:
|
|
||||||
# self.logger.info(f"策略3: 递归在对象属性 '{prop_name}' (路径 {'.'.join(current_path)}) 中找到必填字段路径: {found_path_deeper}")
|
|
||||||
# return found_path_deeper
|
|
||||||
|
|
||||||
self.logger.debug(f"在路径 {'.'.join(current_path) if current_path else 'root'} 未通过任何策略找到可移除的必填字段。")
|
|
||||||
return None
|
|
||||||
def _try_find_removable_body_field(self):
|
|
||||||
body_schema_to_check: Optional[Dict[str, Any]] = None
|
|
||||||
request_body_spec = self.endpoint_spec.get("requestBody")
|
request_body_spec = self.endpoint_spec.get("requestBody")
|
||||||
if request_body_spec and isinstance(request_body_spec, dict):
|
if request_body_spec and isinstance(request_body_spec, dict):
|
||||||
content = request_body_spec.get("content", {})
|
content = request_body_spec.get("content", {})
|
||||||
json_schema_entry = content.get("application/json")
|
# Iterate through common JSON content types or prioritize application/json
|
||||||
if json_schema_entry and isinstance(json_schema_entry, dict) and isinstance(json_schema_entry.get("schema"), dict):
|
for ct in ["application/json", "application/merge-patch+json", "*/*"]:
|
||||||
body_schema_to_check = json_schema_entry["schema"]
|
if ct in content:
|
||||||
|
media_type_obj = content[ct]
|
||||||
|
if isinstance(media_type_obj, dict) and isinstance(media_type_obj.get("schema"), dict):
|
||||||
|
return media_type_obj["schema"]
|
||||||
|
|
||||||
if not body_schema_to_check:
|
# Fallback for OpenAPI 2.0 (Swagger) style 'in: body' parameter
|
||||||
parameters = self.endpoint_spec.get("parameters", [])
|
parameters = self.endpoint_spec.get("parameters", [])
|
||||||
if isinstance(parameters, list):
|
if isinstance(parameters, list):
|
||||||
for param in parameters:
|
for param in parameters:
|
||||||
if isinstance(param, dict) and param.get("in") == "body":
|
if isinstance(param, dict) and param.get("in") == "body":
|
||||||
if isinstance(param.get("schema"), dict):
|
if isinstance(param.get("schema"), dict):
|
||||||
body_schema_to_check = param["schema"]
|
# Schema for 'in: body' parameter is directly usable
|
||||||
break
|
return param["schema"]
|
||||||
|
self.logger.debug("No suitable request body schema found in endpoint_spec.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _try_find_removable_body_field(self):
|
||||||
|
body_schema_to_check = self._get_request_body_schema()
|
||||||
|
|
||||||
if body_schema_to_check:
|
if body_schema_to_check:
|
||||||
self.original_body_schema = copy.deepcopy(body_schema_to_check)
|
self.removed_field_path = schema_utils.util_find_removable_field_path_recursive(
|
||||||
self.removed_field_path = self._find_required_field_in_schema_recursive(self.original_body_schema, [])
|
current_schema=body_schema_to_check,
|
||||||
|
current_path=[],
|
||||||
|
full_api_spec_for_refs=self.global_api_spec
|
||||||
|
)
|
||||||
if self.removed_field_path:
|
if self.removed_field_path:
|
||||||
self.logger.info(f"必填字段缺失测试的目标字段 (请求体): '{'.'.join(map(str, self.removed_field_path))}'")
|
self.logger.info(f"必填字段缺失测试的目标字段 (请求体): '{'.'.join(map(str, self.removed_field_path))}'")
|
||||||
self.field_to_remove_details = {
|
|
||||||
"path": self.removed_field_path,
|
|
||||||
# ... existing code ...
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
self.logger.info('在请求体 schema 中未找到可用于测试 "必填字段缺失" 的字段。')
|
self.logger.info('在请求体 schema 中未找到可用于测试 "必填字段缺失" 的字段。')
|
||||||
else:
|
else:
|
||||||
self.logger.info('此端点规范中未定义请求体 schema。')
|
self.logger.info('此端点规范中未定义或找到请求体 schema。')
|
||||||
|
|
||||||
def generate_query_params(self, current_query_params: Dict[str, Any]) -> Dict[str, Any]:
|
def generate_query_params(self, current_query_params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
self.logger.debug(f"{self.id} is focused on request body, generate_query_params will not modify query parameters.")
|
self.logger.debug(f"{self.id} is focused on request body, generate_query_params will not modify query parameters.")
|
||||||
@ -130,118 +70,74 @@ class MissingRequiredFieldBodyCase(BaseAPITestCase):
|
|||||||
|
|
||||||
def generate_request_body(self, current_body: Optional[Any]) -> Optional[Any]:
|
def generate_request_body(self, current_body: Optional[Any]) -> Optional[Any]:
|
||||||
if not self.removed_field_path:
|
if not self.removed_field_path:
|
||||||
self.logger.debug("No field path identified for removal in request body.")
|
self.logger.debug("No field path identified for removal in request body. Returning original body.")
|
||||||
return current_body
|
return current_body
|
||||||
|
|
||||||
if current_body is None:
|
modified_body, self.original_value_at_path, success = schema_utils.util_remove_value_at_path(
|
||||||
self.logger.debug("current_body is None. Orchestrator should ideally provide a base body. Attempting to build minimal structure for removal.")
|
data_container=current_body,
|
||||||
new_body = {}
|
path=self.removed_field_path
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.logger.info(f"为进行必填字段缺失测试,已通过工具方法从请求体中移除字段路径 '{'.'.join(map(str, self.removed_field_path))}'。")
|
||||||
|
return modified_body
|
||||||
else:
|
else:
|
||||||
new_body = copy.deepcopy(current_body)
|
self.logger.error(f"使用工具方法移除请求体字段路径 '{'.'.join(map(str, self.removed_field_path))}' 失败。将返回原始请求体。")
|
||||||
|
# Restore original_value_at_path to None since removal failed
|
||||||
temp_obj_ref = new_body
|
self.original_value_at_path = None
|
||||||
|
|
||||||
try:
|
|
||||||
for i, key_or_index in enumerate(self.removed_field_path):
|
|
||||||
is_last_element = (i == len(self.removed_field_path) - 1)
|
|
||||||
|
|
||||||
if is_last_element:
|
|
||||||
if isinstance(key_or_index, str): # Key for a dictionary (field name)
|
|
||||||
if isinstance(temp_obj_ref, dict) and key_or_index in temp_obj_ref:
|
|
||||||
original_value = temp_obj_ref.pop(key_or_index)
|
|
||||||
self.logger.info(f"为进行必填字段缺失测试,已从请求体中移除字段路径 '{'.'.join(map(str,self.removed_field_path))}' (原值: '{original_value}')。")
|
|
||||||
return new_body
|
|
||||||
elif isinstance(temp_obj_ref, dict): # Key not in dict, but it's a dict
|
|
||||||
self.logger.warning(f"计划移除的请求体字段路径的最后一部分 '{key_or_index}' (string key) 在对象中未找到,但该对象是字典。可能该字段本就是可选的或不存在于提供的current_body。路径: {'.'.join(map(str,self.removed_field_path))}")
|
|
||||||
return new_body
|
|
||||||
else: # temp_obj_ref is not a dict
|
|
||||||
self.logger.warning(f"计划移除的请求体字段路径的最后一部分 '{key_or_index}' (string key) 期望父级是字典,但找到 {type(temp_obj_ref)}。路径: {'.'.join(map(str,self.removed_field_path))}")
|
|
||||||
return current_body
|
|
||||||
else: # Last element of path is an index - this should not happen as we remove a *field name*
|
|
||||||
self.logger.error(f"路径的最后一部分 '{key_or_index}' 预期为字符串字段名,但类型为 {type(key_or_index)}. Path: {'.'.join(map(str,self.removed_field_path))}")
|
|
||||||
return current_body
|
|
||||||
else: # Not the last element, so we are traversing or building the structure
|
|
||||||
next_key_or_index = self.removed_field_path[i+1]
|
|
||||||
|
|
||||||
if isinstance(key_or_index, str): # Current path part is a dictionary key
|
|
||||||
if not isinstance(temp_obj_ref, dict):
|
|
||||||
self.logger.warning(f"路径期望字典,但在 '{key_or_index}' (父级)处找到 {type(temp_obj_ref)}. Path: {'.'.join(map(str,self.removed_field_path))}. 如果current_body为None,则尝试创建字典。")
|
|
||||||
if temp_obj_ref is new_body and not new_body :
|
|
||||||
temp_obj_ref = {}
|
|
||||||
else:
|
|
||||||
return current_body
|
|
||||||
|
|
||||||
if isinstance(next_key_or_index, int):
|
|
||||||
if key_or_index not in temp_obj_ref or not isinstance(temp_obj_ref.get(key_or_index), list):
|
|
||||||
self.logger.debug(f"路径 '{key_or_index}' 需要是列表 (为索引 {next_key_or_index} 做准备),但未找到或类型不符。将创建空列表。")
|
|
||||||
temp_obj_ref[key_or_index] = []
|
|
||||||
temp_obj_ref = temp_obj_ref[key_or_index]
|
|
||||||
else:
|
|
||||||
if key_or_index not in temp_obj_ref or not isinstance(temp_obj_ref.get(key_or_index), dict):
|
|
||||||
self.logger.debug(f"路径 '{key_or_index}' 需要是字典 (为键 '{next_key_or_index}' 做准备),但未找到或类型不符。将创建空字典。")
|
|
||||||
temp_obj_ref[key_or_index] = {}
|
|
||||||
temp_obj_ref = temp_obj_ref[key_or_index]
|
|
||||||
|
|
||||||
elif isinstance(key_or_index, int):
|
|
||||||
if not isinstance(temp_obj_ref, list):
|
|
||||||
self.logger.error(f"路径期望列表以应用索引 '{key_or_index}',但找到 {type(temp_obj_ref)}. Path: {'.'.join(map(str,self.removed_field_path))}")
|
|
||||||
return current_body
|
|
||||||
|
|
||||||
while len(temp_obj_ref) <= key_or_index:
|
|
||||||
self.logger.debug(f"数组在索引 {key_or_index} 处需要元素,将添加空字典作为占位符(因为后续预期是字段名)。")
|
|
||||||
temp_obj_ref.append({})
|
|
||||||
|
|
||||||
if isinstance(next_key_or_index, str):
|
|
||||||
if not isinstance(temp_obj_ref[key_or_index], dict):
|
|
||||||
self.logger.debug(f"数组项 at index {key_or_index} 需要是字典 (为键 '{next_key_or_index}' 做准备)。如果它是其他类型,将被替换为空字典。")
|
|
||||||
temp_obj_ref[key_or_index] = {}
|
|
||||||
|
|
||||||
temp_obj_ref = temp_obj_ref[key_or_index]
|
|
||||||
|
|
||||||
else:
|
|
||||||
self.logger.error(f"路径部分 '{key_or_index}' 类型未知 ({type(key_or_index)}). Path: {'.'.join(map(str,self.removed_field_path))}")
|
|
||||||
return current_body
|
|
||||||
except Exception as e: # Ensuring the try has an except
|
|
||||||
self.logger.error(f"在准备移除字段路径 '{'.'.join(map(str,self.removed_field_path))}' 时发生错误: {e}", exc_info=True)
|
|
||||||
return current_body
|
return current_body
|
||||||
|
|
||||||
self.logger.error(f"generate_request_body 未能在循环内按预期返回。路径: {'.'.join(map(str,self.removed_field_path))}")
|
|
||||||
return current_body
|
|
||||||
|
|
||||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||||
results = []
|
results = []
|
||||||
if not self.removed_field_path:
|
if not self.removed_field_path:
|
||||||
|
# This case should ideally be caught by _try_find_removable_body_field and the test case might be skipped
|
||||||
|
# by the orchestrator if it has a mechanism to check if a test case is applicable/configurable.
|
||||||
|
# For now, the test case itself handles this.
|
||||||
results.append(self.passed("跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。"))
|
results.append(self.passed("跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。"))
|
||||||
self.logger.info("由于未识别到可移除的必填请求体字段,跳过此测试用例。")
|
self.logger.info("由于未识别到可移除的必填请求体字段,跳过此测试用例的验证。")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
# If original_value_at_path is None AND removed_field_path is set, it might mean the removal operation
|
||||||
|
# in generate_request_body failed or the field wasn't in the provided current_body.
|
||||||
|
# This check can make the test more robust.
|
||||||
|
if self.original_value_at_path is None and self.removed_field_path:
|
||||||
|
# Check if the field was simply not present in the input `current_body` to `generate_request_body`
|
||||||
|
# This logic is tricky because `_util_remove_value_at_path` might return success=False if path DNE.
|
||||||
|
# For now, we rely on the success flag from `_util_remove_value_at_path`.
|
||||||
|
# If generate_request_body returned original_body due to failure, this validation might be misleading.
|
||||||
|
# The logger in generate_request_body should indicate the failure.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
status_code = response_context.status_code
|
status_code = response_context.status_code
|
||||||
json_content = response_context.json_content
|
json_content = response_context.json_content
|
||||||
expected_status_codes = [400, 422]
|
expected_status_codes = [400, 422] # As per many API guidelines for client errors
|
||||||
specific_error_code_from_appendix_b = "4003"
|
specific_error_code_from_appendix_b = "4003" # Or a similar code indicating missing required field
|
||||||
removed_field_str = '.'.join(map(str, self.removed_field_path))
|
|
||||||
|
|
||||||
|
removed_field_str = '.'.join(map(str, self.removed_field_path))
|
||||||
msg_prefix = f"当移除必填请求体字段 '{removed_field_str}' 时,"
|
msg_prefix = f"当移除必填请求体字段 '{removed_field_str}' 时,"
|
||||||
|
|
||||||
if status_code in expected_status_codes:
|
if status_code in expected_status_codes:
|
||||||
status_msg = f"{msg_prefix}API响应了预期的错误状态码 {status_code}。"
|
status_msg = f"{msg_prefix}API响应了预期的错误状态码 {status_code}。"
|
||||||
|
# Check for specific error code in response body if available
|
||||||
if json_content and isinstance(json_content, dict) and str(json_content.get("code")) == specific_error_code_from_appendix_b:
|
if json_content and isinstance(json_content, dict) and str(json_content.get("code")) == specific_error_code_from_appendix_b:
|
||||||
results.append(self.passed(f"{status_msg} 且响应体中包含特定的错误码 '{specific_error_code_from_appendix_b}'。"))
|
results.append(self.passed(f"{status_msg} 且响应体中包含特定的错误码 '{specific_error_code_from_appendix_b}'。"))
|
||||||
self.logger.info(f"正确接收到状态码 {status_code} 和错误码 '{specific_error_code_from_appendix_b}'。")
|
self.logger.info(f"正确接收到状态码 {status_code} 和错误码 '{specific_error_code_from_appendix_b}' (移除字段: body.{removed_field_str})。")
|
||||||
elif json_content and isinstance(json_content, dict) and "code" in json_content:
|
elif json_content and isinstance(json_content, dict) and "code" in json_content:
|
||||||
|
# Still pass because the HTTP status code is correct, but log a warning about the specific code
|
||||||
results.append(ValidationResult(passed=True,
|
results.append(ValidationResult(passed=True,
|
||||||
message=f"{status_msg} 响应体中的错误码为 '{json_content.get('code')}' (期望或类似 '{specific_error_code_from_appendix_b}')。",
|
message=f"{status_msg} 响应体中的错误码为 '{json_content.get('code')}' (期望或类似 '{specific_error_code_from_appendix_b}')。",
|
||||||
details=json_content
|
details={"expected_code_detail": specific_error_code_from_appendix_b, "response_body": json_content}
|
||||||
))
|
))
|
||||||
self.logger.warning(f"接收到状态码 {status_code},但错误码是 '{json_content.get('code')}' 而不是期望的 '{specific_error_code_from_appendix_b}'。此结果仍标记为通过,因状态码正确。")
|
self.logger.warning(f"接收到状态码 {status_code},但内部错误码是 '{json_content.get('code')}' 而不是期望的 '{specific_error_code_from_appendix_b}' (移除字段: body.{removed_field_str})。此结果仍标记为通过,因状态码正确。")
|
||||||
else:
|
else:
|
||||||
results.append(self.passed(f"{status_msg} 但响应体中未找到特定的错误码字段或响应体结构不符合预期。"))
|
results.append(self.passed(f"{status_msg} 但响应体中未找到特定的错误码字段 ('code') 或响应体结构不符合预期。"))
|
||||||
self.logger.info(f"正确接收到状态码 {status_code},但在响应体中未找到错误码字段或预期结构。")
|
self.logger.info(f"正确接收到状态码 {status_code},但在响应体中未找到错误码字段或预期结构 (移除字段: body.{removed_field_str})。")
|
||||||
else:
|
else:
|
||||||
results.append(self.failed(
|
results.append(self.failed(
|
||||||
message=f"{msg_prefix}期望API返回状态码 {expected_status_codes} 中的一个,但实际收到 {status_code}。",
|
message=f"{msg_prefix}期望API返回状态码 {expected_status_codes} 中的一个,但实际收到 {status_code}。",
|
||||||
details={"status_code": status_code, "response_body": json_content, "removed_field": f"body.{removed_field_str}"}
|
details={"status_code": status_code, "response_body": json_content, "removed_field": f"body.{removed_field_str}"}
|
||||||
))
|
))
|
||||||
self.logger.warning(f"必填请求体字段缺失测试失败:期望状态码 {expected_status_codes},实际为 {status_code}。移除的字段:'body.{removed_field_str}'")
|
self.logger.warning(f"必填请求体字段缺失测试失败:期望状态码 {expected_status_codes},实际为 {status_code} (移除字段: body.{removed_field_str})。")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
Binary file not shown.
@ -14,6 +14,7 @@ import datetime
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from dataclasses import asdict as dataclass_asdict, is_dataclass # New import
|
from dataclasses import asdict as dataclass_asdict, is_dataclass # New import
|
||||||
|
import copy
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, create_model
|
from pydantic import BaseModel, Field, create_model
|
||||||
from pydantic.networks import EmailStr
|
from pydantic.networks import EmailStr
|
||||||
@ -23,6 +24,9 @@ from .api_caller.caller import APICaller, APIRequest, APIResponse
|
|||||||
from .json_schema_validator.validator import JSONSchemaValidator
|
from .json_schema_validator.validator import JSONSchemaValidator
|
||||||
from .test_framework_core import ValidationResult, TestSeverity, APIRequestContext, APIResponseContext, BaseAPITestCase
|
from .test_framework_core import ValidationResult, TestSeverity, APIRequestContext, APIResponseContext, BaseAPITestCase
|
||||||
from .test_case_registry import TestCaseRegistry
|
from .test_case_registry import TestCaseRegistry
|
||||||
|
# 尝试导入 utils.schema_utils
|
||||||
|
from .utils import schema_utils
|
||||||
|
|
||||||
# 尝试导入 LLMService,如果失败则允许,因为 LLM 功能是可选的
|
# 尝试导入 LLMService,如果失败则允许,因为 LLM 功能是可选的
|
||||||
try:
|
try:
|
||||||
from .llm_utils.llm_service import LLMService
|
from .llm_utils.llm_service import LLMService
|
||||||
@ -786,6 +790,53 @@ class APITestOrchestrator:
|
|||||||
)
|
)
|
||||||
global_spec_dict = {}
|
global_spec_dict = {}
|
||||||
|
|
||||||
|
# --- BEGIN $ref RESOLUTION ---
|
||||||
|
if global_spec_dict: # Only attempt resolution if we have the full spec for lookups
|
||||||
|
self.logger.debug(f"global_spec_dict keys for $ref resolution: {list(global_spec_dict.keys())}") # <--- 添加的日志行
|
||||||
|
self.logger.debug(f"开始为 endpoint_spec_dict (来自 {type(endpoint_spec)}) 中的 schemas 进行 $ref 解析...")
|
||||||
|
|
||||||
|
# 1. 解析 requestBody schema
|
||||||
|
if 'requestBody' in endpoint_spec_dict and isinstance(endpoint_spec_dict['requestBody'], dict):
|
||||||
|
if 'content' in endpoint_spec_dict['requestBody'] and isinstance(endpoint_spec_dict['requestBody']['content'], dict):
|
||||||
|
for media_type, media_type_obj in endpoint_spec_dict['requestBody']['content'].items():
|
||||||
|
if isinstance(media_type_obj, dict) and 'schema' in media_type_obj:
|
||||||
|
self.logger.debug(f"正在解析 requestBody content '{media_type}' 的 schema...")
|
||||||
|
original_schema = media_type_obj['schema']
|
||||||
|
media_type_obj['schema'] = schema_utils.resolve_json_schema_references(original_schema, global_spec_dict)
|
||||||
|
# self.logger.debug(f"解析后的 requestBody content '{media_type}' schema: {json.dumps(media_type_obj['schema'], indent=2)}")
|
||||||
|
|
||||||
|
|
||||||
|
# 2. 解析 parameters schemas (OpenAPI 2.0 'in: body' parameter or OpenAPI 3.0 parameters)
|
||||||
|
if 'parameters' in endpoint_spec_dict and isinstance(endpoint_spec_dict['parameters'], list):
|
||||||
|
for i, param in enumerate(endpoint_spec_dict['parameters']):
|
||||||
|
if isinstance(param, dict) and 'schema' in param:
|
||||||
|
self.logger.debug(f"正在解析 parameters[{i}] ('{param.get('name', 'N/A')}') 的 schema...")
|
||||||
|
original_param_schema = param['schema']
|
||||||
|
param['schema'] = schema_utils.resolve_json_schema_references(original_param_schema, global_spec_dict)
|
||||||
|
# self.logger.debug(f"解析后的 parameters[{i}] schema: {json.dumps(param['schema'], indent=2)}")
|
||||||
|
|
||||||
|
# 3. 解析 responses schemas
|
||||||
|
if 'responses' in endpoint_spec_dict and isinstance(endpoint_spec_dict['responses'], dict):
|
||||||
|
for status_code, response_obj in endpoint_spec_dict['responses'].items():
|
||||||
|
if isinstance(response_obj, dict) and 'content' in response_obj and isinstance(response_obj['content'], dict):
|
||||||
|
for media_type, media_type_obj in response_obj['content'].items():
|
||||||
|
if isinstance(media_type_obj, dict) and 'schema' in media_type_obj:
|
||||||
|
self.logger.debug(f"正在解析 responses '{status_code}' content '{media_type}' 的 schema...")
|
||||||
|
original_resp_schema = media_type_obj['schema']
|
||||||
|
media_type_obj['schema'] = schema_utils.resolve_json_schema_references(original_resp_schema, global_spec_dict)
|
||||||
|
# self.logger.debug(f"解析后的 response '{status_code}' content '{media_type}' schema: {json.dumps(media_type_obj['schema'], indent=2)}")
|
||||||
|
# OpenAPI 2.0 response schema directly under response object
|
||||||
|
elif isinstance(response_obj, dict) and 'schema' in response_obj:
|
||||||
|
self.logger.debug(f"正在解析 responses '{status_code}' 的 schema (OpenAPI 2.0 style)...")
|
||||||
|
original_resp_schema = response_obj['schema']
|
||||||
|
response_obj['schema'] = schema_utils.resolve_json_schema_references(original_resp_schema, global_spec_dict)
|
||||||
|
|
||||||
|
|
||||||
|
self.logger.info(f"Endpoint spec (来自 {type(endpoint_spec)}) 中的 schemas $ref 解析完成。")
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"global_spec_dict 为空,跳过 endpoint_spec_dict (来自 {type(endpoint_spec)}) 的 $ref 解析。")
|
||||||
|
# --- END $ref RESOLUTION ---
|
||||||
|
|
||||||
# 将 global_spec_dict 注入到 endpoint_spec_dict 中,供可能的内部解析使用 (如果 to_dict 未包含它)
|
# 将 global_spec_dict 注入到 endpoint_spec_dict 中,供可能的内部解析使用 (如果 to_dict 未包含它)
|
||||||
if '_global_api_spec_for_resolution' not in endpoint_spec_dict and global_spec_dict:
|
if '_global_api_spec_for_resolution' not in endpoint_spec_dict and global_spec_dict:
|
||||||
endpoint_spec_dict['_global_api_spec_for_resolution'] = global_spec_dict
|
endpoint_spec_dict['_global_api_spec_for_resolution'] = global_spec_dict
|
||||||
@ -1503,4 +1554,249 @@ class APITestOrchestrator:
|
|||||||
url = self.base_url + formatted_path
|
url = self.base_url + formatted_path
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
def _resolve_json_schema_references(self, schema_to_resolve: Any, full_api_spec: Dict[str, Any], max_depth=10, current_depth=0) -> Any:
|
||||||
|
"""
|
||||||
|
递归解析JSON Schema中的$ref引用。
|
||||||
|
Args:
|
||||||
|
schema_to_resolve: 当前需要解析的schema部分 (可以是字典、列表或基本类型)。
|
||||||
|
full_api_spec: 完整的API规范字典,用于查找$ref路径。
|
||||||
|
max_depth: 最大递归深度,防止无限循环。
|
||||||
|
current_depth: 当前递归深度。
|
||||||
|
Returns:
|
||||||
|
解析了$ref的schema部分。
|
||||||
|
"""
|
||||||
|
if current_depth > max_depth:
|
||||||
|
self.logger.warning(f"达到最大$ref解析深度 ({max_depth}),可能存在循环引用。停止进一步解析。")
|
||||||
|
return schema_to_resolve
|
||||||
|
|
||||||
|
if isinstance(schema_to_resolve, dict):
|
||||||
|
if "$ref" in schema_to_resolve:
|
||||||
|
ref_path = schema_to_resolve["$ref"]
|
||||||
|
if not isinstance(ref_path, str) or not ref_path.startswith("#/"):
|
||||||
|
self.logger.warning(f"不支持的$ref格式或外部引用: {ref_path}。仅支持本地引用 (e.g., #/components/schemas/MyModel)。")
|
||||||
|
return schema_to_resolve # 或者根据需要返回错误/None
|
||||||
|
|
||||||
|
path_parts = ref_path[2:].split('/') # Remove '#/' and split
|
||||||
|
resolved_component = full_api_spec
|
||||||
|
try:
|
||||||
|
for part in path_parts:
|
||||||
|
if isinstance(resolved_component, list): # Handle paths like #/components/parameters/0
|
||||||
|
part = int(part)
|
||||||
|
resolved_component = resolved_component[part]
|
||||||
|
|
||||||
|
# 递归解析引用过来的组件,以处理嵌套的$ref
|
||||||
|
# 同时传递原始$ref携带的其他属性(如description, nullable等可以覆盖引用的内容)
|
||||||
|
# See: https://json-schema.org/understanding-json-schema/structuring.html#merging
|
||||||
|
# For simplicity here, we prioritize the resolved component, but a more robust solution
|
||||||
|
# would merge properties from the $ref object itself with the resolved one.
|
||||||
|
|
||||||
|
# Create a copy of the resolved component to avoid modifying the original spec
|
||||||
|
# and to allow merging of sibling keywords if any.
|
||||||
|
component_copy = copy.deepcopy(resolved_component)
|
||||||
|
|
||||||
|
# Merge sibling keywords from the $ref object into the resolved component.
|
||||||
|
# Keywords in the $ref object override those in the referenced schema.
|
||||||
|
merged_schema = component_copy
|
||||||
|
if isinstance(component_copy, dict): # Ensure it's a dict before trying to update
|
||||||
|
for key, value in schema_to_resolve.items():
|
||||||
|
if key != "$ref":
|
||||||
|
merged_schema[key] = value # Override or add
|
||||||
|
|
||||||
|
self.logger.debug(f"成功解析并合并 $ref: '{ref_path}'。正在递归解析其内容。")
|
||||||
|
return self._resolve_json_schema_references(merged_schema, full_api_spec, max_depth, current_depth + 1)
|
||||||
|
|
||||||
|
except (KeyError, IndexError, TypeError, ValueError) as e:
|
||||||
|
self.logger.error(f"解析$ref '{ref_path}' 失败: {e}.路径部分: {path_parts}. 当前组件类型: {type(resolved_component)}", exc_info=True)
|
||||||
|
return schema_to_resolve # 返回原始的$ref对象或错误指示
|
||||||
|
|
||||||
|
# 如果不是$ref,则递归处理字典中的每个值
|
||||||
|
# 使用copy避免在迭代时修改字典
|
||||||
|
resolved_dict = {}
|
||||||
|
for key, value in schema_to_resolve.items():
|
||||||
|
resolved_dict[key] = self._resolve_json_schema_references(value, full_api_spec, max_depth, current_depth + 1)
|
||||||
|
return resolved_dict
|
||||||
|
|
||||||
|
elif isinstance(schema_to_resolve, list):
|
||||||
|
# 递归处理列表中的每个元素
|
||||||
|
return [self._resolve_json_schema_references(item, full_api_spec, max_depth, current_depth + 1) for item in schema_to_resolve]
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 基本类型 (string, number, boolean, null) 不需要解析
|
||||||
|
return schema_to_resolve
|
||||||
|
|
||||||
|
def _util_find_removable_field_path_recursive(self, current_schema: Dict[str, Any], current_path: List[str], full_api_spec_for_refs: Dict[str, Any]) -> Optional[List[Union[str, int]]]:
|
||||||
|
"""
|
||||||
|
(框架辅助方法) 递归查找第一个可移除的必填字段的路径。
|
||||||
|
此方法现在需要 full_api_spec_for_refs 以便在需要时解析 $ref。
|
||||||
|
"""
|
||||||
|
# 首先解析当前 schema,以防它是 $ref
|
||||||
|
resolved_schema = self._resolve_json_schema_references(current_schema, full_api_spec_for_refs)
|
||||||
|
|
||||||
|
if not isinstance(resolved_schema, dict) or resolved_schema.get("type") != "object":
|
||||||
|
return None
|
||||||
|
|
||||||
|
required_fields_at_current_level = resolved_schema.get("required", [])
|
||||||
|
properties = resolved_schema.get("properties", {})
|
||||||
|
self.logger.debug(f"[Util] 递归查找路径: {current_path}, 当前层级必填字段: {required_fields_at_current_level}, 属性: {list(properties.keys())}")
|
||||||
|
|
||||||
|
# 策略1: 查找当前层级直接声明的必填字段
|
||||||
|
if required_fields_at_current_level and properties:
|
||||||
|
for field_name in required_fields_at_current_level:
|
||||||
|
if field_name in properties:
|
||||||
|
self.logger.info(f"[Util] 策略1: 在路径 {'.'.join(map(str,current_path)) if current_path else 'root'} 找到可直接移除的必填字段: '{field_name}'")
|
||||||
|
return current_path + [field_name]
|
||||||
|
|
||||||
|
# 策略2: 查找数组属性,看其内部item是否有必填字段
|
||||||
|
if properties:
|
||||||
|
for prop_name, prop_schema_orig in properties.items():
|
||||||
|
prop_schema = self._resolve_json_schema_references(prop_schema_orig, full_api_spec_for_refs)
|
||||||
|
if isinstance(prop_schema, dict) and prop_schema.get("type") == "array":
|
||||||
|
items_schema_orig = prop_schema.get("items")
|
||||||
|
if isinstance(items_schema_orig, dict):
|
||||||
|
items_schema = self._resolve_json_schema_references(items_schema_orig, full_api_spec_for_refs)
|
||||||
|
if isinstance(items_schema, dict) and items_schema.get("type") == "object":
|
||||||
|
item_required_fields = items_schema.get("required", [])
|
||||||
|
item_properties = items_schema.get("properties", {})
|
||||||
|
if item_required_fields and item_properties:
|
||||||
|
first_required_field_in_item = next((rf for rf in item_required_fields if rf in item_properties), None)
|
||||||
|
if first_required_field_in_item:
|
||||||
|
self.logger.info(f"[Util] 策略2: 在数组属性 '{prop_name}' (路径 {'.'.join(map(str,current_path)) if current_path else 'root'}) 的元素内找到必填字段: '{first_required_field_in_item}'. 路径: {current_path + [prop_name, 0, first_required_field_in_item]}")
|
||||||
|
return current_path + [prop_name, 0, first_required_field_in_item]
|
||||||
|
|
||||||
|
# 策略3: 递归到子对象中查找(可选,但对于通用工具可能有用)
|
||||||
|
# 注意:这可能会找到非顶层必填对象内部的必填字段。
|
||||||
|
# if properties:
|
||||||
|
# for prop_name, prop_schema_orig_for_recurse in properties.items():
|
||||||
|
# prop_schema_for_recurse = self._resolve_json_schema_references(prop_schema_orig_for_recurse, full_api_spec_for_refs)
|
||||||
|
# if isinstance(prop_schema_for_recurse, dict) and prop_schema_for_recurse.get("type") == "object":
|
||||||
|
# # Avoid re-checking fields already covered by strategy 1 if they were required at this level
|
||||||
|
# # if prop_name not in required_fields_at_current_level:
|
||||||
|
# self.logger.debug(f"[Util] 策略3: 尝试递归进入对象属性 '{prop_name}' (路径 {current_path})")
|
||||||
|
# found_path_deeper = self._util_find_removable_field_path_recursive(prop_schema_for_recurse, current_path + [prop_name], full_api_spec_for_refs)
|
||||||
|
# if found_path_deeper:
|
||||||
|
# return found_path_deeper
|
||||||
|
|
||||||
|
self.logger.debug(f"[Util] 在路径 {'.'.join(map(str,current_path)) if current_path else 'root'} 未通过任何策略找到可移除的必填字段。")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _util_remove_value_at_path(self, data_container: Any, path: List[Union[str, int]]) -> Tuple[Any, Any, bool]:
|
||||||
|
"""
|
||||||
|
(框架辅助方法) 从嵌套的字典/列表中移除指定路径的值。
|
||||||
|
返回 (修改后的容器, 被移除的值, 是否成功)。
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
self.logger.error("[Util] _util_remove_value_at_path: 路径不能为空。")
|
||||||
|
return data_container, None, False
|
||||||
|
|
||||||
|
# 深拷贝以避免修改原始数据,除非调用者期望如此
|
||||||
|
# 如果 data_container 是 None 且路径非空,则尝试构建最小结构
|
||||||
|
if data_container is None:
|
||||||
|
if isinstance(path[0], str): # 路径以字段名开始,期望字典
|
||||||
|
container_copy = {}
|
||||||
|
elif isinstance(path[0], int): # 路径以索引开始,期望列表
|
||||||
|
container_copy = []
|
||||||
|
else:
|
||||||
|
self.logger.error(f"[Util] _util_remove_value_at_path: 路径的第一个元素 '{path[0]}' 类型未知。")
|
||||||
|
return data_container, None, False
|
||||||
|
else:
|
||||||
|
container_copy = copy.deepcopy(data_container)
|
||||||
|
|
||||||
|
current_level = container_copy
|
||||||
|
original_value = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
for i, key_or_index in enumerate(path):
|
||||||
|
is_last_element = (i == len(path) - 1)
|
||||||
|
|
||||||
|
if is_last_element:
|
||||||
|
if isinstance(key_or_index, str): # Key for a dictionary (field name)
|
||||||
|
if isinstance(current_level, dict) and key_or_index in current_level:
|
||||||
|
original_value = current_level.pop(key_or_index)
|
||||||
|
self.logger.info(f"[Util] 从路径 '{'.'.join(map(str,path))}' 成功移除字段 '{key_or_index}' (原值: '{original_value}')。")
|
||||||
|
return container_copy, original_value, True
|
||||||
|
elif isinstance(current_level, dict):
|
||||||
|
self.logger.warning(f"[Util] 路径的最后一部分 '{key_or_index}' (string key) 在对象中未找到。路径: {'.'.join(map(str,path))}")
|
||||||
|
return container_copy, None, False # 字段不存在,但结构符合
|
||||||
|
else:
|
||||||
|
self.logger.error(f"[Util] 路径的最后一部分 '{key_or_index}' (string key) 期望父级是字典,但找到 {type(current_level)}。路径: {'.'.join(map(str,path))}")
|
||||||
|
return data_container, None, False # 结构不符,返回原始数据
|
||||||
|
else: # Last element of path is an index - this indicates removing an item from a list
|
||||||
|
if isinstance(current_level, list) and isinstance(key_or_index, int) and 0 <= key_or_index < len(current_level):
|
||||||
|
original_value = current_level.pop(key_or_index)
|
||||||
|
self.logger.info(f"[Util] 从路径 '{'.'.join(map(str,path))}' 成功移除索引 '{key_or_index}' 的元素 (原值: '{original_value}')。")
|
||||||
|
return container_copy, original_value, True
|
||||||
|
elif isinstance(current_level, list):
|
||||||
|
self.logger.warning(f"[Util] 路径的最后一部分索引 '{key_or_index}' 超出列表范围或类型不符。列表长度: {len(current_level)}. 路径: {'.'.join(map(str,path))}")
|
||||||
|
return container_copy, None, False # 索引无效,但结构符合
|
||||||
|
else:
|
||||||
|
self.logger.error(f"[Util] 路径的最后一部分 '{key_or_index}' 期望父级是列表,但找到 {type(current_level)}。路径: {'.'.join(map(str,path))}")
|
||||||
|
return data_container, None, False # 结构不符
|
||||||
|
|
||||||
|
else: # Not the last element, so we are traversing or building the structure
|
||||||
|
next_key_or_index = path[i+1]
|
||||||
|
|
||||||
|
if isinstance(key_or_index, str): # Current path part is a dictionary key
|
||||||
|
if not isinstance(current_level, dict):
|
||||||
|
self.logger.debug(f"[Util] 路径期望字典,但在 '{key_or_index}' (父级)处找到 {type(current_level)}. 将创建空字典。")
|
||||||
|
# This should only happen if current_level was initially part of a None container and we're building it up.
|
||||||
|
# If current_level is not a dict and it's not the root being built, it's an error.
|
||||||
|
# For robust path creation from None:
|
||||||
|
if current_level is container_copy and not container_copy : # building from scratch
|
||||||
|
current_level = {} # This change needs to be reflected in container_copy if this is the root
|
||||||
|
if i == 0: container_copy = current_level
|
||||||
|
else: # This case is complex: how to link back if not root?
|
||||||
|
self.logger.error(f"[Util] 无法在非根级别从非字典创建路径。")
|
||||||
|
return data_container, None, False
|
||||||
|
else: # Path expects dict, but found something else not at root.
|
||||||
|
self.logger.error(f"[Util] 路径期望字典,但在 '{key_or_index}' 处找到 {type(current_level)}。")
|
||||||
|
return data_container, None, False
|
||||||
|
|
||||||
|
# Ensure the next level exists and is of the correct type
|
||||||
|
if isinstance(next_key_or_index, int): # Next is an array index
|
||||||
|
if key_or_index not in current_level or not isinstance(current_level.get(key_or_index), list):
|
||||||
|
self.logger.debug(f"[Util] 路径 '{key_or_index}' 下需要列表 (为索引 {next_key_or_index} 做准备),将创建空列表。")
|
||||||
|
current_level[key_or_index] = []
|
||||||
|
current_level = current_level[key_or_index]
|
||||||
|
else: # Next is a dictionary key
|
||||||
|
if key_or_index not in current_level or not isinstance(current_level.get(key_or_index), dict):
|
||||||
|
self.logger.debug(f"[Util] 路径 '{key_or_index}' 下需要字典 (为键 '{next_key_or_index}' 做准备),将创建空字典。")
|
||||||
|
current_level[key_or_index] = {}
|
||||||
|
current_level = current_level[key_or_index]
|
||||||
|
|
||||||
|
elif isinstance(key_or_index, int): # Current path part is an array index
|
||||||
|
if not isinstance(current_level, list):
|
||||||
|
self.logger.error(f"[Util] 路径期望列表以应用索引 '{key_or_index}',但找到 {type(current_level)}。")
|
||||||
|
return data_container, None, False
|
||||||
|
|
||||||
|
# Ensure the list is long enough, fill with dict/list based on next path element
|
||||||
|
while len(current_level) <= key_or_index:
|
||||||
|
if isinstance(next_key_or_index, str): # Next is a dict key
|
||||||
|
self.logger.debug(f"[Util] 数组在索引 {key_or_index} 处需要元素,将添加空字典。")
|
||||||
|
current_level.append({})
|
||||||
|
else: # Next is an array index
|
||||||
|
self.logger.debug(f"[Util] 数组在索引 {key_or_index} 处需要元素,将添加空列表。")
|
||||||
|
current_level.append([])
|
||||||
|
|
||||||
|
# Ensure the element at index is of the correct type for the next key/index
|
||||||
|
if isinstance(next_key_or_index, str): # Next is a dict key
|
||||||
|
if not isinstance(current_level[key_or_index], dict):
|
||||||
|
self.logger.debug(f"[Util] 数组项 at index {key_or_index} 需要是字典。将被替换。")
|
||||||
|
current_level[key_or_index] = {}
|
||||||
|
elif isinstance(next_key_or_index, int): # Next is an array index
|
||||||
|
if not isinstance(current_level[key_or_index], list):
|
||||||
|
self.logger.debug(f"[Util] 数组项 at index {key_or_index} 需要是列表。将被替换。")
|
||||||
|
current_level[key_or_index] = []
|
||||||
|
|
||||||
|
current_level = current_level[key_or_index]
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.logger.error(f"[Util] 路径部分 '{key_or_index}' 类型未知 ({type(key_or_index)}).")
|
||||||
|
return data_container, None, False
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"[Util] 在准备移除字段路径 '{'.'.join(map(str,path))}' 时发生错误: {e}", exc_info=True)
|
||||||
|
return data_container, None, False
|
||||||
|
|
||||||
|
self.logger.error(f"[Util] _util_remove_value_at_path 未能在循环内按预期返回。路径: {'.'.join(map(str,path))}")
|
||||||
|
return data_container, None, False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
274
ddms_compliance_suite/utils/schema_utils.py
Normal file
274
ddms_compliance_suite/utils/schema_utils.py
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
import logging
|
||||||
|
import copy
|
||||||
|
from typing import Dict, List, Any, Optional, Union, Tuple
|
||||||
|
|
||||||
|
# 获取模块级别的 logger
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def resolve_json_schema_references(
|
||||||
|
schema_to_resolve: Any,
|
||||||
|
full_api_spec: Dict[str, Any],
|
||||||
|
max_depth: int = 10,
|
||||||
|
current_depth: int = 0,
|
||||||
|
discard_refs: bool = True # 新增参数,默认为 True
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
递归解析JSON Schema中的$ref引用。
|
||||||
|
Args:
|
||||||
|
schema_to_resolve: 当前需要解析的schema部分 (可以是字典、列表或基本类型)。
|
||||||
|
full_api_spec: 完整的API规范字典,用于查找$ref路径。
|
||||||
|
max_depth: 最大递归深度,防止无限循环。
|
||||||
|
current_depth: 当前递归深度。
|
||||||
|
discard_refs: 是否在解析前移除 $ref 和 $$ prefixed 键。
|
||||||
|
Returns:
|
||||||
|
解析了$ref的schema部分。
|
||||||
|
"""
|
||||||
|
if current_depth > max_depth:
|
||||||
|
logger.warning(f"达到最大$ref解析深度 ({max_depth}),可能存在循环引用。停止进一步解析。 Schema: {str(schema_to_resolve)[:200]}")
|
||||||
|
return schema_to_resolve
|
||||||
|
|
||||||
|
if isinstance(schema_to_resolve, dict):
|
||||||
|
current_dict_processing = dict(schema_to_resolve) # 操作副本
|
||||||
|
|
||||||
|
if discard_refs:
|
||||||
|
# 模式1: 丢弃 $ref 和 $$ 开头的键, 然后递归处理剩余值
|
||||||
|
ref_value = current_dict_processing.pop("$ref", None)
|
||||||
|
if ref_value is not None:
|
||||||
|
logger.debug(f"因 discard_refs=True,丢弃 '$ref': {ref_value}")
|
||||||
|
|
||||||
|
keys_to_remove = [k for k in current_dict_processing if k.startswith("$$")]
|
||||||
|
for key_to_remove in keys_to_remove:
|
||||||
|
key_val = current_dict_processing.pop(key_to_remove, None)
|
||||||
|
logger.debug(f"因 discard_refs=True,丢弃 '{key_to_remove}': {key_val}")
|
||||||
|
|
||||||
|
# current_dict_processing 已清理完毕,递归处理其值
|
||||||
|
resolved_children = {}
|
||||||
|
for key, value in current_dict_processing.items():
|
||||||
|
resolved_children[key] = resolve_json_schema_references(
|
||||||
|
value, full_api_spec, max_depth, current_depth + 1, discard_refs=discard_refs
|
||||||
|
)
|
||||||
|
return resolved_children
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 模式2: 尝试解析 $ref (如果存在), 然后递归。$$ 开头的键会保留并递归处理。
|
||||||
|
if "$ref" in current_dict_processing:
|
||||||
|
ref_path = current_dict_processing["$ref"]
|
||||||
|
if not isinstance(ref_path, str) or not ref_path.startswith("#/"):
|
||||||
|
logger.warning(f"不支持的$ref格式或外部引用: {ref_path}。在非丢弃模式下,$ref将作为普通键值对处理。")
|
||||||
|
# 继续执行后续的常规递归,$ref 将作为 current_dict_processing 中的一个键
|
||||||
|
else:
|
||||||
|
path_parts = ref_path[2:].split('/')
|
||||||
|
target_component_root = full_api_spec
|
||||||
|
current_target_component = target_component_root
|
||||||
|
valid_path = True
|
||||||
|
try:
|
||||||
|
for part in path_parts:
|
||||||
|
if isinstance(current_target_component, list):
|
||||||
|
try:
|
||||||
|
part_idx = int(part)
|
||||||
|
current_target_component = current_target_component[part_idx]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logger.error(f"路径部分 '{part}' (应为整数索引) 无效或越界于列表。路径: {ref_path}")
|
||||||
|
valid_path = False
|
||||||
|
break
|
||||||
|
elif isinstance(current_target_component, dict):
|
||||||
|
if part not in current_target_component:
|
||||||
|
logger.error(f"路径部分 '{part}' 在对象中未找到。路径: {ref_path}. 可用键: {list(current_target_component.keys())}")
|
||||||
|
valid_path = False
|
||||||
|
break
|
||||||
|
current_target_component = current_target_component[part]
|
||||||
|
else:
|
||||||
|
logger.error(f"尝试在非字典/列表类型 ({type(current_target_component)}) 中访问路径部分 '{part}'。路径: {ref_path}")
|
||||||
|
valid_path = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if valid_path:
|
||||||
|
# $ref 解析成功
|
||||||
|
final_schema_after_ref_resolution = copy.deepcopy(current_target_component)
|
||||||
|
# 如果解析结果是字典,则将原始 $ref 位置的同级键合并(覆盖)进去
|
||||||
|
if isinstance(final_schema_after_ref_resolution, dict):
|
||||||
|
for key, value in current_dict_processing.items():
|
||||||
|
if key != "$ref": # 合并同级键
|
||||||
|
final_schema_after_ref_resolution[key] = value
|
||||||
|
# 如果解析结果不是字典(例如,一个数组或原始类型),则同级键实际上被丢弃,
|
||||||
|
# 因为返回的是 final_schema_after_ref_resolution 本身。这是 $ref 的标准行为之一。
|
||||||
|
|
||||||
|
logger.debug(f"成功解析 $ref: '{ref_path}'。将递归解析其内容(可能已与同级键合并)。")
|
||||||
|
return resolve_json_schema_references(
|
||||||
|
final_schema_after_ref_resolution, full_api_spec, max_depth, current_depth + 1, discard_refs=discard_refs
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解析$ref '{ref_path}' 时发生意外错误: {e}. 将尝试使用同级节点。", exc_info=False)
|
||||||
|
valid_path = False
|
||||||
|
|
||||||
|
# 如果 $ref 解析失败 (valid_path is False 或 try 块中出现异常)
|
||||||
|
if not valid_path:
|
||||||
|
logger.warning(f"$ref '{ref_path}' 解析失败。将移除 $ref 并处理该对象的其余部分。")
|
||||||
|
current_dict_processing.pop("$ref", None)
|
||||||
|
# 继续执行后续的常规递归,此时 current_dict_processing 已移除了失败的 $ref
|
||||||
|
|
||||||
|
# 常规递归 (模式2: 非丢弃模式 / $ref 已处理或移除)
|
||||||
|
resolved_children = {}
|
||||||
|
for key, value in current_dict_processing.items():
|
||||||
|
resolved_children[key] = resolve_json_schema_references(
|
||||||
|
value, full_api_spec, max_depth, current_depth + 1, discard_refs=discard_refs
|
||||||
|
)
|
||||||
|
return resolved_children
|
||||||
|
|
||||||
|
elif isinstance(schema_to_resolve, list):
|
||||||
|
return [resolve_json_schema_references(item, full_api_spec, max_depth, current_depth + 1, discard_refs=discard_refs) for item in schema_to_resolve]
|
||||||
|
|
||||||
|
else: # 原始类型
|
||||||
|
return schema_to_resolve
|
||||||
|
|
||||||
|
def util_find_removable_field_path_recursive(
|
||||||
|
current_schema: Dict[str, Any],
|
||||||
|
current_path: List[Union[str, int]], # Union added here
|
||||||
|
full_api_spec_for_refs: Dict[str, Any],
|
||||||
|
# logger_param: Optional[logging.Logger] = None # Option to pass logger
|
||||||
|
) -> Optional[List[Union[str, int]]]:
|
||||||
|
"""
|
||||||
|
(框架辅助方法) 递归查找第一个可移除的必填字段的路径。
|
||||||
|
"""
|
||||||
|
# effective_logger = logger_param or logger # Use passed logger or module logger
|
||||||
|
resolved_schema = resolve_json_schema_references(current_schema, full_api_spec_for_refs)
|
||||||
|
|
||||||
|
if not isinstance(resolved_schema, dict) or resolved_schema.get("type") != "object":
|
||||||
|
return None
|
||||||
|
|
||||||
|
required_fields_at_current_level = resolved_schema.get("required", [])
|
||||||
|
properties = resolved_schema.get("properties", {})
|
||||||
|
logger.debug(f"[Util] 递归查找路径: {current_path}, 当前层级必填字段: {required_fields_at_current_level}, 属性: {list(properties.keys())}")
|
||||||
|
|
||||||
|
if required_fields_at_current_level and properties:
|
||||||
|
for field_name in required_fields_at_current_level:
|
||||||
|
if field_name in properties:
|
||||||
|
logger.info(f"[Util] 策略1: 在路径 {'.'.join(map(str,current_path)) if current_path else 'root'} 找到可直接移除的必填字段: '{field_name}'")
|
||||||
|
return current_path + [field_name]
|
||||||
|
|
||||||
|
if properties:
|
||||||
|
for prop_name, prop_schema_orig in properties.items():
|
||||||
|
prop_schema = resolve_json_schema_references(prop_schema_orig, full_api_spec_for_refs)
|
||||||
|
if isinstance(prop_schema, dict) and prop_schema.get("type") == "array":
|
||||||
|
items_schema_orig = prop_schema.get("items")
|
||||||
|
if isinstance(items_schema_orig, dict):
|
||||||
|
items_schema = resolve_json_schema_references(items_schema_orig, full_api_spec_for_refs)
|
||||||
|
if isinstance(items_schema, dict) and items_schema.get("type") == "object":
|
||||||
|
item_required_fields = items_schema.get("required", [])
|
||||||
|
item_properties = items_schema.get("properties", {})
|
||||||
|
if item_required_fields and item_properties:
|
||||||
|
first_required_field_in_item = next((rf for rf in item_required_fields if rf in item_properties), None)
|
||||||
|
if first_required_field_in_item:
|
||||||
|
logger.info(f"[Util] 策略2: 在数组属性 '{prop_name}' (路径 {'.'.join(map(str,current_path)) if current_path else 'root'}) 的元素内找到必填字段: '{first_required_field_in_item}'. 路径: {current_path + [prop_name, 0, first_required_field_in_item]}")
|
||||||
|
return current_path + [prop_name, 0, first_required_field_in_item]
|
||||||
|
|
||||||
|
logger.debug(f"[Util] 在路径 {'.'.join(map(str,current_path)) if current_path else 'root'} 未通过任何策略找到可移除的必填字段。")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def util_remove_value_at_path(
|
||||||
|
data_container: Any,
|
||||||
|
path: List[Union[str, int]],
|
||||||
|
# logger_param: Optional[logging.Logger] = None
|
||||||
|
) -> Tuple[Any, Any, bool]:
|
||||||
|
"""
|
||||||
|
(框架辅助方法) 从嵌套的字典/列表中移除指定路径的值。
|
||||||
|
返回 (修改后的容器, 被移除的值, 是否成功)。
|
||||||
|
"""
|
||||||
|
# effective_logger = logger_param or logger
|
||||||
|
if not path:
|
||||||
|
logger.error("[Util] util_remove_value_at_path: 路径不能为空。")
|
||||||
|
return data_container, None, False
|
||||||
|
|
||||||
|
if data_container is None:
|
||||||
|
if isinstance(path[0], str):
|
||||||
|
container_copy = {}
|
||||||
|
elif isinstance(path[0], int):
|
||||||
|
container_copy = []
|
||||||
|
else:
|
||||||
|
logger.error(f"[Util] util_remove_value_at_path: 路径的第一个元素 '{path[0]}' 类型未知。")
|
||||||
|
return data_container, None, False
|
||||||
|
else:
|
||||||
|
container_copy = copy.deepcopy(data_container)
|
||||||
|
|
||||||
|
current_level = container_copy
|
||||||
|
original_value = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
for i, key_or_index in enumerate(path):
|
||||||
|
is_last_element = (i == len(path) - 1)
|
||||||
|
if is_last_element:
|
||||||
|
if isinstance(key_or_index, str):
|
||||||
|
if isinstance(current_level, dict) and key_or_index in current_level:
|
||||||
|
original_value = current_level.pop(key_or_index)
|
||||||
|
logger.info(f"[Util] 从路径 '{'.'.join(map(str,path))}' 成功移除字段 '{key_or_index}' (原值: '{original_value}')。")
|
||||||
|
return container_copy, original_value, True
|
||||||
|
elif isinstance(current_level, dict):
|
||||||
|
logger.warning(f"[Util] 路径的最后一部分 '{key_or_index}' (string key) 在对象中未找到。路径: {'.'.join(map(str,path))}")
|
||||||
|
return container_copy, None, False
|
||||||
|
else:
|
||||||
|
logger.error(f"[Util] 路径的最后一部分 '{key_or_index}' (string key) 期望父级是字典,但找到 {type(current_level)}。路径: {'.'.join(map(str,path))}")
|
||||||
|
return data_container, None, False
|
||||||
|
else:
|
||||||
|
if isinstance(current_level, list) and isinstance(key_or_index, int) and 0 <= key_or_index < len(current_level):
|
||||||
|
original_value = current_level.pop(key_or_index)
|
||||||
|
logger.info(f"[Util] 从路径 '{'.'.join(map(str,path))}' 成功移除索引 '{key_or_index}' 的元素 (原值: '{original_value}')。")
|
||||||
|
return container_copy, original_value, True
|
||||||
|
elif isinstance(current_level, list):
|
||||||
|
logger.warning(f"[Util] 路径的最后一部分索引 '{key_or_index}' 超出列表范围或类型不符。列表长度: {len(current_level)}. 路径: {'.'.join(map(str,path))}")
|
||||||
|
return container_copy, None, False
|
||||||
|
else:
|
||||||
|
logger.error(f"[Util] 路径的最后一部分 '{key_or_index}' 期望父级是列表,但找到 {type(current_level)}。路径: {'.'.join(map(str,path))}")
|
||||||
|
return data_container, None, False
|
||||||
|
else:
|
||||||
|
next_key_or_index = path[i+1]
|
||||||
|
if isinstance(key_or_index, str):
|
||||||
|
if not isinstance(current_level, dict):
|
||||||
|
if current_level is container_copy and not container_copy :
|
||||||
|
current_level = {}
|
||||||
|
if i == 0: container_copy = current_level
|
||||||
|
else:
|
||||||
|
logger.error(f"[Util] 无法在非根级别从非字典创建路径。")
|
||||||
|
return data_container, None, False
|
||||||
|
else:
|
||||||
|
logger.error(f"[Util] 路径期望字典,但在 '{key_or_index}' 处找到 {type(current_level)}。")
|
||||||
|
return data_container, None, False
|
||||||
|
|
||||||
|
if isinstance(next_key_or_index, int):
|
||||||
|
if key_or_index not in current_level or not isinstance(current_level.get(key_or_index), list):
|
||||||
|
current_level[key_or_index] = []
|
||||||
|
current_level = current_level[key_or_index]
|
||||||
|
else:
|
||||||
|
if key_or_index not in current_level or not isinstance(current_level.get(key_or_index), dict):
|
||||||
|
current_level[key_or_index] = {}
|
||||||
|
current_level = current_level[key_or_index]
|
||||||
|
|
||||||
|
elif isinstance(key_or_index, int):
|
||||||
|
if not isinstance(current_level, list):
|
||||||
|
logger.error(f"[Util] 路径期望列表以应用索引 '{key_or_index}',但找到 {type(current_level)}。")
|
||||||
|
return data_container, None, False
|
||||||
|
|
||||||
|
while len(current_level) <= key_or_index:
|
||||||
|
if isinstance(next_key_or_index, str):
|
||||||
|
current_level.append({})
|
||||||
|
else:
|
||||||
|
current_level.append([])
|
||||||
|
|
||||||
|
if isinstance(next_key_or_index, str):
|
||||||
|
if not isinstance(current_level[key_or_index], dict):
|
||||||
|
current_level[key_or_index] = {}
|
||||||
|
elif isinstance(next_key_or_index, int):
|
||||||
|
if not isinstance(current_level[key_or_index], list):
|
||||||
|
current_level[key_or_index] = []
|
||||||
|
|
||||||
|
current_level = current_level[key_or_index]
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.error(f"[Util] 路径部分 '{key_or_index}' 类型未知 ({type(key_or_index)}).")
|
||||||
|
return data_container, None, False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Util] 在准备移除字段路径 '{'.'.join(map(str,path))}' 时发生错误: {e}", exc_info=True)
|
||||||
|
return data_container, None, False
|
||||||
|
|
||||||
|
logger.error(f"[Util] util_remove_value_at_path 未能在循环内按预期返回。路径: {'.'.join(map(str,path))}")
|
||||||
|
return data_container, None, False
|
||||||
32259
test_report.json
32259
test_report.json
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user