This commit is contained in:
gongwenxin
2025-05-26 15:38:37 +08:00
parent 4180a0ce81
commit 6dde4d73e0
29 changed files with 3552 additions and 1265 deletions
+451 -325
View File
@@ -1,355 +1,481 @@
"""Input Parser Module"""
import json
import os
from typing import Any, Dict, Optional, List, Union
from pydantic import BaseModel # For defining the structure of parsed inputs
from dataclasses import dataclass, field
import logging
from typing import List, Dict, Any, Optional, Union # Ensure Union is imported
logger = logging.getLogger("InputParser")
logger = logging.getLogger(__name__)
class ParsedOpenAPISpec(BaseModel):
# Placeholder for OpenAPI spec details relevant to the compliance suite
spec: Dict[str, Any]
info: Dict[str, Any] # Swagger 'info' object with title, version, etc.
paths: Dict[str, Dict[str, Any]] # API paths and their operations
tags: Optional[List[Dict[str, str]]] = None # API tags
basePath: Optional[str] = None # Base path for all APIs
swagger_version: str # Swagger specification version
class BaseEndpoint:
"""所有端点对象的基类,可以包含一些通用属性或方法。"""
def __init__(self, method: str, path: str):
self.method = method
self.path = path
@dataclass
class YAPIEndpoint:
"""YAPI API端点信息"""
path: str
method: str
title: str = ""
description: str = ""
category_name: str = ""
req_params: List[Dict[str, Any]] = field(default_factory=list)
req_query: List[Dict[str, Any]] = field(default_factory=list)
req_headers: List[Dict[str, Any]] = field(default_factory=list)
req_body_type: str = ""
req_body_other: str = ""
res_body_type: str = ""
res_body: str = ""
def to_dict(self) -> Dict[str, Any]:
# 基类可以提供一个默认的 to_dict 实现或要求子类实现
raise NotImplementedError("Subclasses must implement to_dict")
@dataclass
class ParsedYAPISpec:
class YAPIEndpoint(BaseEndpoint): # Inherit from BaseEndpoint
def __init__(self, data: Dict[str, Any], category_name: Optional[str] = None, category_id: Optional[int] = None):
super().__init__(method=data.get("method", "GET").upper(), path=data.get("path", ""))
self._raw_data = data
self.title: str = data.get("title", "")
self.desc: Optional[str] = data.get("desc")
self._id: int = data.get("_id")
self.project_id: int = data.get("project_id")
self.catid: int = data.get("catid")
self.req_params: List[Dict[str, Any]] = data.get("req_params", [])
self.req_query: List[Dict[str, Any]] = data.get("req_query", [])
self.req_headers: List[Dict[str, Any]] = data.get("req_headers", [])
self.req_body_form: List[Dict[str, Any]] = data.get("req_body_form", [])
self.req_body_type: Optional[str] = data.get("req_body_type")
self.req_body_is_json_schema: bool = data.get("req_body_is_json_schema", False)
self.req_body_other: Optional[str] = data.get("req_body_other")
self.res_body_type: Optional[str] = data.get("res_body_type")
self.res_body_is_json_schema: bool = data.get("res_body_is_json_schema", False)
self.res_body: Optional[str] = data.get("res_body")
self.status: str = data.get("status", "undone")
self.api_opened: bool = data.get("api_opened", False)
self.uid: int = data.get("uid")
self.category_name = category_name
self.category_id = category_id if category_id is not None else self.catid
self._parsed_req_body_schema: Optional[Dict[str, Any]] = None
if self.req_body_type == "json" and self.req_body_other and self.req_body_is_json_schema:
try:
self._parsed_req_body_schema = json.loads(self.req_body_other)
except json.JSONDecodeError as e:
logger.error(f"YAPIEndpoint (ID: {self._id}, Title: {self.title}): Failed to parse req_body_other as JSON during init: {e}. Content: {self.req_body_other[:200]}")
self._parsed_res_body_schema: Optional[Dict[str, Any]] = None
if self.res_body_type == "json" and self.res_body and self.res_body_is_json_schema:
try:
self._parsed_res_body_schema = json.loads(self.res_body)
except json.JSONDecodeError as e:
logger.error(f"YAPIEndpoint (ID: {self._id}, Title: {self.title}): Failed to parse res_body as JSON during init: {e}. Content: {self.res_body[:200]}")
def to_dict(self) -> Dict[str, Any]:
endpoint_dict = {
"method": self.method,
"path": self.path,
"title": self.title,
"summary": self.title,
"description": self.desc or "",
"operationId": f"{self.method.lower()}_{self.path.replace('/', '_').replace('{', '').replace('}', '')}_{self._id}",
"tags": [self.category_name or str(self.catid)],
"parameters": [],
"requestBody": None,
"responses": {},
"_source_format": "yapi",
"_yapi_id": self._id,
"_yapi_raw_data": self._raw_data # Keep raw data for debugging or deeper inspection if needed
}
# Path parameters from req_params
for p_spec in self.req_params:
param_name = p_spec.get("name")
if not param_name: continue
endpoint_dict["parameters"].append({
"name": param_name,
"in": "path",
"required": True, # Path parameters are always required
"description": p_spec.get("desc", ""),
"schema": {"type": "string", "example": p_spec.get("example", f"example_{param_name}")}
})
# Query parameters from req_query
for q_spec in self.req_query:
param_name = q_spec.get("name")
if not param_name: continue
is_required = q_spec.get("required") == "1" # YAPI uses "1" for true
param_schema = {"type": "string"} # Default to string, YAPI doesn't specify types well here
if "example" in q_spec: param_schema["example"] = q_spec["example"]
# Add other fields from YAPI query spec if needed (e.g., desc)
endpoint_dict["parameters"].append({
"name": param_name,
"in": "query",
"required": is_required,
"description": q_spec.get("desc", ""),
"schema": param_schema
})
# Header parameters from req_headers
for h_spec in self.req_headers:
param_name = h_spec.get("name")
if not param_name or param_name.lower() == 'content-type': continue # Content-Type is handled by requestBody
is_required = h_spec.get("required") == "1"
default_value = h_spec.get("value") # YAPI uses 'value' for default/example header value
param_schema = {"type": "string"}
if default_value:
if is_required: # If required, it's more like an example of what's expected
param_schema["example"] = default_value
else: # If not required, it's a default value
param_schema["default"] = default_value
endpoint_dict["parameters"].append({
"name": param_name,
"in": "header",
"required": is_required,
"description": h_spec.get("desc", ""),
"schema": param_schema
})
# Request body
if self.req_body_type == "json" and self._parsed_req_body_schema:
endpoint_dict["requestBody"] = {
"content": {
"application/json": {
"schema": self._parsed_req_body_schema
}
}
}
elif self.req_body_type == "form" and self.req_body_form:
properties = {}
required_form_params = []
for form_param in self.req_body_form:
name = form_param.get("name")
if not name: continue
properties[name] = {
"type": "string", # YAPI form params are typically strings, file uploads are different
"description": form_param.get("desc","")
}
if form_param.get("example"): properties[name]["example"] = form_param.get("example")
if form_param.get("required") == "1": required_form_params.append(name)
endpoint_dict["requestBody"] = {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"properties": properties,
"required": required_form_params if required_form_params else None # OpenAPI: omit if empty
}
}
# YAPI also supports req_body_type = 'file', which would map to multipart/form-data
# This example focuses on json and basic form.
}
}
# Add other req_body_types if necessary (e.g., raw, file)
# Responses
# YAPI has a simpler response structure. We'll map its res_body to a default success response (e.g., 200 or 201).
default_success_status = "200"
if self.method == "POST": default_success_status = "201" # Common practice for POST success
if self.res_body_type == "json" and self._parsed_res_body_schema:
endpoint_dict["responses"][default_success_status] = {
"description": "Successful Operation (from YAPI res_body)",
"content": {
"application/json": {
"schema": self._parsed_res_body_schema
}
}
}
elif self.res_body_type == "json" and not self._parsed_res_body_schema and self.res_body: # Schema parsing failed but text exists
endpoint_dict["responses"][default_success_status] = {
"description": "Successful Operation (Schema parsing error, raw text might be available)",
"content": {"application/json": {"schema": {"type": "object", "description": "Schema parsing failed for YAPI res_body."}}} # Placeholder
}
else: # No JSON schema, or other res_body_type
endpoint_dict["responses"][default_success_status] = {
"description": "Successful Operation (No specific schema provided in YAPI for this response)"
}
# Ensure there's always a default response if nothing specific was added
if not endpoint_dict["responses"]:
endpoint_dict["responses"]["default"] = {"description": "Default response from YAPI definition"}
return endpoint_dict
def __repr__(self):
return f"<YAPIEndpoint ID:{self._id} Method:{self.method} Path:{self.path} Title:'{self.title}'>"
class SwaggerEndpoint(BaseEndpoint): # Inherit from BaseEndpoint
def __init__(self, path: str, method: str, data: Dict[str, Any], global_spec: Dict[str, Any]):
super().__init__(method=method.upper(), path=path)
self._raw_data = data
self._global_spec = global_spec # Store for $ref resolution
self.summary: Optional[str] = data.get("summary")
self.description: Optional[str] = data.get("description")
self.operation_id: Optional[str] = data.get("operationId")
self.tags: List[str] = data.get("tags", [])
# Parameters, requestBody, responses are processed by to_dict
def _resolve_ref(self, ref_path: str) -> Optional[Dict[str, Any]]:
"""Resolves a $ref path within the global OpenAPI/Swagger spec."""
if not ref_path.startswith("#/"):
logger.warning(f"Unsupported $ref path: {ref_path}. Only local refs '#/...' are currently supported.")
return None
parts = ref_path[2:].split('/') # Remove '#/' and split
current_level = self._global_spec
try:
for part in parts:
# Decode URI component encoding if present (e.g. "~0" for "~", "~1" for "/")
part = part.replace("~1", "/").replace("~0", "~")
current_level = current_level[part]
# It's crucial to return a copy if the resolved ref will be modified,
# or ensure modifications happen on copies later.
# For now, returning as is, assuming downstream processing is careful or uses copies.
if isinstance(current_level, dict):
return current_level # Potentially json.loads(json.dumps(current_level)) for a deep copy
else: # Resolved to a non-dict, which might be valid for some simple refs but unusual for schemas
logger.warning(f"$ref '{ref_path}' resolved to a non-dictionary type: {type(current_level)}. Value: {str(current_level)[:100]}")
return {"type": "string", "description": f"Resolved $ref '{ref_path}' to non-dict: {str(current_level)[:100]}"} # Placeholder
except (KeyError, TypeError, AttributeError) as e:
logger.error(f"Failed to resolve $ref '{ref_path}': {e}", exc_info=True)
return None
def _process_schema_or_ref(self, schema_like: Any) -> Optional[Dict[str, Any]]:
"""
Processes a schema part, resolving $refs and recursively processing nested structures.
Returns a new dictionary with resolved refs, or None if resolution fails badly.
"""
if not isinstance(schema_like, dict):
if schema_like is None: return None
logger.warning(f"Expected a dictionary for schema processing, got {type(schema_like)}. Value: {str(schema_like)[:100]}")
return {"type": "string", "description": f"Schema was not a dict: {str(schema_like)[:100]}"} # Placeholder for non-dict schema
# If it's a $ref, resolve it.
if "$ref" in schema_like:
return self._resolve_ref(schema_like["$ref"]) # This will be the new base schema_like
# Create a copy to avoid modifying the original spec during processing
processed_schema = schema_like.copy()
# Recursively process 'properties' for object schemas
if "properties" in processed_schema and isinstance(processed_schema["properties"], dict):
new_properties = {}
for prop_name, prop_schema in processed_schema["properties"].items():
resolved_prop = self._process_schema_or_ref(prop_schema)
if resolved_prop is not None: # Only add if resolution was successful
new_properties[prop_name] = resolved_prop
# else: logger.warning(f"Failed to process property '{prop_name}' in {self.operation_id or self.path}")
processed_schema["properties"] = new_properties
# Recursively process 'items' for array schemas
if "items" in processed_schema and isinstance(processed_schema["items"], dict): # 'items' should be a schema object
resolved_items = self._process_schema_or_ref(processed_schema["items"])
if resolved_items is not None:
processed_schema["items"] = resolved_items
# else: logger.warning(f"Failed to process 'items' schema in {self.operation_id or self.path}")
# Handle allOf, anyOf, oneOf by trying to merge or process them (simplistic merge for allOf)
# This is a complex area of JSON Schema. This is a very basic attempt.
if "allOf" in processed_schema and isinstance(processed_schema["allOf"], list):
merged_all_of_props = {}
merged_all_of_required = set()
temp_schema_for_all_of = {"type": processed_schema.get("type", "object"), "properties": {}, "required": []}
for sub_schema_data in processed_schema["allOf"]:
resolved_sub_schema = self._process_schema_or_ref(sub_schema_data)
if resolved_sub_schema and isinstance(resolved_sub_schema, dict):
if "properties" in resolved_sub_schema:
temp_schema_for_all_of["properties"].update(resolved_sub_schema["properties"])
if "required" in resolved_sub_schema and isinstance(resolved_sub_schema["required"], list):
merged_all_of_required.update(resolved_sub_schema["required"])
# Copy other top-level keywords from the resolved_sub_schema if needed, e.g. description
for key, value in resolved_sub_schema.items():
if key not in ["properties", "required", "type", "$ref", "allOf", "anyOf", "oneOf"]:
if key not in temp_schema_for_all_of or temp_schema_for_all_of[key] is None: # prioritize existing
temp_schema_for_all_of[key] = value
if temp_schema_for_all_of["properties"]:
processed_schema["properties"] = {**processed_schema.get("properties",{}), **temp_schema_for_all_of["properties"]}
if merged_all_of_required:
current_required = set(processed_schema.get("required", []))
current_required.update(merged_all_of_required)
processed_schema["required"] = sorted(list(current_required))
del processed_schema["allOf"] # Remove allOf after processing
# Copy other merged attributes back to processed_schema
for key, value in temp_schema_for_all_of.items():
if key not in ["properties", "required", "type", "$ref", "allOf", "anyOf", "oneOf"]:
if key not in processed_schema or processed_schema[key] is None:
processed_schema[key] = value
# anyOf, oneOf are harder as they represent choices. For now, we might just list them or pick first.
# For simplicity in to_dict, we might not fully expand them but ensure refs inside are resolved.
for keyword in ["anyOf", "oneOf"]:
if keyword in processed_schema and isinstance(processed_schema[keyword], list):
processed_sub_list = []
for sub_item in processed_schema[keyword]:
resolved_sub = self._process_schema_or_ref(sub_item)
if resolved_sub:
processed_sub_list.append(resolved_sub)
if processed_sub_list: # only update if some were resolved
processed_schema[keyword] = processed_sub_list
return processed_schema
def to_dict(self) -> Dict[str, Any]:
endpoint_data = {
"method": self.method,
"path": self.path,
"summary": self.summary or "",
"title": self.summary or self.operation_id or "", # Fallback for title
"description": self.description or "",
"operationId": self.operation_id or f"{self.method.lower()}_{self.path.replace('/', '_').replace('{', '').replace('}', '')}",
"tags": self.tags,
"parameters": [],
"requestBody": None,
"responses": {},
"_source_format": "swagger/openapi",
"_swagger_raw_data": self._raw_data, # Keep raw for debugging
"_global_api_spec_for_resolution": self._global_spec # For test cases that might need to resolve further
}
# Process parameters
if "parameters" in self._raw_data and isinstance(self._raw_data["parameters"], list):
for param_data_raw in self._raw_data["parameters"]:
# Each param_data_raw could itself be a $ref or contain a schema that is a $ref
processed_param_container = self._process_schema_or_ref(param_data_raw)
if processed_param_container and isinstance(processed_param_container, dict):
# If the parameter itself was a $ref, processed_param_container is the resolved object.
# If it contained a schema that was a $ref, that nested schema should be resolved.
# We need to ensure 'schema' key exists if 'in' is path, query, header
if "schema" in processed_param_container and isinstance(processed_param_container["schema"], dict):
# schema was present, process it further (it might have been already by _process_schema_or_ref if it was a complex object)
# but if _process_schema_or_ref was called on param_data_raw which wasn't a ref itself,
# the internal 'schema' ref might not have been re-processed with full context.
# However, the recursive nature of _process_schema_or_ref should handle nested $refs.
pass # Assume it's processed by the main call to _process_schema_or_ref on param_data_raw
elif "content" in processed_param_container: # Parameter described by Content Object (OpenAPI 3.x)
pass # Content object schemas should have been resolved by _process_schema_or_ref
endpoint_data["parameters"].append(processed_param_container)
# Process requestBody
if "requestBody" in self._raw_data and isinstance(self._raw_data["requestBody"], dict):
processed_rb = self._process_schema_or_ref(self._raw_data["requestBody"])
if processed_rb:
endpoint_data["requestBody"] = processed_rb
# Process responses
if "responses" in self._raw_data and isinstance(self._raw_data["responses"], dict):
for status_code, resp_data_raw in self._raw_data["responses"].items():
processed_resp = self._process_schema_or_ref(resp_data_raw)
if processed_resp:
endpoint_data["responses"][status_code] = processed_resp
elif resp_data_raw: # If processing failed but raw exists, keep raw (though this is less ideal)
endpoint_data["responses"][status_code] = resp_data_raw
logger.warning(f"Kept raw response data for {status_code} due to processing failure for {self.operation_id or self.path}")
if not endpoint_data["responses"]: # Ensure default response if none processed
endpoint_data["responses"]["default"] = {"description": "Default response from Swagger/OpenAPI definition"}
return endpoint_data
def __repr__(self):
return f"<SwaggerEndpoint Method:{self.method} Path:{self.path} Summary:'{self.summary}'>"
class ParsedAPISpec:
"""解析后的API规范的通用基类"""
def __init__(self, spec_type: str, endpoints: List[Union[YAPIEndpoint, SwaggerEndpoint]], spec: Dict[str, Any]):
self.spec_type = spec_type
self.endpoints = endpoints
self.spec = spec # Store the original full spec dictionary, useful for $ref resolution if not pre-resolved
class ParsedYAPISpec(ParsedAPISpec):
"""解析后的YAPI规范"""
endpoints: List[YAPIEndpoint]
categories: List[Dict[str, Any]]
total_count: int
def __init__(self, endpoints: List[YAPIEndpoint], categories: List[Dict[str, Any]], spec: Dict[str, Any]):
super().__init__(spec_type="yapi", endpoints=endpoints, spec=spec)
self.categories = categories
@dataclass
class SwaggerEndpoint:
"""Swagger API端点信息"""
path: str
method: str
summary: str = ""
description: str = ""
operation_id: str = ""
tags: List[str] = field(default_factory=list)
parameters: List[Dict[str, Any]] = field(default_factory=list)
responses: Dict[str, Any] = field(default_factory=dict)
consumes: List[str] = field(default_factory=list)
produces: List[str] = field(default_factory=list)
request_body: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ParsedSwaggerSpec:
"""解析后的Swagger规范"""
endpoints: List[SwaggerEndpoint]
info: Dict[str, Any]
swagger_version: str
host: str = ""
base_path: str = ""
schemes: List[str] = field(default_factory=list)
tags: List[Dict[str, Any]] = field(default_factory=list)
categories: List[Dict[str, Any]] = field(default_factory=list)
class ParsedBusinessLogic(BaseModel):
# Placeholder for parsed business logic flow
name: str
steps: list # List of steps, each could be another Pydantic model
class ParsedSwaggerSpec(ParsedAPISpec):
"""解析后的Swagger/OpenAPI规范"""
def __init__(self, endpoints: List[SwaggerEndpoint], tags: List[Dict[str, Any]], spec: Dict[str, Any]):
super().__init__(spec_type="swagger", endpoints=endpoints, spec=spec)
self.tags = tags
class InputParser:
"""
Responsible for parsing DDMS supplier's input materials like API specs, etc.
"""
"""负责解析输入(如YAPI JSON)并提取API端点信息"""
def __init__(self):
pass
def parse_openapi_spec(self, spec_path: str) -> Optional[ParsedOpenAPISpec]:
"""
Parses an OpenAPI specification from a file path.
Args:
spec_path: The file path of the OpenAPI specification.
Returns:
A ParsedOpenAPISpec object containing the parsed specification,
or None if parsing fails.
"""
try:
# Check if file exists
if not os.path.exists(spec_path):
print(f"Error: File not found: {spec_path}")
return None
# Read and parse JSON file
with open(spec_path, 'r', encoding='utf-8') as f:
swagger_data = json.load(f)
# Extract basic information
swagger_version = swagger_data.get('swagger', swagger_data.get('openapi', 'Unknown'))
info = swagger_data.get('info', {})
paths = swagger_data.get('paths', {})
tags = swagger_data.get('tags', [])
base_path = swagger_data.get('basePath', '')
# Create and return ParsedOpenAPISpec
return ParsedOpenAPISpec(
spec=swagger_data,
info=info,
paths=paths,
tags=tags,
basePath=base_path,
swagger_version=swagger_version
)
except FileNotFoundError:
print(f"File not found: {spec_path}")
return None
except json.JSONDecodeError as e:
print(f"Error parsing JSON from {spec_path}: {e}")
return None
except Exception as e:
print(f"Error parsing OpenAPI spec from {spec_path}: {e}")
return None
self.logger = logging.getLogger(__name__)
def parse_yapi_spec(self, file_path: str) -> Optional[ParsedYAPISpec]:
"""
解析YAPI规范文件
Args:
file_path: YAPI JSON文件路径
Returns:
Optional[ParsedYAPISpec]: 解析后的YAPI规范,如果解析失败则返回None
"""
if not os.path.isfile(file_path):
logger.error(f"文件不存在: {file_path}")
return None
self.logger.info(f"Parsing YAPI spec from: {file_path}")
all_endpoints: List[YAPIEndpoint] = []
yapi_categories: List[Dict[str, Any]] = []
raw_spec_data_list: Optional[List[Dict[str, Any]]] = None # YAPI export is a list of categories
try:
with open(file_path, 'r', encoding='utf-8') as f:
yapi_data = json.load(f)
if not isinstance(yapi_data, list):
logger.error(f"无效的YAPI文件格式: 顶层元素应该是数组")
raw_spec_data_list = json.load(f)
if not isinstance(raw_spec_data_list, list):
self.logger.error(f"YAPI spec file {file_path} does not contain a JSON list as expected for categories.")
return None
endpoints = []
categories = []
# 处理分类
for category_data in yapi_data:
for category_data in raw_spec_data_list:
if not isinstance(category_data, dict):
logger.warning(f"YAPI 分类条目格式不正确,应为字典类型,已跳过: {category_data}")
self.logger.warning(f"Skipping non-dictionary item in YAPI spec list: {str(category_data)[:100]}")
continue
cat_name = category_data.get("name")
cat_id = category_data.get("_id", category_data.get("id")) # YAPI uses _id
yapi_categories.append({"name": cat_name, "description": category_data.get("desc"), "id": cat_id})
category_name = category_data.get('name', '')
category_desc = category_data.get('desc', '')
# 添加到分类列表
categories.append({
'name': category_name,
'desc': category_desc
})
# 处理API接口
api_list = category_data.get('list', [])
if not isinstance(api_list, list):
logger.warning(f"分类 '{category_name}' 中的 API列表 (list) 格式不正确,应为数组类型,已跳过。")
continue
for api_item in api_list:
if not isinstance(api_item, dict):
logger.warning(f"分类 '{category_name}' 中的 API条目格式不正确,应为字典类型,已跳过: {api_item}")
for endpoint_data in category_data.get("list", []):
if not isinstance(endpoint_data, dict):
self.logger.warning(f"Skipping non-dictionary endpoint item in category '{cat_name}': {str(endpoint_data)[:100]}")
continue
# 提取API信息
path = api_item.get('path', '')
if not path:
logger.info(f"分类 '{category_name}' 中的 API条目缺少 'path',使用空字符串。 API: {api_item.get('title', '未命名')}")
method = api_item.get('method', 'GET')
if api_item.get('method') is None: # 仅当原始数据中完全没有 method 字段时记录
logger.info(f"分类 '{category_name}' 中的 API条目 '{path}' 缺少 'method',使用默认值 'GET'")
title = api_item.get('title', '')
if not title:
logger.info(f"分类 '{category_name}' 中的 API条目 '{path}' ({method}) 缺少 'title',使用空字符串。")
description = api_item.get('desc', '')
# 提取请求参数
req_params = api_item.get('req_params', [])
req_query = api_item.get('req_query', [])
req_headers = api_item.get('req_headers', [])
# 提取请求体信息
req_body_type = api_item.get('req_body_type', '')
req_body_other = api_item.get('req_body_other', '')
# 提取响应体信息
res_body_type = api_item.get('res_body_type', '')
res_body = api_item.get('res_body', '')
# 创建端点对象
endpoint = YAPIEndpoint(
path=path,
method=method,
title=title,
description=description,
category_name=category_name,
req_params=req_params,
req_query=req_query,
req_headers=req_headers,
req_body_type=req_body_type,
req_body_other=req_body_other,
res_body_type=res_body_type,
res_body=res_body
)
endpoints.append(endpoint)
return ParsedYAPISpec(
endpoints=endpoints,
categories=categories,
total_count=len(endpoints)
)
try:
yapi_endpoint = YAPIEndpoint(endpoint_data, category_name=cat_name, category_id=cat_id)
all_endpoints.append(yapi_endpoint)
except Exception as e_ep:
self.logger.error(f"Error processing YAPI endpoint data (ID: {endpoint_data.get('_id', 'N/A')}, Title: {endpoint_data.get('title', 'N/A')}). Error: {e_ep}", exc_info=True)
# The 'spec' for ParsedYAPISpec should be a dict representing the whole document.
# Since YAPI export is a list of categories, we wrap it.
yapi_full_spec_dict = {"yapi_categories": raw_spec_data_list}
return ParsedYAPISpec(endpoints=all_endpoints, categories=yapi_categories, spec=yapi_full_spec_dict)
except FileNotFoundError:
self.logger.error(f"YAPI spec file not found: {file_path}")
except json.JSONDecodeError as e:
self.logger.error(f"Error decoding JSON from YAPI spec file {file_path}: {e}")
except Exception as e:
logger.error(f"解析YAPI文件时出错: {str(e)}")
return None
self.logger.error(f"An unexpected error occurred while parsing YAPI spec {file_path}: {e}", exc_info=True)
return None
def parse_swagger_spec(self, file_path: str) -> Optional[ParsedSwaggerSpec]:
"""
解析Swagger规范文件
Args:
file_path: Swagger JSON文件路径
Returns:
Optional[ParsedSwaggerSpec]: 解析后的Swagger规范,如果解析失败则返回None
"""
if not os.path.isfile(file_path):
logger.error(f"文件不存在: {file_path}")
return None
self.logger.info(f"Parsing Swagger/OpenAPI spec from: {file_path}")
all_endpoints: List[SwaggerEndpoint] = []
swagger_tags: List[Dict[str, Any]] = []
raw_spec_data_dict: Optional[Dict[str, Any]] = None # Swagger/OpenAPI is a single root object
try:
with open(file_path, 'r', encoding='utf-8') as f:
swagger_data = json.load(f)
if not isinstance(swagger_data, dict):
logger.error(f"无效的Swagger文件格式: 顶层元素应该是对象")
# TODO: Add YAML support if needed, e.g., using PyYAML
raw_spec_data_dict = json.load(f)
if not isinstance(raw_spec_data_dict, dict):
self.logger.error(f"Swagger spec file {file_path} does not contain a JSON object as expected.")
return None
# 提取基本信息
swagger_version = swagger_data.get('swagger', swagger_data.get('openapi', ''))
info = swagger_data.get('info', {})
host = swagger_data.get('host', '')
base_path = swagger_data.get('basePath', '')
schemes = swagger_data.get('schemes', [])
tags = swagger_data.get('tags', [])
swagger_tags = raw_spec_data_dict.get("tags", [])
paths = raw_spec_data_dict.get("paths", {})
# 创建分类列表
categories = []
for tag in tags:
categories.append({
'name': tag.get('name', ''),
'desc': tag.get('description', '')
})
# 处理API路径
paths = swagger_data.get('paths', {})
endpoints = []
for path, path_item in paths.items():
if not isinstance(path_item, dict):
continue
# 处理每个HTTP方法 (GET, POST, PUT, DELETE等)
for method, operation in path_item.items():
if method in ['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace']:
if not isinstance(operation, dict):
continue
# 提取操作信息
summary = operation.get('summary', '')
description = operation.get('description', '')
operation_id = operation.get('operationId', '')
operation_tags = operation.get('tags', [])
# 提取参数信息
parameters = operation.get('parameters', [])
# 提取响应信息
responses = operation.get('responses', {})
# 提取请求和响应的内容类型
consumes = operation.get('consumes', swagger_data.get('consumes', []))
produces = operation.get('produces', swagger_data.get('produces', []))
# 提取请求体信息 (OpenAPI 3.0 格式)
request_body = operation.get('requestBody', {})
# 创建端点对象
endpoint = SwaggerEndpoint(
path=path,
method=method.upper(),
summary=summary,
description=description,
operation_id=operation_id,
tags=operation_tags,
parameters=parameters,
responses=responses,
consumes=consumes,
produces=produces,
request_body=request_body
)
endpoints.append(endpoint)
# 创建返回对象
return ParsedSwaggerSpec(
endpoints=endpoints,
info=info,
swagger_version=swagger_version,
host=host,
base_path=base_path,
schemes=schemes,
tags=tags,
categories=categories
)
for path, path_item_obj in paths.items():
if not isinstance(path_item_obj, dict): continue
for method, operation_obj in path_item_obj.items():
# Common methods, can be extended
if method.lower() not in ["get", "post", "put", "delete", "patch", "options", "head", "trace"]:
continue # Skip non-standard HTTP methods or extensions like 'parameters' at path level
if not isinstance(operation_obj, dict): continue
try:
# Pass the full raw_spec_data_dict for $ref resolution within SwaggerEndpoint
swagger_endpoint = SwaggerEndpoint(path, method, operation_obj, global_spec=raw_spec_data_dict)
all_endpoints.append(swagger_endpoint)
except Exception as e_ep:
self.logger.error(f"Error processing Swagger endpoint: {method.upper()} {path}. Error: {e_ep}", exc_info=True)
return ParsedSwaggerSpec(endpoints=all_endpoints, tags=swagger_tags, spec=raw_spec_data_dict)
except FileNotFoundError:
self.logger.error(f"Swagger spec file not found: {file_path}")
except json.JSONDecodeError as e:
self.logger.error(f"Error decoding JSON from Swagger spec file {file_path}: {e}")
except Exception as e:
logger.error(f"解析Swagger文件时出错: {str(e)}")
return None
def parse_business_logic_flow(self, flow_description: str) -> Optional[ParsedBusinessLogic]:
"""
Parses a business logic flow description.
The format of this description is TBD and this parser would need to be built accordingly.
Args:
flow_description: The string content describing the business logic flow.
Returns:
A ParsedBusinessLogic object or None if parsing fails.
"""
print(f"[InputParser] Placeholder: Parsing business logic flow. Content: {flow_description[:100]}...")
# Placeholder: Actual parsing logic will depend on the defined format.
return ParsedBusinessLogic(name="Example Flow", steps=["Step 1 API call", "Step 2 Validate Response"])
# Add other parsers as needed (e.g., for data object definitions)
self.logger.error(f"An unexpected error occurred while parsing Swagger spec {file_path}: {e}", exc_info=True)
return None
@@ -20,22 +20,22 @@ logger = logging.getLogger(__name__)
class ValidationResult:
"""Validation result container"""
def __init__(self, is_valid: bool, errors: List[str] = None, warnings: List[str] = None):
def __init__(self, passed: bool, errors: List[str] = None, warnings: List[str] = None):
"""
Initialize a validation result
Args:
is_valid: Whether the data is valid according to the schema
passed: Whether the data is valid according to the schema
errors: List of error messages (if any)
warnings: List of warning messages (if any)
"""
self.is_valid = is_valid
self.passed = passed
self.errors = errors or []
self.warnings = warnings or []
def __str__(self) -> str:
"""String representation of validation result"""
status = "Valid" if self.is_valid else "Invalid"
status = "Valid" if self.passed else "Invalid"
result = f"Validation Result: {status}\n"
if self.errors:
+42 -12
View File
@@ -93,18 +93,20 @@ class BaseAPITestCase:
use_llm_for_query_params: bool = False
use_llm_for_headers: bool = False
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None):
def __init__(self, endpoint_spec: Dict[str, Any], global_api_spec: Dict[str, Any], json_schema_validator: Optional[Any] = None, llm_service: Optional[Any] = None):
"""
初始化测试用例。
Args:
endpoint_spec: 当前被测API端点的详细定义 (来自YAPI/Swagger解析结果)。
global_api_spec: 完整的API规范文档 (来自YAPI/Swagger解析结果)。
json_schema_validator: APITestOrchestrator 传入的 JSONSchemaValidator 实例 (可选)。
llm_service: APITestOrchestrator 传入的 LLMService 实例 (可选)。
"""
self.endpoint_spec = endpoint_spec
self.global_api_spec = global_api_spec
self.logger = logging.getLogger(f"testcase.{self.id}")
self.json_schema_validator = json_schema_validator # 存储传入的校验器实例
self.llm_service = llm_service # 存储注入的 LLMService 实例
self.logger.debug(f"Test case '{self.id}' initialized for endpoint: {self.endpoint_spec.get('method', '')} {self.endpoint_spec.get('path', '')}")
# --- 1. 请求生成与修改阶段 ---
@@ -120,6 +122,20 @@ class BaseAPITestCase:
self.logger.debug(f"Hook: generate_request_body, current body type: {type(current_body)}")
return current_body
def generate_path_params(self, current_path_params: Dict[str, Any]) -> Dict[str, Any]:
"""
允许测试用例修改或生成路径参数。
这些参数将用于替换URL中的占位符,例如 /users/{userId}
Args:
current_path_params: 从API规范或编排器默认逻辑生成的初始路径参数。
Returns:
最终要使用的路径参数字典。
"""
self.logger.debug(f"Hook: generate_path_params, current: {current_path_params}")
return current_path_params
# --- 1.5. 请求URL修改阶段 (新增钩子) ---
def modify_request_url(self, current_url: str) -> str:
"""
@@ -180,26 +196,40 @@ class BaseAPITestCase:
results.append(self.failed(f"{context_message_prefix} schema validation skipped: Validator not available."))
return results
is_valid, errors = self.json_schema_validator.validate(data_to_validate, schema_definition)
# validator_result 是 JSONSchemaValidator 内部定义的 ValidationResult 对象
validator_result = self.json_schema_validator.validate(data_to_validate, schema_definition)
if is_valid:
results.append(self.passed(f"{context_message_prefix} conforms to the JSON schema."))
if validator_result.passed:
success_message = f"{context_message_prefix} conforms to the JSON schema."
# 可以选择性地将 validator 的警告信息添加到 details
current_details = {}
if validator_result.warnings:
current_details["schema_warnings"] = validator_result.warnings
# 如果 validator_result 有其他有用的成功信息,也可以加入 message 或 details
results.append(self.passed(success_message, details=current_details if current_details else None))
else:
error_messages = []
if isinstance(errors, list):
for error in errors: # jsonschema.exceptions.ValidationError 对象
error_messages.append(f"- Path: '{list(error.path)}', Message: {error.message}") # error.path 是一个deque
elif isinstance(errors, str): # 兼容旧版或简单错误字符串
error_messages.append(errors)
# 从 validator_result.errors 构建 message 和 details
error_reason = "Validation failed."
if validator_result.errors:
error_reason = "Errors:\n" + "\n".join([f"- {e}" for e in validator_result.errors])
full_message = f"{context_message_prefix} does not conform to the JSON schema. Errors:\n" + "\n".join(error_messages)
full_message = f"{context_message_prefix} does not conform to the JSON schema. {error_reason}"
current_details = {
"schema_errors": validator_result.errors,
"validated_data_sample": str(data_to_validate)[:200]
}
if validator_result.warnings:
current_details["schema_warnings"] = validator_result.warnings
results.append(self.failed(
message=full_message,
details={"schema_errors": error_messages, "validated_data_sample": str(data_to_validate)[:200]}
details=current_details
))
self.logger.warning(f"{context_message_prefix} schema validation failed: {full_message}")
return results
# --- Helper to easily create a passed ValidationResult ---
@staticmethod
def passed(message: str, details: Optional[Dict[str, Any]] = None) -> ValidationResult:
+214 -209
View File
@@ -640,21 +640,22 @@ class APITestOrchestrator:
validation_results: List[ValidationResult] = []
overall_status: ExecutedTestCaseResult.Status
execution_message = ""
test_case_instance: Optional[BaseAPITestCase] = None # Initialize to None
# 将 endpoint_spec 转换为字典,如果它还不是的话
endpoint_spec_dict: Dict[str, Any]
if isinstance(endpoint_spec, dict):
endpoint_spec_dict = endpoint_spec
self.logger.debug(f"endpoint_spec 已经是字典类型。")
# self.logger.debug(f"endpoint_spec 已经是字典类型。")
elif hasattr(endpoint_spec, 'to_dict') and callable(endpoint_spec.to_dict):
try:
endpoint_spec_dict = endpoint_spec.to_dict()
self.logger.debug(f"成功通过 to_dict() 方法将类型为 {type(endpoint_spec)} 的 endpoint_spec 转换为字典。")
# self.logger.debug(f"成功通过 to_dict() 方法将类型为 {type(endpoint_spec)} 的 endpoint_spec 转换为字典。")
if not endpoint_spec_dict: # 如果 to_dict() 返回空字典
self.logger.warning(f"endpoint_spec.to_dict() (类型: {type(endpoint_spec)}) 返回了一个空字典。")
# self.logger.warning(f"endpoint_spec.to_dict() (类型: {type(endpoint_spec)}) 返回了一个空字典。")
# 尝试备用转换
if isinstance(endpoint_spec, (YAPIEndpoint, SwaggerEndpoint)):
self.logger.debug(f"尝试从 {type(endpoint_spec).__name__} 对象的属性手动构建 endpoint_spec_dict。")
# self.logger.debug(f"尝试从 {type(endpoint_spec).__name__} 对象的属性手动构建 endpoint_spec_dict。")
endpoint_spec_dict = {
"method": getattr(endpoint_spec, 'method', 'UNKNOWN_METHOD').upper(),
"path": getattr(endpoint_spec, 'path', 'UNKNOWN_PATH'),
@@ -667,7 +668,7 @@ class APITestOrchestrator:
"_original_object_type": type(endpoint_spec).__name__
}
if not any(endpoint_spec_dict.values()): # 如果手动构建后仍基本为空
self.logger.error(f"手动从属性构建 endpoint_spec_dict (类型: {type(endpoint_spec)}) 后仍然为空或无效。")
# self.logger.error(f"手动从属性构建 endpoint_spec_dict (类型: {type(endpoint_spec)}) 后仍然为空或无效。")
endpoint_spec_dict = {} # 重置为空,触发下方错误处理
except Exception as e:
self.logger.error(f"调用 endpoint_spec (类型: {type(endpoint_spec)}) 的 to_dict() 方法时出错: {e}。尝试备用转换。")
@@ -691,10 +692,10 @@ class APITestOrchestrator:
endpoint_spec_dict = {} # 转换失败
elif hasattr(endpoint_spec, 'data') and isinstance(getattr(endpoint_spec, 'data'), dict): # 兼容 YAPIEndpoint 结构
endpoint_spec_dict = getattr(endpoint_spec, 'data')
self.logger.debug(f"使用了类型为 {type(endpoint_spec)} 的 endpoint_spec 的 .data 属性。")
# self.logger.debug(f"使用了类型为 {type(endpoint_spec)} 的 endpoint_spec 的 .data 属性。")
else: # 如果没有 to_dict, 也不是已知可直接访问 .data 的类型,则尝试最后的通用转换或手动构建
if isinstance(endpoint_spec, (YAPIEndpoint, SwaggerEndpoint)):
self.logger.debug(f"类型为 {type(endpoint_spec).__name__} 的 endpoint_spec 没有 to_dict() 或 data,尝试从属性手动构建。")
# self.logger.debug(f"类型为 {type(endpoint_spec).__name__} 的 endpoint_spec 没有 to_dict() 或 data,尝试从属性手动构建。")
endpoint_spec_dict = {
"method": getattr(endpoint_spec, 'method', 'UNKNOWN_METHOD').upper(),
"path": getattr(endpoint_spec, 'path', 'UNKNOWN_PATH'),
@@ -795,7 +796,8 @@ class APITestOrchestrator:
test_case_instance = test_case_class(
endpoint_spec=endpoint_spec_dict,
global_api_spec=global_spec_dict,
json_schema_validator=self.validator
json_schema_validator=self.validator,
llm_service=self.llm_service # Pass the orchestrator's LLM service instance
)
self.logger.info(f"开始执行测试用例 '{test_case_instance.id}' ({test_case_instance.name}) for endpoint '{endpoint_spec_dict.get('method', 'N/A')} {endpoint_spec_dict.get('path', 'N/A')}'")
@@ -928,226 +930,199 @@ class APITestOrchestrator:
)
except Exception as e:
self.logger.error(f"执行测试用例 '{test_case_class.id if test_case_instance else test_case_class.__name__}' 时发生严重错误: {e}", exc_info=True)
self.logger.error(f"执行测试用例 '{test_case_class.id if hasattr(test_case_class, 'id') else test_case_class.__name__}' (在实例化阶段或之前) 时发生严重错误: {e}", exc_info=True)
# 如果 test_case_instance 在实例化时失败,它将是 None
tc_id_for_log = test_case_instance.id if test_case_instance else (test_case_class.id if hasattr(test_case_class, 'id') else "unknown_tc_id_instantiation_error")
tc_name_for_log = test_case_instance.name if test_case_instance else (test_case_class.name if hasattr(test_case_class, 'name') else test_case_class.__name__)
# 实例化失败,严重性默认为CRITICAL
tc_severity_for_log = test_case_instance.severity if test_case_instance else TestSeverity.CRITICAL
tc_duration = time.monotonic() - start_time
# validation_results 可能在此阶段为空,或包含来自先前步骤的条目(如果错误发生在实例化之后)
return ExecutedTestCaseResult(
test_case_id=test_case_instance.id if test_case_instance else test_case_class.id if hasattr(test_case_class, 'id') else "unknown_tc_id",
test_case_name=test_case_instance.name if test_case_instance else test_case_class.name if hasattr(test_case_class, 'name') else "Unknown Test Case Name",
test_case_severity=test_case_instance.severity if test_case_instance else TestSeverity.CRITICAL,
test_case_id=tc_id_for_log,
test_case_name=tc_name_for_log,
test_case_severity=tc_severity_for_log,
status=ExecutedTestCaseResult.Status.ERROR,
validation_points=validation_results,
message=f"测试用例执行时发生内部错误: {str(e)}",
validation_points=validation_results, # Ensure validation_results is defined (it is, at the start of the function)
message=f"测试用例执行时发生内部错误 (可能在实例化期间): {str(e)}",
duration=tc_duration
)
def _prepare_initial_request_data(
self,
endpoint_spec: Dict[str, Any],
test_case_instance: Optional[BaseAPITestCase] = None
) -> Tuple[str, Dict[str, Any], Dict[str, Any], Dict[str, Any], Optional[Any]]:
endpoint_spec: Dict[str, Any], # 已经转换为字典
test_case_instance: Optional[BaseAPITestCase] = None # 传入测试用例实例以便访问其LLM配置
) -> APIRequestContext: # 返回 APIRequestContext 对象
"""
根据OpenAPI端点规格和测试用例实例准备初始请求数据。
包含端点级别的LLM参数缓存逻辑
根据API端点规范,准备初始请求数据,包括URL(模板)、路径参数、查询参数、头部和请求体
这些数据将作为测试用例中 generate_* 方法的输入
"""
method = endpoint_spec.get("method", "get").upper()
operation_id = endpoint_spec.get("operationId", f"{method}_{endpoint_spec.get('path', '')}")
endpoint_cache_key = f"{method}_{endpoint_spec.get('path', '')}"
self.logger.info(f"[{operation_id}] 开始为端点 {endpoint_cache_key} 准备初始请求数据 (TC: {test_case_instance.id if test_case_instance else 'N/A'})")
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('}','')}"
# 尝试从缓存加载参数
if endpoint_cache_key in self.llm_endpoint_params_cache:
cached_params = self.llm_endpoint_params_cache[endpoint_cache_key]
self.logger.info(f"[{operation_id}] 从缓存加载了端点 '{endpoint_cache_key}' 的LLM参数。")
# 直接从缓存中获取各类参数,如果存在的话
path_params_data = cached_params.get("path_params", {})
query_params_data = cached_params.get("query_params", {})
headers_data = cached_params.get("headers", {})
body_data = cached_params.get("body") # Body可能是None
initial_path_params: Dict[str, Any] = {}
initial_query_params: Dict[str, Any] = {}
initial_headers: Dict[str, str] = {}
initial_body: Optional[Any] = None
parameters = endpoint_spec.get('parameters', [])
# 1. 处理路径参数
path_param_specs = [p for p in parameters if p.get('in') == 'path']
for param_spec in path_param_specs:
name = param_spec.get('name')
if not name: continue
# 即使从缓存加载,仍需确保默认头部(如Accept, Content-Type)存在或被正确设置
# Content-Type应基于body_data是否存在来决定
default_headers = {"Accept": "application/json"}
if body_data is not None and method not in ["GET", "DELETE", "HEAD", "OPTIONS"]:
default_headers["Content-Type"] = "application/json"
should_use_llm = self._should_use_llm_for_param_type("path_params", test_case_instance)
if should_use_llm and self.llm_service:
self.logger.info(f"Attempting LLM generation for path parameter '{name}' in '{operation_id}'")
# generated_value = self.llm_service.generate_data_for_parameter(param_spec, endpoint_spec, "path")
# initial_path_params[name] = generated_value if generated_value is not None else f"llm_placeholder_for_{name}"
initial_path_params[name] = f"llm_path_{name}" # Placeholder
else:
if 'example' in param_spec:
initial_path_params[name] = param_spec['example']
elif param_spec.get('schema') and 'example' in param_spec['schema']:
initial_path_params[name] = param_spec['schema']['example'] # OpenAPI 3.0 `parameter.schema.example`
elif 'default' in param_spec.get('schema', {}):
initial_path_params[name] = param_spec['schema']['default']
elif 'default' in param_spec: # OpenAPI 2.0 `parameter.default`
initial_path_params[name] = param_spec['default']
else:
schema = param_spec.get('schema', {})
param_type = schema.get('type', 'string')
if param_type == 'integer': initial_path_params[name] = 123
elif param_type == 'number': initial_path_params[name] = 1.23
elif param_type == 'boolean': initial_path_params[name] = True
elif param_type == 'string' and schema.get('format') == 'uuid': initial_path_params[name] = str(UUID(int=0)) # Example UUID
elif param_type == 'string' and schema.get('format') == 'date': initial_path_params[name] = dt.date.today().isoformat()
elif param_type == 'string' and schema.get('format') == 'date-time': initial_path_params[name] = dt.datetime.now().isoformat()
else: initial_path_params[name] = f"param_{name}"
self.logger.debug(f"Initial path param for '{operation_id}': {name} = {initial_path_params.get(name)}")
# 2. 处理查询参数
query_param_specs = [p for p in parameters if p.get('in') == 'query']
for param_spec in query_param_specs:
name = param_spec.get('name')
if not name: continue
should_use_llm = self._should_use_llm_for_param_type("query_params", test_case_instance)
if should_use_llm and self.llm_service:
self.logger.info(f"Attempting LLM generation for query parameter '{name}' in '{operation_id}'")
initial_query_params[name] = f"llm_query_{name}" # Placeholder
else:
if 'example' in param_spec:
initial_query_params[name] = param_spec['example']
elif param_spec.get('schema') and 'example' in param_spec['schema']:
initial_query_params[name] = param_spec['schema']['example']
elif 'default' in param_spec.get('schema', {}):
initial_query_params[name] = param_spec['schema']['default']
elif 'default' in param_spec:
initial_query_params[name] = param_spec['default']
else:
initial_query_params[name] = f"query_val_{name}" # Simplified default
self.logger.debug(f"Initial query param for '{operation_id}': {name} = {initial_query_params.get(name)}")
# 3. 处理请求头参数 (包括规范定义的和标准的 Content-Type/Accept)
header_param_specs = [p for p in parameters if p.get('in') == 'header']
for param_spec in header_param_specs:
name = param_spec.get('name')
if not name: continue
# 标准头 Content-Type 和 Accept 会在后面专门处理
if name.lower() in ['content-type', 'accept', 'authorization']:
self.logger.debug(f"Skipping standard header '{name}' in parameter processing for '{operation_id}'. It will be handled separately.")
continue
headers_data = {**default_headers, **headers_data} # 合并,缓存中的优先
self.logger.debug(f"[{operation_id}] (缓存加载) 准备的请求数据: method={method}, path_params={path_params_data}, query_params={query_params_data}, headers={list(headers_data.keys())}, body_type={type(body_data).__name__}")
return method, path_params_data, query_params_data, headers_data, body_data
should_use_llm = self._should_use_llm_for_param_type("headers", test_case_instance)
if should_use_llm and self.llm_service:
self.logger.info(f"Attempting LLM generation for header '{name}' in '{operation_id}'")
initial_headers[name] = f"llm_header_{name}" # Placeholder
else:
if 'example' in param_spec:
initial_headers[name] = str(param_spec['example'])
elif param_spec.get('schema') and 'example' in param_spec['schema']:
initial_headers[name] = str(param_spec['schema']['example'])
elif 'default' in param_spec.get('schema', {}):
initial_headers[name] = str(param_spec['schema']['default'])
elif 'default' in param_spec:
initial_headers[name] = str(param_spec['default'])
else:
initial_headers[name] = f"header_val_{name}"
self.logger.debug(f"Initial custom header param for '{operation_id}': {name} = {initial_headers.get(name)}")
# 缓存未命中,需要生成参数
self.logger.info(f"[{operation_id}] 端点 '{endpoint_cache_key}' 的参数未在缓存中找到,开始生成。")
generated_params_for_endpoint: Dict[str, Any] = {}
path_params_data: Dict[str, Any] = {}
query_params_data: Dict[str, Any] = {}
headers_data_generated: Dict[str, Any] = {} # LLM或常规生成的,不含默认
body_data: Optional[Any] = None
# 3.1 设置 Content-Type
# 优先从 requestBody.content 获取 (OpenAPI 3.x)
request_body_spec = endpoint_spec.get('requestBody', {})
if 'content' in request_body_spec:
content_types = list(request_body_spec['content'].keys())
if content_types:
# 优先选择 application/json 如果存在
initial_headers['Content-Type'] = next((ct for ct in content_types if 'json' in ct.lower()), content_types[0])
elif 'consumes' in endpoint_spec: # 然后是 consumes (OpenAPI 2.0)
consumes = endpoint_spec['consumes']
if consumes:
initial_headers['Content-Type'] = next((c for c in consumes if 'json' in c.lower()), consumes[0])
elif method in ['POST', 'PUT', 'PATCH'] and not initial_headers.get('Content-Type'):
initial_headers['Content-Type'] = 'application/json' # 默认对于这些方法
self.logger.debug(f"Initial Content-Type for '{operation_id}': {initial_headers.get('Content-Type')}")
# 提取各类参数的定义列表
path_params_spec_list = [p for p in endpoint_spec.get("parameters", []) if p.get("in") == "path"]
query_params_spec_list = [p for p in endpoint_spec.get("parameters", []) if p.get("in") == "query"]
headers_spec_list = [p for p in endpoint_spec.get("parameters", []) if p.get("in") == "header"]
request_body_spec = endpoint_spec.get("requestBody", {}).get("content", {}).get("application/json", {}).get("schema")
# 3.2 设置 Accept
# 优先从 responses.<code>.content 获取 (OpenAPI 3.x)
responses_spec = endpoint_spec.get('responses', {})
accept_header_set = False
for code, response_def in responses_spec.items():
if 'content' in response_def:
accept_types = list(response_def['content'].keys())
if accept_types:
initial_headers['Accept'] = next((at for at in accept_types if 'json' in at.lower() or '*/*' in at), accept_types[0])
accept_header_set = True
break
if not accept_header_set and 'produces' in endpoint_spec: # 然后是 produces (OpenAPI 2.0)
produces = endpoint_spec['produces']
if produces:
initial_headers['Accept'] = next((p for p in produces if 'json' in p.lower() or '*/*' in p), produces[0])
accept_header_set = True
if not accept_header_set and not initial_headers.get('Accept'):
initial_headers['Accept'] = 'application/json, */*' # 更通用的默认值
self.logger.debug(f"Initial Accept header for '{operation_id}': {initial_headers.get('Accept')}")
# --- 1. 处理路径参数 ---
param_type_key = "path_params"
if self._should_use_llm_for_param_type(param_type_key, test_case_instance) and path_params_spec_list:
self.logger.info(f"[{operation_id}] 尝试使用LLM生成路径参数。")
object_schema, model_name = self._build_object_schema_for_params(path_params_spec_list, f"DynamicPathParamsFor_{operation_id}")
if object_schema and model_name:
try:
PydanticModel = self._create_pydantic_model_from_schema(object_schema, model_name)
if PydanticModel:
llm_generated = self.llm_service.generate_parameters_from_schema(
PydanticModel,
prompt_instruction=f"Generate valid path parameters for API operation: {operation_id}. Description: {endpoint_spec.get('description', '') or endpoint_spec.get('summary', 'N/A')}"
)
if isinstance(llm_generated, dict):
path_params_data = llm_generated
self.logger.info(f"[{operation_id}] LLM成功生成路径参数: {path_params_data}")
else:
self.logger.warning(f"[{operation_id}] LLM为路径参数返回了非字典类型: {type(llm_generated)}。回退到常规生成。")
path_params_data = self._generate_params_from_list(path_params_spec_list, operation_id, "path")
else:
path_params_data = self._generate_params_from_list(path_params_spec_list, operation_id, "path")
except Exception as e:
self.logger.error(f"[{operation_id}] LLM生成路径参数失败: {e}。回退到常规生成。", exc_info=True)
path_params_data = self._generate_params_from_list(path_params_spec_list, operation_id, "path")
else: # _build_object_schema_for_params 返回 None
path_params_data = self._generate_params_from_list(path_params_spec_list, operation_id, "path")
else: # 不使用LLM或LLM服务不可用,或者 path_params_spec_list 为空但仍需确保path_params_data被赋值
if self._should_use_llm_for_param_type(param_type_key, test_case_instance) and not path_params_spec_list:
self.logger.info(f"[{operation_id}] 配置为路径参数使用LLM,但没有定义路径参数规格。")
# 对于不使用LLM或LLM不适用的情况,或者 spec_list 为空的情况,都执行常规生成(如果 spec_list 非空则会记录)
if path_params_spec_list and not self._should_use_llm_for_param_type(param_type_key, test_case_instance):
self.logger.info(f"[{operation_id}] 使用常规方法或LLM未启用,为路径参数。")
path_params_data = self._generate_params_from_list(path_params_spec_list, operation_id, "path")
generated_params_for_endpoint[param_type_key] = path_params_data
# 4. 处理请求体 (Body)
request_body_schema: Optional[Dict[str, Any]] = None
# 确定请求体 schema 的来源,优先 OpenAPI 3.x 的 requestBody
content_type_for_body_schema = initial_headers.get('Content-Type', 'application/json').split(';')[0].strip()
# --- 2. 处理查询参数 ---
param_type_key = "query_params"
if self._should_use_llm_for_param_type(param_type_key, test_case_instance) and query_params_spec_list:
self.logger.info(f"[{operation_id}] 尝试使用LLM生成查询参数。")
object_schema, model_name = self._build_object_schema_for_params(query_params_spec_list, f"DynamicQueryParamsFor_{operation_id}")
if object_schema and model_name:
try:
PydanticModel = self._create_pydantic_model_from_schema(object_schema, model_name)
if PydanticModel:
llm_generated = self.llm_service.generate_parameters_from_schema(
PydanticModel,
prompt_instruction=f"Generate valid query parameters for API operation: {operation_id}. Description: {endpoint_spec.get('description', '') or endpoint_spec.get('summary', 'N/A')}"
)
if isinstance(llm_generated, dict):
query_params_data = llm_generated
self.logger.info(f"[{operation_id}] LLM成功生成查询参数: {query_params_data}")
else:
self.logger.warning(f"[{operation_id}] LLM为查询参数返回了非字典类型: {type(llm_generated)}。回退到常规生成。")
query_params_data = self._generate_params_from_list(query_params_spec_list, operation_id, "query")
else:
query_params_data = self._generate_params_from_list(query_params_spec_list, operation_id, "query")
except Exception as e:
self.logger.error(f"[{operation_id}] LLM生成查询参数失败: {e}。回退到常规生成。", exc_info=True)
query_params_data = self._generate_params_from_list(query_params_spec_list, operation_id, "query")
else: # _build_object_schema_for_params 返回 None
query_params_data = self._generate_params_from_list(query_params_spec_list, operation_id, "query")
else: # 不使用LLM或LLM服务不可用,或者 query_params_spec_list 为空
if self._should_use_llm_for_param_type(param_type_key, test_case_instance) and not query_params_spec_list:
self.logger.info(f"[{operation_id}] 配置为查询参数使用LLM,但没有定义查询参数规格。")
if query_params_spec_list and not self._should_use_llm_for_param_type(param_type_key, test_case_instance):
self.logger.info(f"[{operation_id}] 使用常规方法或LLM未启用,为查询参数。")
query_params_data = self._generate_params_from_list(query_params_spec_list, operation_id, "query")
generated_params_for_endpoint[param_type_key] = query_params_data
if 'content' in request_body_spec and content_type_for_body_schema in request_body_spec['content']:
request_body_schema = request_body_spec['content'][content_type_for_body_schema].get('schema')
elif 'parameters' in endpoint_spec: # OpenAPI 2.0 (Swagger) body parameter
body_param = next((p for p in parameters if p.get('in') == 'body'), None)
if body_param and 'schema' in body_param:
request_body_schema = body_param['schema']
# --- 3. 处理头部参数 ---
param_type_key = "headers"
if self._should_use_llm_for_param_type(param_type_key, test_case_instance) and headers_spec_list:
self.logger.info(f"[{operation_id}] 尝试使用LLM生成头部参数。")
object_schema, model_name = self._build_object_schema_for_params(headers_spec_list, f"DynamicHeadersFor_{operation_id}")
if object_schema and model_name:
try:
PydanticModel = self._create_pydantic_model_from_schema(object_schema, model_name)
if PydanticModel:
llm_generated = self.llm_service.generate_parameters_from_schema(
PydanticModel,
prompt_instruction=f"Generate valid HTTP headers for API operation: {operation_id}. Description: {endpoint_spec.get('description', '') or endpoint_spec.get('summary', 'N/A')}"
)
if isinstance(llm_generated, dict):
headers_data_generated = llm_generated # Store LLM generated ones separately first
self.logger.info(f"[{operation_id}] LLM成功生成头部参数: {headers_data_generated}")
else:
self.logger.warning(f"[{operation_id}] LLM为头部参数返回了非字典类型: {type(llm_generated)}。回退到常规生成。")
headers_data_generated = self._generate_params_from_list(headers_spec_list, operation_id, "header")
else:
headers_data_generated = self._generate_params_from_list(headers_spec_list, operation_id, "header")
except Exception as e:
self.logger.error(f"[{operation_id}] LLM生成头部参数失败: {e}。回退到常规生成。", exc_info=True)
headers_data_generated = self._generate_params_from_list(headers_spec_list, operation_id, "header")
else: # _build_object_schema_for_params 返回 None
headers_data_generated = self._generate_params_from_list(headers_spec_list, operation_id, "header")
else: # 不使用LLM或LLM服务不可用,或者 headers_spec_list 为空
if self._should_use_llm_for_param_type(param_type_key, test_case_instance) and not headers_spec_list:
self.logger.info(f"[{operation_id}] 配置为头部参数使用LLM,但没有定义头部参数规格。")
if headers_spec_list and not self._should_use_llm_for_param_type(param_type_key, test_case_instance):
self.logger.info(f"[{operation_id}] 使用常规方法或LLM未启用,为头部参数。")
headers_data_generated = self._generate_params_from_list(headers_spec_list, operation_id, "header")
generated_params_for_endpoint[param_type_key] = headers_data_generated
# --- 4. 处理请求体 ---
param_type_key = "body"
if self._should_use_llm_for_param_type(param_type_key, test_case_instance) and request_body_spec:
self.logger.info(f"[{operation_id}] 尝试使用LLM生成请求体。")
model_name = f"DynamicBodyFor_{operation_id}"
try:
PydanticModel = self._create_pydantic_model_from_schema(request_body_spec, model_name)
if PydanticModel:
llm_generated_body = self.llm_service.generate_parameters_from_schema(
PydanticModel,
prompt_instruction=f"Generate a valid JSON request body for API operation: {operation_id}. Description: {endpoint_spec.get('description', '') or endpoint_spec.get('summary', 'N/A')}. Schema: {json.dumps(request_body_spec, indent=2)}"
)
if isinstance(llm_generated_body, dict):
try:
body_data = PydanticModel(**llm_generated_body).model_dump(by_alias=True)
self.logger.info(f"[{operation_id}] LLM成功生成并验证请求体。")
except ValidationError as ve:
self.logger.error(f"[{operation_id}] LLM生成的请求体未能通过Pydantic模型验证: {ve}。回退到常规生成。")
body_data = self._generate_data_from_schema(request_body_spec, "requestBody", operation_id)
elif isinstance(llm_generated_body, BaseModel): # LLM直接返回模型实例
body_data = llm_generated_body.model_dump(by_alias=True)
self.logger.info(f"[{operation_id}] LLM成功生成请求体 (模型实例)。")
else:
self.logger.warning(f"[{operation_id}] LLM为请求体返回了非预期类型: {type(llm_generated_body)}。回退到常规生成。")
body_data = self._generate_data_from_schema(request_body_spec, "requestBody", operation_id)
else: # _create_pydantic_model_from_schema 返回 None
self.logger.warning(f"[{operation_id}] 未能为请求体创建Pydantic模型。回退到常规生成。")
body_data = self._generate_data_from_schema(request_body_spec, "requestBody", operation_id)
except Exception as e:
self.logger.error(f"[{operation_id}] LLM生成请求体失败: {e}。回退到常规生成。", exc_info=True)
body_data = self._generate_data_from_schema(request_body_spec, "requestBody", operation_id)
elif request_body_spec: # 不使用LLM但有body spec
self.logger.info(f"[{operation_id}] 使用常规方法或LLM未启用/不适用,为请求体。")
body_data = self._generate_data_from_schema(request_body_spec, "requestBody", operation_id)
else: # 没有requestBody定义
self.logger.info(f"[{operation_id}] 端点没有定义请求体。")
body_data = None # 明确设为None
generated_params_for_endpoint[param_type_key] = body_data
if request_body_schema:
should_use_llm_for_body = self._should_use_llm_for_param_type("body", test_case_instance)
if should_use_llm_for_body and self.llm_service:
self.logger.info(f"Attempting LLM generation for request body of '{operation_id}' with schema...")
initial_body = self.llm_service.generate_data_from_schema(request_body_schema, endpoint_spec, "requestBody")
if initial_body is None:
self.logger.warning(f"LLM failed to generate request body for '{operation_id}'. Falling back to default schema generator.")
initial_body = self._generate_data_from_schema(request_body_schema, context_name=f"{operation_id}_body", operation_id=operation_id)
else:
initial_body = self._generate_data_from_schema(request_body_schema, context_name=f"{operation_id}_body", operation_id=operation_id)
self.logger.debug(f"Initial request body generated for '{operation_id}' (type: {type(initial_body)})")
else:
self.logger.debug(f"No request body schema found or applicable for '{operation_id}' with Content-Type '{content_type_for_body_schema}'. Initial body is None.")
# 合并最终的头部 (默认头部 + 生成的头部)
final_headers = {"Accept": "application/json"}
if body_data is not None and method not in ["GET", "DELETE", "HEAD", "OPTIONS"]:
final_headers["Content-Type"] = "application/json"
final_headers.update(headers_data_generated) # headers_data_generated 是从LLM或常规生成的
# 将本次生成的所有参数存入缓存
self.llm_endpoint_params_cache[endpoint_cache_key] = generated_params_for_endpoint
self.logger.info(f"[{operation_id}] 端点 '{endpoint_cache_key}' 的参数已生成并存入缓存。")
# 确保路径参数中的值都是字符串 (URL部分必须是字符串)
path_params_data_str = {k: str(v) if v is not None else "" for k, v in path_params_data.items()}
self.logger.debug(f"[{operation_id}] (新生成) 准备的请求数据: method={method}, path_params={path_params_data_str}, query_params={query_params_data}, headers={list(final_headers.keys())}, body_type={type(body_data).__name__}")
return method, path_params_data_str, query_params_data, final_headers, body_data
# 构造并返回APIRequestContext
return APIRequestContext(
method=method,
url=path_template, # 传递路径模板, e.g. /items/{itemId}
path_params=initial_path_params,
query_params=initial_query_params,
headers=initial_headers,
body=initial_body,
endpoint_spec=endpoint_spec # 传递原始的 endpoint_spec 字典
)
def _build_object_schema_for_params(self, params_spec_list: List[Dict[str, Any]], model_name_base: str) -> Tuple[Optional[Dict[str, Any]], str]:
"""
@@ -1490,4 +1465,34 @@ class APITestOrchestrator:
self.logger.debug(f"{log_prefix}_generate_data_from_schema: 未知或不支持的 schema 类型 '{schema_type}' for{context_log}. Schema: {schema}")
return None
def _format_url_with_path_params(self, 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'
同时处理 base_url.
"""
# 首先确保 path_template 不以 '/' 开头,如果 self.base_url 已经以 '/' 结尾
# 或者确保它们之间只有一个 '/'
formatted_path = path_template
for key, value in path_params.items():
placeholder = f"{{{key}}}"
if placeholder in formatted_path:
formatted_path = formatted_path.replace(placeholder, str(value))
else:
self.logger.warning(f"路径参数 '{key}' 在路径模板 '{path_template}' 中未找到占位符。")
# 拼接 base_url 和格式化后的路径
# 确保 base_url 和 path 之间只有一个斜杠
if self.base_url.endswith('/') and formatted_path.startswith('/'):
url = self.base_url + formatted_path[1:]
elif not self.base_url.endswith('/') and not formatted_path.startswith('/'):
if formatted_path: # 避免在 base_url 后添加不必要的 '/' (如果 formatted_path 为空)
url = self.base_url + '/' + formatted_path
else:
url = self.base_url
else:
url = self.base_url + formatted_path
return url