v0.2
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Assertion Engine Module"""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
# from ..models.rule_models import BusinessAssertionTemplate # Assuming rule_models.py will exist
|
||||
|
||||
class AssertionEngine:
|
||||
"""
|
||||
Responsible for verifying test step results based on predefined rules.
|
||||
This is a placeholder and will need significant development based on
|
||||
how assertion rules are defined and evaluated (e.g., Python expressions, JSONPath, etc.).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Initialization, potentially loading common assertion helpers or context
|
||||
pass
|
||||
|
||||
def evaluate_assertion(self, assertion_rule: Any, context_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Evaluates a single assertion rule against the given context data.
|
||||
|
||||
Args:
|
||||
assertion_rule: The rule definition (e.g., a Pydantic model like BusinessAssertionTemplate).
|
||||
The structure of this will depend on your rule design.
|
||||
context_data: Data from the test execution context (e.g., API response, extracted variables).
|
||||
|
||||
Returns:
|
||||
True if the assertion passes, False otherwise.
|
||||
"""
|
||||
# Placeholder logic - this needs to be implemented based on rule type
|
||||
# Example: if rule is a python expression
|
||||
# if assertion_rule.template_language == "python_expression":
|
||||
# try:
|
||||
# # Ensure the expression is safe to eval!
|
||||
# # Consider using ast.literal_eval for simple cases or a safer evaluation library.
|
||||
# # For complex expressions, a dedicated DSL or restricted environment is better.
|
||||
# # The context_data would be made available to the expression.
|
||||
# return bool(eval(assertion_rule.template_expression, {}, context_data))
|
||||
# except Exception as e:
|
||||
# print(f"Error evaluating Python expression assertion: {e}")
|
||||
# return False
|
||||
|
||||
# Example: if rule is a simple equality check (defined differently)
|
||||
# if "expected_value" in assertion_rule and "actual_value_path" in assertion_rule:
|
||||
# actual_value = get_value_from_path(context_data, assertion_rule.actual_value_path) # Needs helper
|
||||
# return actual_value == assertion_rule.expected_value
|
||||
|
||||
print(f"[AssertionEngine] Placeholder: Evaluating rule '{getattr(assertion_rule, "name", "Unnamed Rule")}'. Context: {context_data}")
|
||||
# This is a very basic placeholder. Real implementation depends heavily on rule definition.
|
||||
return True # Default to True for now
|
||||
|
||||
# Helper function example (would likely be more complex or use a library like jsonpath-ng)
|
||||
# def get_value_from_path(data: Dict[str, Any], path: str) -> Any:
|
||||
# """Retrieves a value from a nested dict using a simple dot-separated path."""
|
||||
# keys = path.split('.')
|
||||
# value = data
|
||||
# for key in keys:
|
||||
# if isinstance(value, dict) and key in value:
|
||||
# value = value[key]
|
||||
# else:
|
||||
# return None # Or raise an error
|
||||
# return value
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional, List, Tuple, Type
|
||||
from typing import Any, Dict, Optional, List, Tuple, Type, Union
|
||||
import logging
|
||||
from .utils import schema_utils
|
||||
|
||||
class TestSeverity(Enum):
|
||||
"""测试用例的严重程度"""
|
||||
@@ -87,6 +88,10 @@ class BaseAPITestCase:
|
||||
# 新增:测试用例执行顺序 (数值越小越先执行)
|
||||
execution_order: int = 100
|
||||
|
||||
# 新增:标记此测试用例是否为关键的前置设置测试
|
||||
# 如果此用例失败,后续针对该端点的其他测试用例将被跳过
|
||||
is_critical_setup_test: bool = False
|
||||
|
||||
# LLM 生成控制属性 (默认为 False,表示不使用LLM,除非显式开启)
|
||||
use_llm_for_body: bool = False
|
||||
use_llm_for_path_params: bool = False
|
||||
@@ -238,4 +243,259 @@ class BaseAPITestCase:
|
||||
# --- Helper to easily create a failed ValidationResult ---
|
||||
@staticmethod
|
||||
def failed(message: str, details: Optional[Dict[str, Any]] = None) -> ValidationResult:
|
||||
return ValidationResult(passed=False, message=message, details=details)
|
||||
return ValidationResult(passed=False, message=message, details=details)
|
||||
|
||||
# --- New helper methods for schema and field finding ---
|
||||
def _get_resolved_request_body_schema(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Helper to get the (potentially $ref-resolved by orchestrator) request body schema
|
||||
from self.endpoint_spec.
|
||||
The orchestrator is expected to have handled $ref resolution before test case instantiation.
|
||||
"""
|
||||
request_body_spec = self.endpoint_spec.get("requestBody")
|
||||
if request_body_spec and isinstance(request_body_spec, dict):
|
||||
content = request_body_spec.get("content", {})
|
||||
# Iterate through common JSON content types or prioritize application/json
|
||||
# Order matters: more specific first
|
||||
for ct in ["application/json", "application/merge-patch+json", "application/*+json", "*/*"]:
|
||||
if ct in content:
|
||||
media_type_obj = content[ct]
|
||||
if isinstance(media_type_obj, dict) and isinstance(media_type_obj.get("schema"), dict):
|
||||
self.logger.debug(f"Found request body schema under content type: {ct}")
|
||||
return media_type_obj["schema"]
|
||||
|
||||
# Fallback for OpenAPI 2.0 (Swagger) style 'in: body' parameter
|
||||
# This might also be present in OpenAPI 3.0 for compatibility or by mistake
|
||||
parameters = self.endpoint_spec.get("parameters", [])
|
||||
if isinstance(parameters, list):
|
||||
for param in parameters:
|
||||
if isinstance(param, dict) and param.get("in") == "body":
|
||||
param_schema = param.get("schema")
|
||||
if isinstance(param_schema, dict):
|
||||
self.logger.debug("Found request body schema under 'in: body' parameter (Swagger 2.0 style).")
|
||||
# Schema for 'in: body' parameter is directly usable
|
||||
return param_schema
|
||||
|
||||
self.logger.debug("No suitable request body schema found in endpoint_spec.")
|
||||
return None
|
||||
|
||||
def _find_removable_field_path(self, schema_to_search: Optional[Dict[str, Any]], schema_name_for_log: str) -> Optional[List[Union[str, int]]]:
|
||||
"""
|
||||
Uses schema_utils to find a removable (required) field path within the given schema.
|
||||
Args:
|
||||
schema_to_search: The schema dictionary to search within.
|
||||
schema_name_for_log: A string name for the schema (e.g., "request body", "response header") for logging.
|
||||
Returns:
|
||||
A list representing the path to a removable field, or None if not found.
|
||||
"""
|
||||
if not schema_to_search:
|
||||
self.logger.info(f"Schema for '{schema_name_for_log}' is missing or empty. Cannot find removable field.")
|
||||
return None
|
||||
|
||||
removable_path = schema_utils.util_find_removable_field_path_recursive(
|
||||
current_schema=schema_to_search,
|
||||
current_path=[],
|
||||
full_api_spec_for_refs=self.global_api_spec
|
||||
# schema_utils.resolve_json_schema_references will use discard_refs=True by default
|
||||
)
|
||||
|
||||
if removable_path:
|
||||
self.logger.info(f"Found a removable field path in '{schema_name_for_log}' schema: '{'.'.join(map(str, removable_path))}'")
|
||||
else:
|
||||
self.logger.info(f"No removable (required) field path found in '{schema_name_for_log}' schema.")
|
||||
return removable_path
|
||||
|
||||
def _find_simple_type_field_in_schema(
|
||||
self,
|
||||
schema_to_search: Optional[Dict[str, Any]],
|
||||
schema_name_for_log: str
|
||||
) -> Optional[Tuple[List[Union[str, int]], str, Dict[str, Any]]]:
|
||||
"""
|
||||
(Helper for test cases) Finds the first simple type field (string, integer, number, boolean)
|
||||
in the given schema using schema_utils.
|
||||
|
||||
Args:
|
||||
schema_to_search: The schema dictionary to search within.
|
||||
Expected to be already resolved (e.g., from _get_resolved_request_body_schema).
|
||||
schema_name_for_log: A name for the schema (e.g., "request body", "response body") for logging.
|
||||
|
||||
Returns:
|
||||
A tuple (field_path, field_type, field_schema) if found, otherwise None.
|
||||
"""
|
||||
if not schema_to_search:
|
||||
self.logger.debug(f"_find_simple_type_field_in_schema: Schema for '{schema_name_for_log}' is None or empty, cannot search.")
|
||||
return None
|
||||
|
||||
if not isinstance(schema_to_search, dict):
|
||||
self.logger.warning(f"_find_simple_type_field_in_schema: Expected schema for '{schema_name_for_log}' to be a dict, got {type(schema_to_search)}.")
|
||||
return None
|
||||
|
||||
self.logger.debug(f"_find_simple_type_field_in_schema: Searching for simple type field in '{schema_name_for_log}' schema...")
|
||||
|
||||
found_target = schema_utils.find_first_simple_type_field_recursive(
|
||||
current_schema=schema_to_search,
|
||||
logger_param=self.logger # Pass the test case's logger
|
||||
)
|
||||
|
||||
if found_target:
|
||||
field_path, field_type, field_prop_schema = found_target
|
||||
self.logger.info(f"_find_simple_type_field_in_schema: Found simple type field in '{schema_name_for_log}': Path={'.'.join(map(str, field_path))}, Type={field_type}")
|
||||
return field_path, field_type, field_prop_schema
|
||||
else:
|
||||
self.logger.debug(f"_find_simple_type_field_in_schema: No simple type field found in '{schema_name_for_log}' schema.")
|
||||
return None
|
||||
|
||||
def _find_first_simple_type_parameter(
|
||||
self,
|
||||
param_location: str
|
||||
) -> Optional[Tuple[List[Union[str, int]], str, Dict[str, Any], str]]:
|
||||
"""
|
||||
Finds the first parameter in the specified location (e.g., 'query', 'header')
|
||||
that is a simple type or contains a simple type if it's an object schema.
|
||||
|
||||
Args:
|
||||
param_location: The location of the parameter ('query', 'header').
|
||||
|
||||
Returns:
|
||||
A tuple (full_path, param_type, param_schema, param_name) if found, otherwise None.
|
||||
- full_path: Path to the simple type (e.g., ['paramName'] or ['paramName', 'nestedField']).
|
||||
- param_type: The original type of the simple field.
|
||||
- param_schema: The schema definition of the simple field.
|
||||
- param_name: The name of the top-level parameter.
|
||||
"""
|
||||
parameters = self.endpoint_spec.get("parameters", [])
|
||||
if not isinstance(parameters, list):
|
||||
self.logger.warning(f"_find_first_simple_type_parameter: 'parameters' in endpoint_spec is not a list. Cannot find {param_location} parameter.")
|
||||
return None
|
||||
|
||||
for param_spec in parameters:
|
||||
if not isinstance(param_spec, dict) or param_spec.get("in") != param_location:
|
||||
continue
|
||||
|
||||
param_name = param_spec.get("name")
|
||||
if not param_name:
|
||||
self.logger.warning(f"_find_first_simple_type_parameter: Found a {param_location} parameter without a name. Skipping: {param_spec}")
|
||||
continue
|
||||
|
||||
self.logger.debug(f"_find_first_simple_type_parameter: Checking {param_location} parameter '{param_name}'.")
|
||||
|
||||
# Case 1: Parameter schema is directly defined at the top level of param_spec (OpenAPI 3.0)
|
||||
param_actual_schema = param_spec.get("schema")
|
||||
if isinstance(param_actual_schema, dict):
|
||||
schema_type = param_actual_schema.get("type")
|
||||
if schema_type in ["string", "integer", "number", "boolean"]:
|
||||
self.logger.info(f"_find_first_simple_type_parameter: Found simple type {param_location} parameter '{param_name}' (type: {schema_type}) via its 'schema'.")
|
||||
return [param_name], schema_type, param_actual_schema, param_name
|
||||
elif schema_type == "object":
|
||||
self.logger.debug(f"_find_first_simple_type_parameter: {param_location} parameter '{param_name}' has an object schema. Searching within...")
|
||||
# Schema is already resolved by orchestrator, so no need to call resolve_ref here.
|
||||
found_in_object = self._find_simple_type_field_in_schema(param_actual_schema, f"{param_location} parameter '{param_name}'")
|
||||
if found_in_object:
|
||||
nested_path, nested_type, nested_schema = found_in_object
|
||||
full_path = [param_name] + nested_path
|
||||
self.logger.info(f"_find_first_simple_type_parameter: Found simple type field within object {param_location} parameter '{param_name}'. Path: {'.'.join(map(str,full_path))}, Type: {nested_type}")
|
||||
return full_path, nested_type, nested_schema, param_name
|
||||
# Add other cases if necessary, e.g. array of simple types for query params (though less common for type mismatch target)
|
||||
|
||||
# Case 2: Type is defined directly in param_spec (OpenAPI 2.0 / Swagger or simple OpenAPI 3.0 params)
|
||||
# This is checked after 'schema' as 'schema' is more explicit in OpenAPI 3+
|
||||
direct_param_type = param_spec.get("type")
|
||||
if direct_param_type in ["string", "integer", "number", "boolean"]:
|
||||
# This param_spec itself is the schema for the simple type
|
||||
self.logger.info(f"_find_first_simple_type_parameter: Found simple type {param_location} parameter '{param_name}' (type: {direct_param_type}) via direct 'type'.")
|
||||
return [param_name], direct_param_type, param_spec, param_name
|
||||
|
||||
self.logger.info(f"_find_first_simple_type_parameter: No suitable simple type field found for {param_location} parameters.")
|
||||
return None
|
||||
|
||||
def _find_required_parameter_name(self, param_in: str) -> Optional[str]:
|
||||
"""
|
||||
Finds the name of the first required parameter in the specified location ('query', 'header', 'path').
|
||||
Args:
|
||||
param_in: The location of the parameter (e.g., "query", "header", "path").
|
||||
Returns:
|
||||
The name of the first required parameter found, or None.
|
||||
"""
|
||||
parameters = self.endpoint_spec.get("parameters", [])
|
||||
if not isinstance(parameters, list):
|
||||
self.logger.warning(f"'parameters' in endpoint_spec is not a list, cannot find required {param_in} parameter.")
|
||||
return None
|
||||
|
||||
for param_spec in parameters:
|
||||
if (isinstance(param_spec, dict) and
|
||||
param_spec.get("in") == param_in and
|
||||
param_spec.get("required") is True):
|
||||
param_name = param_spec.get("name")
|
||||
if param_name:
|
||||
self.logger.info(f"Found required '{param_in}' parameter: '{param_name}'.")
|
||||
return param_name
|
||||
|
||||
self.logger.info(f"No required '{param_in}' parameter found in endpoint_spec.")
|
||||
return None
|
||||
|
||||
def expect_error_response(
|
||||
self,
|
||||
response_context: APIResponseContext,
|
||||
expected_status_codes: List[int],
|
||||
expected_error_code_in_body: Optional[Union[str, int]] = None,
|
||||
error_code_field_name: str = "code",
|
||||
context_message_prefix: str = "Error response validation"
|
||||
) -> List[ValidationResult]:
|
||||
"""
|
||||
Validates if the response matches expected error conditions.
|
||||
|
||||
Args:
|
||||
response_context: The API response context.
|
||||
expected_status_codes: A list of expected HTTP status codes (e.g., [400, 422]).
|
||||
expected_error_code_in_body: Optional. A specific error code expected in the response body.
|
||||
error_code_field_name: The name of the field in the JSON response body that contains the error code.
|
||||
context_message_prefix: Prefix for logging and result messages.
|
||||
|
||||
Returns:
|
||||
A list of ValidationResult objects.
|
||||
"""
|
||||
results = []
|
||||
status_code = response_context.status_code
|
||||
json_content = response_context.json_content
|
||||
|
||||
if status_code in expected_status_codes:
|
||||
msg = f"{context_message_prefix}: Received expected status code {status_code}."
|
||||
if expected_error_code_in_body is not None:
|
||||
if isinstance(json_content, dict):
|
||||
error_code_in_response = json_content.get(error_code_field_name)
|
||||
if error_code_in_response == expected_error_code_in_body:
|
||||
results.append(self.passed(f"{msg} Specific error code '{expected_error_code_in_body}' (field: '{error_code_field_name}') found in response body."))
|
||||
elif error_code_in_response is not None:
|
||||
results.append(ValidationResult(passed=True, # Still counts as a valid error status, but code mismatch is noted
|
||||
message=f"{msg} Status code is as expected, but error code in body ('{error_code_field_name}': '{error_code_in_response}') does not match expected '{expected_error_code_in_body}'.",
|
||||
details={"response_body": json_content}
|
||||
))
|
||||
else: # Error code field not found
|
||||
results.append(ValidationResult(passed=True, # Status is good, but code presence is an issue
|
||||
message=f"{msg} Status code is as expected, but did not find error code field '{error_code_field_name}' in response body.",
|
||||
details={"response_body": json_content}
|
||||
))
|
||||
else: # JSON content is not a dict
|
||||
# Add a check for text_content before slicing
|
||||
raw_text_detail = response_context.text_content[:500] if response_context.text_content else "(No text content)"
|
||||
results.append(ValidationResult(passed=True, # Status is good, but body isn't inspectable for code
|
||||
message=f"{msg} Status code is as expected, but response body is not a JSON object, so cannot check for error code '{error_code_field_name}'.",
|
||||
details={"raw_response_text": raw_text_detail}
|
||||
))
|
||||
else: # No specific error code in body to check, status code match is enough
|
||||
results.append(self.passed(msg))
|
||||
else:
|
||||
details = {"received_status_code": status_code, "expected_status_codes": expected_status_codes}
|
||||
if isinstance(json_content, dict):
|
||||
details["response_body"] = json_content
|
||||
else:
|
||||
# Add a check for text_content before slicing
|
||||
raw_text_detail = response_context.text_content[:500] if response_context.text_content else "(No text content)"
|
||||
details["raw_response_text"] = raw_text_detail
|
||||
results.append(self.failed(
|
||||
message=f"{context_message_prefix}: Expected status code to be one of {expected_status_codes}, but received {status_code}.",
|
||||
details=details
|
||||
))
|
||||
self.logger.warning(f"{self.id}: {context_message_prefix} failed. Expected status: {expected_status_codes}, Actual: {status_code}")
|
||||
|
||||
return results
|
||||
@@ -26,6 +26,7 @@ from .test_framework_core import ValidationResult, TestSeverity, APIRequestConte
|
||||
from .test_case_registry import TestCaseRegistry
|
||||
# 尝试导入 utils.schema_utils
|
||||
from .utils import schema_utils
|
||||
from .utils.common_utils import format_url_with_path_params # 新增导入
|
||||
|
||||
# 尝试导入 LLMService,如果失败则允许,因为 LLM 功能是可选的
|
||||
try:
|
||||
@@ -65,12 +66,17 @@ class ExecutedTestCaseResult:
|
||||
self.timestamp = datetime.datetime.now()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
message=""
|
||||
if self.message:
|
||||
message = self.message
|
||||
else:
|
||||
message= ";".join([vp.message for vp in self.validation_points])
|
||||
return {
|
||||
"test_case_id": self.test_case_id,
|
||||
"test_case_name": self.test_case_name,
|
||||
"test_case_severity": self.test_case_severity.value, # 使用枚举值
|
||||
"status": self.status.value,
|
||||
"message": self.message,
|
||||
"message": message,
|
||||
"duration_seconds": self.duration,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"validation_points": [vp.details if vp.details else {"passed": vp.passed, "message": vp.message} for vp in self.validation_points]
|
||||
@@ -102,6 +108,7 @@ class TestResult: # 原来的 TestResult 被重构为 EndpointExecutionResult
|
||||
self.start_time = start_time if start_time else datetime.datetime.now()
|
||||
self.end_time: Optional[datetime.datetime] = None
|
||||
self.error_message: Optional[str] = None # 如果整个端点测试出错,记录错误信息
|
||||
self.message: Optional[str] = None
|
||||
|
||||
def add_executed_test_case_result(self, result: ExecutedTestCaseResult):
|
||||
self.executed_test_cases.append(result)
|
||||
@@ -264,6 +271,7 @@ class TestSummary:
|
||||
"error_in_execution": self.test_cases_error,
|
||||
"skipped_during_endpoint_execution": self.test_cases_skipped_in_endpoint,
|
||||
"success_rate_percentage": f"{self.test_case_success_rate:.2f}",
|
||||
|
||||
},
|
||||
"detailed_results": [result.to_dict() for result in self.detailed_results]
|
||||
}
|
||||
@@ -445,20 +453,33 @@ class APITestOrchestrator:
|
||||
recursion_depth: int = 0
|
||||
) -> Optional[Type[BaseModel]]:
|
||||
"""
|
||||
动态地从JSON Schema字典创建一个Pydantic模型类。
|
||||
支持嵌套对象和数组。
|
||||
|
||||
Args:
|
||||
schema: JSON Schema字典。
|
||||
model_name: 要创建的Pydantic模型的名称。
|
||||
recursion_depth: 当前递归深度,用于防止无限循环。
|
||||
|
||||
Returns:
|
||||
一个Pydantic BaseModel的子类,如果创建失败则返回None。
|
||||
Dynamically creates a Pydantic model from a JSON schema.
|
||||
Handles nested schemas, arrays, and various OpenAPI/JSON Schema constructs.
|
||||
Uses a cache (_dynamic_model_cache) to avoid redefining identical models.
|
||||
"""
|
||||
MAX_RECURSION_DEPTH = 10
|
||||
if recursion_depth > MAX_RECURSION_DEPTH:
|
||||
self.logger.error(f"创建Pydantic模型 '{model_name}' 时达到最大递归深度 {MAX_RECURSION_DEPTH}。可能存在循环引用。")
|
||||
# This cache key generation might need refinement for very complex/deep schemas
|
||||
# For now, using a combination of model_name and sorted schema keys/values
|
||||
# Important: dicts are unhashable, so we convert to a sorted tuple of items for the cache key.
|
||||
# This is a simplified cache key; a more robust approach might involve serializing the schema.
|
||||
# schema_tuple_for_key = tuple(sorted(schema.items())) if isinstance(schema, dict) else schema
|
||||
# cache_key = (model_name, schema_tuple_for_key, recursion_depth) # Might be too verbose/complex
|
||||
|
||||
# Simpler cache key based on model_name only if we assume model_name is sufficiently unique
|
||||
# for a given schema structure within a run. If schemas can change for the same model_name,
|
||||
# this needs to be more sophisticated.
|
||||
|
||||
# If model_name is unique per structure, this is fine.
|
||||
# Let's assume model_name is carefully constructed to be unique for each distinct schema structure
|
||||
# by the calling functions (e.g., _generate_data_from_schema, _build_object_schema_for_params).
|
||||
|
||||
# Simplified approach: if a model with this exact name was already created, reuse it.
|
||||
# This relies on the caller to ensure `model_name` is unique per schema structure.
|
||||
if model_name in _dynamic_model_cache:
|
||||
self.logger.debug(f"Reusing cached Pydantic model: {model_name}")
|
||||
return _dynamic_model_cache[model_name]
|
||||
|
||||
if recursion_depth > self.MAX_RECURSION_DEPTH_PYDANTIC:
|
||||
self.logger.error(f"创建Pydantic模型 '{model_name}' 时达到最大递归深度 {self.MAX_RECURSION_DEPTH_PYDANTIC}。可能存在循环引用。")
|
||||
return None
|
||||
|
||||
# 清理模型名称,使其成为有效的Python标识符
|
||||
@@ -875,13 +896,15 @@ class APITestOrchestrator:
|
||||
|
||||
|
||||
final_url_template = endpoint_spec_dict.get('path', '')
|
||||
# 添加日志:打印将要用于替换的路径参数
|
||||
self.logger.debug(f"Path parameters to be substituted: {current_path_params}")
|
||||
final_url = self.base_url + final_url_template
|
||||
for p_name, p_val in current_path_params.items():
|
||||
placeholder = f"{{{p_name}}}"
|
||||
if placeholder in final_url_template: # 替换基础路径模板中的占位符
|
||||
if placeholder in final_url_template: # 检查原始模板中是否存在占位符
|
||||
final_url = final_url.replace(placeholder, str(p_val))
|
||||
# 注意: 如果 _prepare_initial_request_data 填充的 final_url 已经包含了 base_url,这里的拼接逻辑需要调整
|
||||
# 假设 final_url_template 只是 path string e.g. /users/{id}
|
||||
# 添加日志:打印替换后的URL (在测试用例修改之前)
|
||||
self.logger.debug(f"URL after path parameter substitution (before TC modify_request_url hook): {final_url}")
|
||||
|
||||
# ---- 调用测试用例的 URL 修改钩子 ----
|
||||
effective_url = final_url # 默认使用原始构建的URL
|
||||
@@ -1015,9 +1038,9 @@ class APITestOrchestrator:
|
||||
根据API端点规范,准备初始的请求数据,包括URL(模板)、路径参数、查询参数、头部和请求体。
|
||||
这些数据将作为测试用例中 generate_* 方法的输入。
|
||||
"""
|
||||
method = endpoint_spec.get('method', 'GET').upper()
|
||||
path_template = endpoint_spec.get('path', '/') # 这是路径模板, e.g., /users/{id}
|
||||
operation_id = endpoint_spec.get('operationId') or f"{method}_{path_template.replace('/', '_').replace('{', '_').replace('}','')}"
|
||||
method = endpoint_spec.get("method", "GET").upper()
|
||||
path_template = endpoint_spec.get("path", "/")
|
||||
operation_id = endpoint_spec.get("operationId", path_template) # 使用 path 作为 operationId 的 fallback
|
||||
|
||||
initial_path_params: Dict[str, Any] = {}
|
||||
initial_query_params: Dict[str, Any] = {}
|
||||
@@ -1317,31 +1340,70 @@ class APITestOrchestrator:
|
||||
endpoint_test_result.finalize_endpoint_test()
|
||||
return endpoint_test_result
|
||||
|
||||
applicable_test_case_classes = self.test_case_registry.get_applicable_test_cases(
|
||||
applicable_test_case_classes_unordered = self.test_case_registry.get_applicable_test_cases(
|
||||
endpoint_method=endpoint.method.upper(),
|
||||
endpoint_path=endpoint.path
|
||||
)
|
||||
|
||||
if not applicable_test_case_classes:
|
||||
if not applicable_test_case_classes_unordered:
|
||||
self.logger.info(f"端点 '{endpoint_id}' 没有找到适用的自定义测试用例。")
|
||||
endpoint_test_result.finalize_endpoint_test()
|
||||
endpoint_test_result.finalize_endpoint_test() # 确保在返回前调用
|
||||
return endpoint_test_result
|
||||
|
||||
self.logger.info(f"端点 '{endpoint_id}' 发现了 {len(applicable_test_case_classes)} 个适用的测试用例: {[tc.id for tc in applicable_test_case_classes]}")
|
||||
# 根据 execution_order 排序测试用例
|
||||
applicable_test_case_classes = sorted(
|
||||
applicable_test_case_classes_unordered,
|
||||
key=lambda tc_class: tc_class.execution_order
|
||||
)
|
||||
|
||||
self.logger.info(f"端点 '{endpoint_id}' 发现了 {len(applicable_test_case_classes)} 个适用的测试用例 (已排序): {[tc.id for tc in applicable_test_case_classes]}")
|
||||
|
||||
critical_setup_test_failed = False
|
||||
critical_setup_failure_reason = ""
|
||||
|
||||
for tc_class in applicable_test_case_classes:
|
||||
self.logger.debug(f"准备执行测试用例 '{tc_class.id}' for '{endpoint_id}'")
|
||||
executed_case_result = self._execute_single_test_case(
|
||||
test_case_class=tc_class,
|
||||
endpoint_spec=endpoint,
|
||||
global_api_spec=global_api_spec
|
||||
)
|
||||
endpoint_test_result.add_executed_test_case_result(executed_case_result)
|
||||
if executed_case_result.status.value == TestResult.Status.FAILED.value:
|
||||
# 红色
|
||||
self.logger.debug(f"\033[91m ❌ 测试用例 '{tc_class.id}' 执行失败。\033[0m")
|
||||
start_single_tc_time = time.monotonic() # 用于计算跳过测试用例的持续时间
|
||||
|
||||
if critical_setup_test_failed:
|
||||
self.logger.warning(f"由于关键的前置测试用例失败,跳过测试用例 '{tc_class.id}' for '{endpoint_id}'. 原因: {critical_setup_failure_reason}")
|
||||
skipped_tc_duration = time.monotonic() - start_single_tc_time
|
||||
executed_case_result = ExecutedTestCaseResult(
|
||||
test_case_id=tc_class.id,
|
||||
test_case_name=tc_class.name,
|
||||
test_case_severity=tc_class.severity,
|
||||
status=ExecutedTestCaseResult.Status.SKIPPED,
|
||||
validation_points=[],
|
||||
message=f"由于关键的前置测试失败而被跳过: {critical_setup_failure_reason}",
|
||||
duration=skipped_tc_duration
|
||||
)
|
||||
else:
|
||||
self.logger.debug(f"准备执行测试用例 '{tc_class.id}' for '{endpoint_id}'")
|
||||
executed_case_result = self._execute_single_test_case(
|
||||
test_case_class=tc_class,
|
||||
endpoint_spec=endpoint,
|
||||
global_api_spec=global_api_spec
|
||||
)
|
||||
|
||||
# 检查是否是关键测试用例以及是否失败
|
||||
if hasattr(tc_class, 'is_critical_setup_test') and tc_class.is_critical_setup_test:
|
||||
if executed_case_result.status in [ExecutedTestCaseResult.Status.FAILED, ExecutedTestCaseResult.Status.ERROR]:
|
||||
critical_setup_test_failed = True
|
||||
critical_setup_failure_reason = f"关键测试 '{tc_class.id}' 失败 (状态: {executed_case_result.status.value})。消息: {executed_case_result.message}"
|
||||
self.logger.error(f"关键的前置测试用例 '{tc_class.id}' for '{endpoint_id}' 失败。后续测试将被跳过。原因: {critical_setup_failure_reason}")
|
||||
|
||||
endpoint_test_result.add_executed_test_case_result(executed_case_result)
|
||||
|
||||
# 日志部分可以保持不变或根据需要调整
|
||||
if executed_case_result.status.value == ExecutedTestCaseResult.Status.FAILED.value:
|
||||
self.logger.debug(f"\033[91m ❌ 测试用例 '{tc_class.id}' 执行失败。\033[0m")
|
||||
elif executed_case_result.status.value == ExecutedTestCaseResult.Status.PASSED.value :
|
||||
self.logger.debug(f"\033[92m ✅ 测试用例 '{tc_class.id}' 执行成功。\033[0m")
|
||||
# 对于SKIPPED和ERROR状态,可以添加不同颜色的日志
|
||||
elif executed_case_result.status.value == ExecutedTestCaseResult.Status.SKIPPED.value:
|
||||
self.logger.debug(f"\033[93m ⏭️ 测试用例 '{tc_class.id}' 被跳过。\033[0m") # 黄色
|
||||
elif executed_case_result.status.value == ExecutedTestCaseResult.Status.ERROR.value:
|
||||
self.logger.debug(f"\033[91m 💥 测试用例 '{tc_class.id}' 执行时发生错误。\033[0m") # 红色 (与FAILED相同或不同)
|
||||
|
||||
self.logger.debug(f"测试用例 '{tc_class.id}' 执行完毕,状态: {executed_case_result.status.value}")
|
||||
|
||||
endpoint_test_result.finalize_endpoint_test()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def format_url_with_path_params(path_template: str, path_params: Dict[str, Any]) -> str:
|
||||
"""
|
||||
使用提供的路径参数格式化URL路径模板。
|
||||
例如, path_template='/users/{userId}/items/{itemId}'
|
||||
path_params={'userId': 123, 'itemId': 'abc'}
|
||||
-> '/users/123/items/abc'
|
||||
|
||||
Args:
|
||||
path_template: 包含占位符的URL路径,例如 /resource/{id}。
|
||||
path_params: 包含占位符名称及其值的字典。
|
||||
|
||||
Returns:
|
||||
格式化后的URL路径。
|
||||
"""
|
||||
url = path_template
|
||||
try:
|
||||
# 优先使用 .format(**path_params) 如果所有占位符都能匹配
|
||||
# 这要求 path_params 中的键与模板中的占位符完全对应
|
||||
# url = path_template.format(**path_params) # 更简洁,但如果参数不完全匹配会报错
|
||||
|
||||
# 使用正则表达式逐个替换更安全,可以处理部分参数或额外参数的情况
|
||||
for param_name, param_value in path_params.items():
|
||||
placeholder = f"{{{param_name}}}"
|
||||
if placeholder in url:
|
||||
url = url.replace(placeholder, str(param_value))
|
||||
else:
|
||||
# Log if a path param was provided but not found in template. Could be optional.
|
||||
logger.debug(f"Path parameter '{param_name}' provided but placeholder '{placeholder}' not found in template '{path_template}'.")
|
||||
|
||||
# 检查是否还有未替换的占位符 (可选,但推荐)
|
||||
remaining_placeholders = re.findall(r"({[^{}]+?})", url)
|
||||
if remaining_placeholders:
|
||||
logger.warning(f"URL '{url}' 中仍有未替换的路径参数占位符: {remaining_placeholders}。原始模板: '{path_template}', 提供参数: {path_params}")
|
||||
|
||||
except KeyError as e:
|
||||
logger.error(f"格式化URL路径 '{path_template}' 失败:路径参数 '{e}' 未在提供的 path_params 中找到。可用参数: {list(path_params.keys())}")
|
||||
# 根据需要,这里可以选择是返回原始模板还是抛出异常
|
||||
# return path_template
|
||||
raise ValueError(f"Missing path parameter {e} for URL template {path_template}") from e
|
||||
except Exception as e:
|
||||
logger.error(f"格式化URL路径 '{path_template}' 时发生未知错误: {e}")
|
||||
raise # 或者返回原始模板
|
||||
|
||||
return url
|
||||
@@ -271,4 +271,342 @@ def util_remove_value_at_path(
|
||||
return data_container, None, False
|
||||
|
||||
logger.error(f"[Util] util_remove_value_at_path 未能在循环内按预期返回。路径: {'.'.join(map(str,path))}")
|
||||
return data_container, None, False
|
||||
return data_container, None, False
|
||||
|
||||
def util_set_value_at_path(
|
||||
data_container: Any,
|
||||
path: List[Union[str, int]],
|
||||
new_value: Any,
|
||||
# logger_param: Optional[logging.Logger] = None
|
||||
) -> Tuple[Any, bool]:
|
||||
"""
|
||||
(框架辅助方法) 在嵌套的字典/列表中为指定路径设置新值。
|
||||
如果路径中的某些部分不存在,会尝试创建它们 (字典会创建,列表会尝试填充到指定索引,但需谨慎)。
|
||||
返回 (修改后的容器, 是否成功)。
|
||||
"""
|
||||
# effective_logger = logger_param or logger
|
||||
if not path:
|
||||
logger.error("[Util] util_set_value_at_path: 路径不能为空。")
|
||||
# 如果路径为空,是否应该用 new_value 替换整个 data_container?
|
||||
# 当前行为:返回原始容器和失败状态,因为路径通常指向容器内部。
|
||||
# 如果要支持替换整个容器,需要明确此行为。
|
||||
if data_container is None and new_value is not None: # 特殊情况:如果原始容器是None,且路径为空,则新值成为容器
|
||||
logger.info("[Util] util_set_value_at_path: 路径为空,原始容器为None,新值将作为新容器返回。")
|
||||
return new_value, True
|
||||
elif data_container is not None and new_value is None and not path: # 路径为空,新值为None,则清空容器
|
||||
logger.info("[Util] util_set_value_at_path: 路径为空,新值为None,容器将被清空 (返回None)。")
|
||||
return None, True
|
||||
# 对于路径为空且两者都不是None的情况,目前返回失败,因为通常期望有路径。
|
||||
# 或者可以考虑直接返回 new_value,意味着整个对象被替换。
|
||||
# logger.info(f"[Util] util_set_value_at_path: Path is empty. Replacing entire container.")
|
||||
# return new_value, True # 备选行为:替换整个对象
|
||||
return data_container, False
|
||||
|
||||
# 深拷贝以避免修改原始输入,除非原始输入是None
|
||||
container_copy = copy.deepcopy(data_container) if data_container is not None else None
|
||||
|
||||
current_level = container_copy
|
||||
|
||||
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 not isinstance(current_level, dict):
|
||||
# 如果当前层级不是字典 (例如是None,或是被意外替换为其他类型),无法设置键值对
|
||||
logger.error(f"[Util] util_set_value_at_path: 路径的最后一部分 '{key_or_index}' (string key) 期望父级是字典,但找到 {type(current_level)}。路径: {'.'.join(map(str,path))}")
|
||||
# 尝试强制转换为字典?这可能不是预期行为。
|
||||
# 如果 current_level 是 None 且它是根 (container_copy is None),则初始化 container_copy
|
||||
if current_level is None and i == 0: # 路径只有一级,且容器本身是None
|
||||
container_copy = {}
|
||||
current_level = container_copy
|
||||
else: # 更深层级的None或类型错误
|
||||
return data_container, False
|
||||
current_level[key_or_index] = new_value
|
||||
logger.info(f"[Util] 在路径 {'.'.join(map(str,path))} (键 '{key_or_index}') 处设置值为 '{new_value}'")
|
||||
return container_copy, True
|
||||
elif isinstance(key_or_index, int): # key_or_index is an integer (list index)
|
||||
if not isinstance(current_level, list):
|
||||
logger.error(f"[Util] util_set_value_at_path: 路径的最后一部分索引 '{key_or_index}' 期望父级是列表,但找到 {type(current_level)}。路径: {'.'.join(map(str,path))}")
|
||||
if current_level is None and i == 0: # 路径只有一级,且容器本身是None
|
||||
container_copy = [None] * (key_or_index + 1) # 创建足够长度的列表
|
||||
current_level = container_copy
|
||||
else:
|
||||
return data_container, False
|
||||
elif isinstance(key_or_index, int):
|
||||
# 确保列表足够长以容纳索引
|
||||
while len(current_level) <= key_or_index:
|
||||
current_level.append(None) # 用 None 填充直到达到所需长度
|
||||
current_level[key_or_index] = new_value
|
||||
logger.info(f"[Util] 在路径 {'.'.join(map(str,path))} (索引 '{key_or_index}') 处设置值为 '{new_value}'")
|
||||
return container_copy, True
|
||||
else:
|
||||
logger.error(f"[Util] util_set_value_at_path: 路径的最后一部分 '{key_or_index}' 类型未知。路径: {'.'.join(map(str,path))}")
|
||||
return data_container, False
|
||||
else: # Not the last element, traverse deeper
|
||||
next_key_or_index_is_int = isinstance(path[i+1], int)
|
||||
|
||||
if isinstance(key_or_index, str): # Current path part is a dictionary key
|
||||
if not isinstance(current_level, dict):
|
||||
# 如果在根级别且容器是None,则初始化为字典
|
||||
if current_level is None and i == 0:
|
||||
container_copy = {}
|
||||
current_level = container_copy
|
||||
else:
|
||||
logger.error(f"[Util] util_set_value_at_path: 路径期望字典,但在 '{key_or_index}' 处找到 {type(current_level)}。路径: {'.'.join(map(str,path[:i+1]))}")
|
||||
return data_container, False
|
||||
if key_or_index not in current_level or current_level[key_or_index] is None or \
|
||||
(next_key_or_index_is_int and not isinstance(current_level[key_or_index], list)) or \
|
||||
(not next_key_or_index_is_int and not isinstance(current_level[key_or_index], dict)):
|
||||
# 如果键不存在,或值为None,或类型与下一路径部分不匹配,则创建/重置
|
||||
logger.debug(f"[Util] util_set_value_at_path: 在路径 '{key_or_index}' 处创建/重置结构。下一个是索引: {next_key_or_index_is_int}")
|
||||
current_level[key_or_index] = [] if next_key_or_index_is_int else {}
|
||||
current_level = current_level[key_or_index]
|
||||
elif isinstance(key_or_index, int): # Current path part is a list index
|
||||
if not isinstance(current_level, list):
|
||||
if current_level is None and i == 0:
|
||||
container_copy = []
|
||||
current_level = container_copy
|
||||
else:
|
||||
logger.error(f"[Util] util_set_value_at_path: 路径期望列表以应用索引 '{key_or_index}',但找到 {type(current_level)}。路径: {'.'.join(map(str,path[:i+1]))}")
|
||||
return data_container, False
|
||||
elif isinstance(key_or_index, int):
|
||||
# 确保列表足够长以容纳索引,并确保该索引处的元素是正确的类型 (list/dict)
|
||||
while len(current_level) <= key_or_index:
|
||||
current_level.append(None) # 用 None 填充
|
||||
if current_level[key_or_index] is None or \
|
||||
(next_key_or_index_is_int and not isinstance(current_level[key_or_index], list)) or \
|
||||
(not next_key_or_index_is_int and not isinstance(current_level[key_or_index], dict)):
|
||||
logger.debug(f"[Util] util_set_value_at_path: 在列表索引 '{key_or_index}' 处创建/重置结构。下一个是索引: {next_key_or_index_is_int}")
|
||||
current_level[key_or_index] = [] if next_key_or_index_is_int else {}
|
||||
current_level = current_level[key_or_index]
|
||||
else:
|
||||
logger.error(f"[Util] util_set_value_at_path: 路径部分 '{key_or_index}' 类型未知 ({type(key_or_index)})。路径: {'.'.join(map(str,path[:i+1]))}")
|
||||
return data_container, False
|
||||
except Exception as e:
|
||||
logger.error(f"[Util] 在准备设置字段路径 {'.'.join(map(str,path))} 的值时发生错误: {e}", exc_info=True)
|
||||
return data_container, False
|
||||
|
||||
# Should not be reached if logic is correct, path must have at least one element by initial check.
|
||||
logger.error(f"[Util] util_set_value_at_path 未能在循环内按预期返回。路径: {'.'.join(map(str,path))}")
|
||||
return data_container, False
|
||||
|
||||
def generate_mismatched_value(
|
||||
original_type: Optional[str],
|
||||
original_value: Any,
|
||||
field_schema: Optional[Dict[str, Any]],
|
||||
logger_param: Optional[logging.Logger] = None
|
||||
) -> Any:
|
||||
"""
|
||||
(框架辅助方法) 根据原始数据类型、原始值和字段 schema 生成一个类型不匹配的值。
|
||||
主要用于类型不匹配的测试用例。
|
||||
Args:
|
||||
original_type: 字段的原始 OpenAPI 类型 (e.g., "string", "integer").
|
||||
original_value: 字段的原始值 (当前未直接用于生成逻辑,但可供未来扩展).
|
||||
field_schema: 字段的 schema 定义,用于检查如 "enum" 之类的约束。
|
||||
logger_param: 可选的 logger 实例。
|
||||
Returns:
|
||||
一个与 original_type 不匹配的值。
|
||||
"""
|
||||
effective_logger = logger_param or logger
|
||||
|
||||
# 优先考虑 schema 中的 enum,选择一个不在 enum 中且类型不匹配的值
|
||||
if field_schema and "enum" in field_schema and isinstance(field_schema["enum"], list):
|
||||
enum_values = field_schema["enum"]
|
||||
if original_type == "string":
|
||||
if 123 not in enum_values: return 123
|
||||
if False not in enum_values: return False
|
||||
# 如果数字和布尔都在枚举中,尝试一个与已知枚举值不同的字符串
|
||||
# (虽然这仍然是字符串类型,但目的是为了触发非枚举值的验证)
|
||||
# 或者,如果目的是严格类型不匹配,这里应该返回非字符串。
|
||||
# 当前逻辑倾向于返回一个肯定非字符串的值。
|
||||
elif original_type == "integer":
|
||||
if "not-an-integer" not in enum_values: return "not-an-integer"
|
||||
if 3.14 not in enum_values: return 3.14
|
||||
elif original_type == "number": # Includes float/double
|
||||
if "not-a-number" not in enum_values: return "not-a-number"
|
||||
elif original_type == "boolean":
|
||||
if "not-a-boolean" not in enum_values: return "not-a-boolean"
|
||||
if 1 not in enum_values: return 1
|
||||
# 如果枚举覆盖了所有简单备选,则回退到下面的通用逻辑
|
||||
|
||||
# 通用类型不匹配逻辑 (当 enum 不存在或 enum 检查未返回时)
|
||||
if original_type == "string":
|
||||
return 12345 # Number instead of string
|
||||
elif original_type == "integer":
|
||||
return "not-an-integer" # String instead of integer
|
||||
elif original_type == "number": # Includes float/double
|
||||
return "not-a-number" # String instead of number
|
||||
elif original_type == "boolean":
|
||||
return "not-a-boolean" # String instead of boolean
|
||||
elif original_type == "array":
|
||||
return {"value": "not-an-array"} # Object instead of array
|
||||
elif original_type == "object":
|
||||
return ["not", "an", "object"] # Array instead of object
|
||||
|
||||
effective_logger.warning(f"generate_mismatched_value: 原始类型 '{original_type}' 未知或无法生成不匹配值。将返回固定字符串 'mismatch_test_default'。")
|
||||
return "mismatch_test_default" # Fallback for unknown types
|
||||
|
||||
def build_object_schema_for_params(params_spec_list: List[Dict[str, Any]], model_name_base: str, logger_param: Optional[logging.Logger] = None) -> Tuple[Optional[Dict[str, Any]], str]:
|
||||
"""
|
||||
从参数规范列表构建一个对象的JSON schema,主要用于请求体、查询参数或头部的聚合。
|
||||
Args:
|
||||
params_spec_list: 参数规范的列表 (例如,OpenAPI参数对象列表)。
|
||||
model_name_base: 用于生成动态模型名称的基础字符串。
|
||||
logger_param: 可选的 logger 实例。
|
||||
Returns:
|
||||
一个元组,包含 (构建的JSON object schema 或 None, 模型名称字符串)。
|
||||
"""
|
||||
effective_logger = logger_param or logger # Use passed logger or module logger
|
||||
|
||||
if not params_spec_list:
|
||||
effective_logger.debug(f"参数列表为空,无需为 '{model_name_base}' 构建对象 schema。")
|
||||
return None, f"{model_name_base}EmptyParams"
|
||||
|
||||
properties = {}
|
||||
required_fields = []
|
||||
|
||||
for param_spec in params_spec_list:
|
||||
param_name = param_spec.get("name")
|
||||
if not param_name:
|
||||
effective_logger.warning(f"参数规范缺少 'name' 字段,已跳过: {param_spec}")
|
||||
continue
|
||||
|
||||
# 从参数规范中提取 schema (OpenAPI 3.x)
|
||||
param_schema = param_spec.get("schema")
|
||||
if not param_schema:
|
||||
# 尝试兼容 OpenAPI 2.0 (Swagger) 的情况,其中类型信息直接在参数级别
|
||||
# 例如: type, format, items, default, enum 等
|
||||
# https://swagger.io/specification/v2/#parameterObject
|
||||
# 注意: 这种兼容性可能不完整,因为很多属性需要映射
|
||||
compatible_schema = {}
|
||||
if "type" in param_spec:
|
||||
compatible_schema["type"] = param_spec["type"]
|
||||
if "format" in param_spec:
|
||||
compatible_schema["format"] = param_spec["format"]
|
||||
if "items" in param_spec: # for array types
|
||||
compatible_schema["items"] = param_spec["items"]
|
||||
if "default" in param_spec:
|
||||
compatible_schema["default"] = param_spec["default"]
|
||||
if "enum" in param_spec:
|
||||
compatible_schema["enum"] = param_spec["enum"]
|
||||
# 其他如 description, example 等也可以考虑加入
|
||||
if compatible_schema: # 如果至少收集到了一些类型信息
|
||||
param_schema = compatible_schema
|
||||
effective_logger.debug(f"参数 '{param_name}' 没有 'schema' 字段,但从顶级字段构建了兼容 schema: {param_schema}")
|
||||
else:
|
||||
effective_logger.warning(f"参数 '{param_name}' 缺少 'schema' 字段且无法构建兼容schema,已跳过。规范: {param_spec}")
|
||||
continue
|
||||
|
||||
properties[param_name] = param_schema
|
||||
if param_spec.get("required", False):
|
||||
required_fields.append(param_name)
|
||||
|
||||
if not properties:
|
||||
effective_logger.debug(f"未能从参数列表为 '{model_name_base}' 提取任何属性。")
|
||||
return None, f"{model_name_base}NoProps"
|
||||
|
||||
final_schema: Dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": properties
|
||||
}
|
||||
if required_fields:
|
||||
final_schema["required"] = required_fields
|
||||
|
||||
# 生成一个稍微独特的名字,以防多个操作有相同的 param_type
|
||||
# 例如 OperationIdQueryRequest, OperationIdHeaderRequest
|
||||
model_name = f"{model_name_base.replace(' ', '')}Params"
|
||||
effective_logger.debug(f"为 '{model_name_base}' 构建的对象 schema: {final_schema}, 模型名: {model_name}")
|
||||
return final_schema, model_name
|
||||
|
||||
def find_first_simple_type_field_recursive(
|
||||
current_schema: Dict[str, Any],
|
||||
current_path: Optional[List[Union[str, int]]] = None,
|
||||
# full_api_spec_for_refs: Optional[Dict[str, Any]] = None, # Schema is expected to be pre-resolved
|
||||
logger_param: Optional[logging.Logger] = None
|
||||
) -> Optional[Tuple[List[Union[str, int]], str, Dict[str, Any]]]:
|
||||
"""
|
||||
递归地在给定的 schema 中查找第一个简单类型的字段 (string, integer, number, boolean)。
|
||||
这包括查找嵌套在对象或数组中的简单类型字段。
|
||||
|
||||
Args:
|
||||
current_schema: 当前正在搜索的 schema 部分 (应为字典)。
|
||||
current_path: 到达当前 schema 的路径列表 (用于构建完整路径)。
|
||||
logger_param: 可选的 logger 实例。
|
||||
|
||||
Returns:
|
||||
一个元组 (field_path, field_type, field_schema) 如果找到,否则为 None。
|
||||
field_path 是一个列表,表示从根 schema 到找到的字段的路径。
|
||||
field_type 是字段的原始类型字符串 (e.g., "string")。
|
||||
field_schema 是该字段自身的 schema 定义。
|
||||
"""
|
||||
effective_logger = logger_param or logger # Use module logger if specific one not provided
|
||||
path_so_far = current_path if current_path is not None else []
|
||||
|
||||
if not isinstance(current_schema, dict):
|
||||
effective_logger.debug(f"Schema at path {'.'.join(map(str, path_so_far))} is not a dict, cannot search further.")
|
||||
return None
|
||||
|
||||
schema_type = current_schema.get("type")
|
||||
# effective_logger.debug(f"Searching in path: {'.'.join(map(str, path_so_far))}, Schema Type: '{schema_type}'")
|
||||
|
||||
if schema_type == "object":
|
||||
properties = current_schema.get("properties", {})
|
||||
for name, prop_schema in properties.items():
|
||||
if not isinstance(prop_schema, dict):
|
||||
effective_logger.debug(f"Property '{name}' at path {'.'.join(map(str, path_so_far + [name]))} has non-dict schema. Skipping.")
|
||||
continue
|
||||
|
||||
prop_type = prop_schema.get("type")
|
||||
if prop_type in ["string", "integer", "number", "boolean"]:
|
||||
field_path = path_so_far + [name]
|
||||
effective_logger.info(f"Found simple type field: Path={'.'.join(map(str, field_path))}, Type={prop_type}")
|
||||
return field_path, prop_type, prop_schema
|
||||
elif prop_type == "object":
|
||||
found_in_nested_object = find_first_simple_type_field_recursive(
|
||||
prop_schema,
|
||||
path_so_far + [name],
|
||||
logger_param=effective_logger
|
||||
)
|
||||
if found_in_nested_object:
|
||||
return found_in_nested_object
|
||||
elif prop_type == "array":
|
||||
items_schema = prop_schema.get("items")
|
||||
if isinstance(items_schema, dict):
|
||||
# Look for simple type or object within array items
|
||||
item_type = items_schema.get("type")
|
||||
if item_type in ["string", "integer", "number", "boolean"]:
|
||||
field_path = path_so_far + [name, 0] # Target first item of the array
|
||||
effective_logger.info(f"Found simple type field in array item: Path={'.'.join(map(str, field_path))}, Type={item_type}")
|
||||
return field_path, item_type, items_schema
|
||||
elif item_type == "object":
|
||||
# Path to the first item of the array, then recurse into that item's object schema
|
||||
found_in_array_item_object = find_first_simple_type_field_recursive(
|
||||
items_schema,
|
||||
path_so_far + [name, 0],
|
||||
logger_param=effective_logger
|
||||
)
|
||||
if found_in_array_item_object:
|
||||
return found_in_array_item_object
|
||||
|
||||
elif schema_type == "array": # If the current_schema itself is an array (e.g., root schema is an array)
|
||||
items_schema = current_schema.get("items")
|
||||
if isinstance(items_schema, dict):
|
||||
item_type = items_schema.get("type")
|
||||
if item_type in ["string", "integer", "number", "boolean"]:
|
||||
field_path = path_so_far + [0] # Target first item of this root/current array
|
||||
effective_logger.info(f"Found simple type field in root/current array item: Path={'.'.join(map(str, field_path))}, Type={item_type}")
|
||||
return field_path, item_type, items_schema
|
||||
elif item_type == "object":
|
||||
# Path to the first item of this root/current array, then recurse
|
||||
found_in_root_array_item_object = find_first_simple_type_field_recursive(
|
||||
items_schema,
|
||||
path_so_far + [0],
|
||||
logger_param=effective_logger
|
||||
)
|
||||
if found_in_root_array_item_object:
|
||||
return found_in_root_array_item_object
|
||||
|
||||
# effective_logger.debug(f"No simple type field found at path {'.'.join(map(str, path_so_far))}")
|
||||
return None
|
||||
Reference in New Issue
Block a user