mvp
This commit is contained in:
Binary file not shown.
@@ -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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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路径模板。
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user