This commit is contained in:
gongwenxin
2025-06-16 14:49:49 +08:00
parent adc1a0053f
commit df90a5377f
210 changed files with 323584 additions and 12804 deletions
+69 -26
View File
@@ -256,37 +256,80 @@ class BaseAPITestCase:
# --- 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.
获取请求体schema,利用to_dict()方法已统一的格式
Returns:
请求体的schema定义,如果不存在则返回None
"""
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"]
# endpoint_spec已经是统一格式的字典(由to_dict()方法生成)
request_body = self.endpoint_spec.get("requestBody")
if not request_body or not isinstance(request_body, dict):
self.logger.debug("Request body is None or not a dict")
return None
# 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
# 直接获取content对象中的schema
content = request_body.get("content", {})
if content and isinstance(content, dict):
# 按优先级查找JSON内容类型
for content_type in ["application/json", "application/merge-patch+json", "application/*+json", "*/*"]:
if content_type in content and isinstance(content[content_type], dict):
schema = content[content_type].get("schema")
if schema:
self.logger.debug(f"Found request body schema in content type: {content_type}")
return schema
self.logger.debug("No suitable request body schema found in endpoint_spec.")
self.logger.debug("No suitable request body schema found")
return None
def _get_resolved_response_schema(self, response_spec: Dict[str, Any] = None, status_code: str = "200") -> Optional[Dict[str, Any]]:
"""
获取响应schema,利用to_dict()方法已统一的格式
Args:
response_spec: 响应规范对象,通常是endpoint_spec['responses'][status_code]。如果不提供,将使用状态码从endpoint_spec中获取。
status_code: HTTP状态码,默认为"200",仅当response_spec为None时使用
Returns:
响应的schema定义,如果不存在则返回None
"""
# 如果未提供response_spec,则使用status_code从endpoint_spec中获取
if response_spec is None:
responses = self.endpoint_spec.get("responses", {})
response_spec = responses.get(status_code)
if not response_spec or not isinstance(response_spec, dict):
self.logger.debug(f"Response spec for status code {status_code} is None or not a dict. Cannot extract schema.")
return None
# 直接获取content对象中的schema
content = response_spec.get("content", {})
if content and isinstance(content, dict):
# 按优先级查找JSON内容类型
for content_type in ["application/json", "application/merge-patch+json", "application/*+json", "*/*"]:
if content_type in content and isinstance(content[content_type], dict):
schema = content[content_type].get("schema")
if schema:
self.logger.debug(f"Found response schema in content type: {content_type}")
return schema
self.logger.debug(f"No suitable response schema found for status code {status_code}.")
return None
def _get_resolved_schema(self, schema_or_ref: Dict[str, Any]) -> Dict[str, Any]:
"""
由于to_dict()方法已经解析了所有的$ref引用,此方法仅返回输入的schema
Args:
schema_or_ref: 一个可能包含$ref引用的schema对象(实际上不应该包含$ref)
Returns:
原始schema对象
"""
if isinstance(schema_or_ref, dict) and '$ref' in schema_or_ref:
self.logger.warning(f"Unexpected $ref found in schema: {schema_or_ref['$ref']}. This should have been resolved by to_dict() method.")
return schema_or_ref
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.
+38 -1
View File
@@ -1,10 +1,47 @@
# -*- coding: utf-8 -*-
import logging
import re
from typing import Dict, Any
from typing import Dict, Any, Tuple
import json
logger = logging.getLogger(__name__)
def is_camel_case(s: str) -> bool:
"""
检查字符串是否为小驼峰式命名 (lowerCamelCase)。
小驼峰式命名要求:
1. 以小写字母开头。
2. 后续可以跟字母或数字。
3. 不能包含下划线或短横线。
"""
if not s or not isinstance(s, str):
return False
# 检查是否以小写字母开头,并且不包含下划线或中划线
return re.match(r'^[a-z]+([A-Z0-9][a-z0-9]*)*$', s) is not None
def extract_json_from_response(response: Any) -> Tuple[Any, str]:
"""
从API响应中安全地提取JSON数据。
这个响应可以是一个 requests.Response 对象,也可以是一个字典(模拟的响应)。
"""
if hasattr(response, 'json'): # 类似 requests.Response 对象
try:
return response.json(), ""
except json.JSONDecodeError as e:
return None, f"JSON解析失败: {e}"
except Exception as e:
return None, f"从响应中提取JSON时发生未知错误: {e}"
elif isinstance(response, dict): # 模拟的响应字典
return response, ""
elif isinstance(response, str):
try:
return json.loads(response), ""
except json.JSONDecodeError as e:
return None, f"JSON字符串解析失败: {e}"
return None, "不支持的响应类型"
def format_url_with_path_params(path_template: str, path_params: Dict[str, Any]) -> str:
"""
使用提供的路径参数格式化URL路径模板。
+202 -1
View File
@@ -1,6 +1,7 @@
import logging
import copy
from typing import Dict, List, Any, Optional, Union, Tuple
import re
# 获取模块级别的 logger
logger = logging.getLogger(__name__)
@@ -609,4 +610,204 @@ def find_first_simple_type_field_recursive(
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
return None
def util_extract_range_from_description(description: str, logger_param: Optional[logging.Logger] = None) -> Tuple[Optional[float], Optional[float]]:
"""从描述文本中提取数值范围信息。"""
effective_logger = logger_param or logger
if not description:
return None, None
min_value, max_value = None, None
MIN_VALUE_PATTERNS = [
r'(最小|至少|从|大于等于|不小于|>=|>|minimum|min).*?(\d+(\.\d+)?)',
r'(\d+(\.\d+)?).*?(起|开始|以上|最小|minimum|min)',
]
MAX_VALUE_PATTERNS = [
r'(最大|至多|不超过|不大于|小于等于|<=|<|maximum|max).*?(\d+(\.\d+)?)',
r'(\d+(\.\d+)?).*?(以下|以内|之内|最大|maximum|max)',
]
for pattern in MIN_VALUE_PATTERNS:
matches = re.search(pattern, description, re.IGNORECASE)
if matches:
try:
min_value = float(matches.group(2))
effective_logger.debug(f"'{description}' 中提取到最小值: {min_value}")
break
except (ValueError, IndexError):
continue
for pattern in MAX_VALUE_PATTERNS:
matches = re.search(pattern, description, re.IGNORECASE)
if matches:
try:
max_value = float(matches.group(2))
effective_logger.debug(f"'{description}' 中提取到最大值: {max_value}")
break
except (ValueError, IndexError):
continue
return min_value, max_value
def util_find_ranged_field_recursive(
schema: Dict[str, Any],
path: List[Union[str, int]],
logger_param: Optional[logging.Logger] = None
) -> Optional[Tuple[List[Union[str, int]], Dict[str, Any], Optional[str], Optional[float], Optional[float], str]]:
"""
递归地在schema中寻找第一个具有范围限制的字段。
返回 (路径, schema, 类型, 最小值, 最大值, 描述)。
"""
effective_logger = logger_param or logger
if not isinstance(schema, dict):
return None
schema_type = schema.get('type')
description = schema.get('description', '')
# 检查描述和schema关键字
min_val, max_val = util_extract_range_from_description(description, effective_logger)
schema_min = schema.get('minimum')
schema_max = schema.get('maximum')
if schema_min is not None:
min_val = schema_min
if schema_max is not None:
max_val = schema_max
# 如果找到范围限制,则认为这是一个目标字段
if min_val is not None or max_val is not None:
effective_logger.debug(f"在路径 {'.'.join(map(str, path))} 找到范围受限字段。")
return path, schema, schema_type, min_val, max_val, description
# 递归
if schema_type == 'object' and 'properties' in schema:
for prop_name, prop_schema in schema['properties'].items():
result = util_find_ranged_field_recursive(prop_schema, path + [prop_name], effective_logger)
if result:
return result
if schema_type == 'array' and 'items' in schema and isinstance(schema['items'], dict):
result = util_find_ranged_field_recursive(schema['items'], path + [0], effective_logger)
if result:
return result
return None
def util_extract_enum_from_description(description: str, logger: logging.Logger) -> Optional[List[str]]:
"""
从描述字符串中提取枚举值。
例如: "排序字段,key=字段名,value=排序方式(可选值为:ASC、DESC)" -> ["ASC", "DESC"]
"""
if not isinstance(description, str):
return None
# 正则表达式匹配 "可选值为" 后面的内容,支持中文冒号和英文冒号,以及括号
# 匹配直到行尾或遇到另一个右括号
patterns = [
r"可选值为[|:]([\w\s,、,]+)",
r"可选值 *[:] *([\w\s,、,]+)",
r"\(可选值为[|:](.*?)\)",
r"(可选值为[|:](.*?)\"
]
found_values = None
for pattern in patterns:
match = re.search(pattern, description)
if match:
found_values = match.group(1).strip()
break
if not found_values:
return None
# 按逗号、顿号、空格分割,并去除每个值前后的空白
enums = [item.strip() for item in re.split(r'[,\s、,]+', found_values) if item.strip()]
if enums:
logger.debug(f"Extracted enums: {enums} from description: '{description[:50]}...'")
return enums
return None
def util_find_enum_field_recursive(schema: Dict[str, Any], path: List[Union[str, int]], logger: logging.Logger) -> Optional[Tuple[List[Union[str, int]], List[str], str]]:
"""
递归查找第一个包含可解析枚举值的字段。
"""
if not isinstance(schema, dict):
return None
# 优先检查当前层级的 description
description = schema.get("description", "")
enums = util_extract_enum_from_description(description, logger)
if enums:
return path, enums, description
# 如果当前层级是对象,则递归其属性
if schema.get("type") == "object" and "properties" in schema:
for prop_name, prop_schema in schema.get("properties", {}).items():
new_path = path + [prop_name]
result = util_find_enum_field_recursive(prop_schema, new_path, logger)
if result:
return result
# 如果当前层级是数组,则递归其 items
if schema.get("type") == "array" and "items" in schema:
# 假设我们只检查数组项的结构,路径中用 '[]' 或 index 0 表示
new_path = path + [0]
result = util_find_enum_field_recursive(schema["items"], new_path, logger)
if result:
return result
return None
def normalize_yapi_responses(responses: dict, method: str) -> dict:
"""
将YAPI格式的响应规范标准化为OpenAPI格式。
如果responses为空或不包含任何成功状态码(2XX),则根据请求方法添加默认状态码。
Args:
responses: YAPI或已解析的响应规范字典
method: HTTP请求方法 (GET, POST, PUT, DELETE等)
Returns:
标准化后的响应规范字典
"""
if not responses:
responses = {}
# 检查是否已经包含成功状态码
has_success_code = False
for code in responses.keys():
if code.startswith('2') or code == 'default':
has_success_code = True
break
# 如果没有成功状态码,根据请求方法添加默认状态码
if not has_success_code:
if method.upper() == 'POST':
# POST请求默认使用201 Created
default_code = '201'
default_desc = 'Created'
elif method.upper() == 'DELETE':
# DELETE请求默认使用204 No Content
default_code = '204'
default_desc = 'No Content'
else:
# GET, PUT, PATCH等默认使用200 OK
default_code = '200'
default_desc = 'OK'
# 从responses中查找default响应,如果存在则复制其内容
default_response = responses.get('default', {})
if not default_response:
default_response = {'description': default_desc}
elif 'description' not in default_response:
default_response['description'] = default_desc
responses[default_code] = default_response
return responses