add:stage

This commit is contained in:
gongwenxin
2025-07-11 17:56:56 +08:00
parent 8fb86a34e9
commit cd1c6a340e
386 changed files with 272591 additions and 316502 deletions
+259 -4
View File
@@ -1,6 +1,9 @@
import json
import logging
from typing import List, Dict, Any, Optional, Union # Ensure Union is imported
from typing import List, Dict, Any, Optional, Union, Tuple
import requests
from urllib.parse import urljoin
import copy
logger = logging.getLogger(__name__)
@@ -373,9 +376,53 @@ class SwaggerEndpoint(BaseEndpoint): # Inherit from BaseEndpoint
def __repr__(self):
return f"<SwaggerEndpoint Method:{self.method} Path:{self.path} Summary:'{self.summary}'>"
class DMSEndpoint(BaseEndpoint):
"""Represents an API endpoint discovered dynamically from the DMS service."""
def __init__(self, method: str, path: str, title: str,
request_body: Optional[Dict[str, Any]],
responses: Dict[str, Any],
parameters: Optional[List[Dict[str, Any]]] = None,
category_name: Optional[str] = None,
raw_record: Optional[Dict[str, Any]] = None,
test_mode: str = 'standalone',
operation_id: Optional[str] = None):
super().__init__(method=method.upper(), path=path)
self.title = title
self.request_body = request_body
self.responses = responses
self.parameters = parameters if parameters is not None else []
self.category_name = category_name
self._raw_record = raw_record
self.test_mode = test_mode
self.operation_id = operation_id or f"{self.method.lower()}_{self.category_name or 'dms'}_{title.replace(' ', '_')}"
def to_dict(self) -> Dict[str, Any]:
"""Converts the DMS endpoint data into a standardized OpenAPI-like dictionary."""
endpoint_dict = {
"method": self.method,
"path": self.path,
"title": self.title,
"summary": self.title,
"description": self.title,
"operationId": self.operation_id,
"tags": [self.category_name] if self.category_name else [],
"parameters": self.parameters,
"requestBody": self.request_body,
"responses": self.responses,
"_source_format": "dms",
"_dms_raw_record": self._raw_record,
"_test_mode": self.test_mode
}
return endpoint_dict
def __repr__(self):
return f"<DMSEndpoint Method:{self.method} Path:{self.path} Title:'{self.title}' Mode:{self.test_mode}>"
Endpoint = Union[YAPIEndpoint, SwaggerEndpoint, DMSEndpoint]
class ParsedAPISpec:
"""解析后的API规范的通用基类"""
def __init__(self, spec_type: str, endpoints: List[Union[YAPIEndpoint, SwaggerEndpoint]], spec: Dict[str, Any]):
"""Base class for a parsed API specification from any source."""
def __init__(self, spec_type: str, endpoints: List[Union[YAPIEndpoint, SwaggerEndpoint, 'DMSEndpoint']], 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
@@ -392,6 +439,11 @@ class ParsedSwaggerSpec(ParsedAPISpec):
super().__init__(spec_type="swagger", endpoints=endpoints, spec=spec)
self.tags = tags
class ParsedDMSSpec(ParsedAPISpec):
"""Parsed specification from the dynamic DMS source."""
def __init__(self, endpoints: List[DMSEndpoint], spec: Dict[str, Any]):
super().__init__(spec_type="dms", endpoints=endpoints, spec=spec)
class InputParser:
"""负责解析输入(如YAPI JSON)并提取API端点信息"""
def __init__(self):
@@ -478,4 +530,207 @@ class InputParser:
self.logger.error(f"Error decoding JSON from Swagger spec file {file_path}: {e}")
except Exception as e:
self.logger.error(f"An unexpected error occurred while parsing Swagger spec {file_path}: {e}", exc_info=True)
return None
return None
def parse_dms_spec(self, domain_mapping_path: str, base_url: str, headers: Optional[Dict[str, str]] = None) -> Optional[ParsedDMSSpec]:
self.logger.info(f"Starting DMS spec parsing. Base URL: {base_url}, Domain Map: {domain_mapping_path}")
headers = headers or {}
try:
with open(domain_mapping_path, 'r', encoding='utf-8') as f:
DOMAIN_MAP = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
self.logger.warning(f"Could not load or parse domain map file '{domain_mapping_path}'. Using default domain. Error: {e}")
DOMAIN_MAP = {}
list_url = urljoin(base_url, "/api/schema/manage/schema")
self.logger.info(f"Fetching API list from: {list_url}")
try:
response = requests.get(list_url, headers=headers)
response.raise_for_status()
api_list_data = response.json()
# 检查业务代码是否成功
if api_list_data.get("code") != 0:
self.logger.error(f"DMS API list endpoint returned a business error: {api_list_data.get('message')}")
return None
# 从分页结构中提取 'records'
api_records = api_list_data.get("data", {}).get("records", [])
if not api_records:
self.logger.warning("DMS API list is empty or 'records' key is missing in the response data.")
# Returning an empty spec is valid if the list is just empty.
return ParsedDMSSpec(endpoints=[], spec={"dms_api_list": []})
except requests.exceptions.RequestException as e:
self.logger.error(f"Failed to fetch API list from DMS: {e}")
return None
except json.JSONDecodeError:
self.logger.error("Failed to decode JSON response from DMS API list.")
return None
endpoints: List[DMSEndpoint] = []
for item in api_records:
domain_name = item.get('domain')
name = item.get('name')
model_id = item.get('id')
if not all(k in item for k in ['domain', 'name', 'id']):
self.logger.warning(f"Skipping an item in API list because it's missing 'domain', 'name', or 'id': {item}")
continue
instance_code = DOMAIN_MAP.get(domain_name, domain_name)
model_url = urljoin(base_url, f"/api/schema/manage/schema/{model_id}")
self.logger.info(f"Fetching model for '{name}' from: {model_url}")
try:
response = requests.get(model_url, headers=headers)
response.raise_for_status()
model_schema_response = response.json()
except requests.exceptions.RequestException as e:
self.logger.error(f"Error fetching model for '{name}': {e}")
continue
except json.JSONDecodeError:
self.logger.error(f"Failed to decode JSON for model '{name}'.")
continue
if not model_schema_response or 'data' not in model_schema_response or not model_schema_response['data']:
self.logger.warning(f"Skipping API '{name}' due to missing or empty model schema in response.")
continue
model_data = model_schema_response['data']
model = model_data.get('model')
if not model or 'properties' not in model or not model['properties']:
self.logger.warning(f"Skipping API '{name}' due to missing or invalid 'model' object in schema.")
continue
pk_name = next(iter(model['properties']), None)
if not pk_name:
self.logger.warning(f"Skipping API '{name}' because no properties found in model to identify a primary key.")
continue
pk_schema = model['properties'][pk_name]
version = model_data.get('version', '1.0.0')
dms_instance_code = instance_code
category_name = domain_name
success_response = {
"200": { "description": "Success", "content": {"application/json": {"schema": {"type": "object", "properties": {"code": {"type": "integer"}, "message": {"type": "string"}, "data": {"type": "boolean"}}}}}}}
# Create Endpoint (POST)
create_path = f"/api/dms/{dms_instance_code}/v1/{name}"
create_request_body_schema = {"type": "object", "properties": {"version": {"type": "string", "example": version}, "act": {"type": "integer", "example": 0}, "data": {"type": "array", "items": model}}, "required": ["data"]}
endpoints.append(DMSEndpoint(path=create_path, method='post', title=f"Create {name}", request_body={'content': {'application/json': {'schema': create_request_body_schema}}}, responses=success_response, test_mode='standalone', operation_id=f"create_{name}", category_name=category_name, raw_record=item))
# List Endpoint (POST)
list_path = f"/api/dms/{dms_instance_code}/v1/{name}/{version}"
list_response_schema = {"type": "object", "properties": {"code": {"type": "integer"}, "message": {"type": "string"}, "data": {"type": "array", "items": model}}}
endpoints.append(DMSEndpoint(path=list_path, method='post', title=f"List {name}", request_body={'content': {'application/json': {'schema': {}}}}, responses={'200': {'description': 'Successful Operation', 'content': {'application/json': {'schema': list_response_schema}}}}, test_mode='scenario_only', operation_id=f"list_{name}", category_name=category_name, raw_record=item))
# Read Endpoint (GET)
read_path = f"/api/dms/{dms_instance_code}/v1/{name}/{version}/{{id}}"
read_response_schema = {"type": "object", "properties": {"code": {"type": "integer"}, "message": {"type": "string"}, "data": model}}
read_parameters = [{'name': 'id', 'in': 'path', 'required': True, 'description': f'The ID of the {name}, maps to {pk_name}', 'schema': pk_schema}]
endpoints.append(DMSEndpoint(path=read_path, method='get', title=f"Read {name}", request_body=None, responses={'200': {'description': 'Successful Operation', 'content': {'application/json': {'schema': read_response_schema}}}}, parameters=read_parameters, test_mode='scenario_only', operation_id=f"read_{name}", category_name=category_name, raw_record=item))
# Update Endpoint (PUT)
update_path = f"/api/dms/{dms_instance_code}/v1/{name}"
endpoints.append(DMSEndpoint(path=update_path, method='put', title=f"Update {name}", request_body={'content': {'application/json': {'schema': create_request_body_schema}}}, responses=success_response, test_mode='scenario_only', operation_id=f"update_{name}", category_name=category_name, raw_record=item))
# Delete Endpoint (DELETE)
delete_path = f"/api/dms/{dms_instance_code}/v1/{name}"
delete_request_body_schema = {"type": "object", "properties": {"version": {"type": "string", "example": version}, "data": {"type": "array", "items": {"type": "object", "properties": { pk_name: pk_schema }, "required": [pk_name]}}}, "required": ["data"]}
endpoints.append(DMSEndpoint(path=delete_path, method='delete', title=f"Delete {name}", request_body={'content': {'application/json': {'schema': delete_request_body_schema}}}, responses=success_response, test_mode='scenario_only', operation_id=f"delete_{name}", category_name=category_name, raw_record=item))
# The 'spec' for ParsedDMSSpec should represent the whole document.
# We can construct a dictionary holding all the raw data we fetched.
dms_full_spec_dict = {"dms_api_list": api_records}
return ParsedDMSSpec(endpoints=endpoints, spec=dms_full_spec_dict)
class DmsConfig:
def __init__(self, base_url: str, domain_map_file: str, headers: Optional[Dict[str, str]] = None):
self.base_url = base_url
self.domain_map_file = domain_map_file
self.headers = headers
def get_endpoints_from_swagger(file_path: str) -> Tuple[List[SwaggerEndpoint], str]:
"""
Parses a Swagger/OpenAPI JSON file and returns a list of SwaggerEndpoint objects
and the base path of the API.
"""
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:
# 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):
logger.error(f"Swagger spec file {file_path} does not contain a JSON object as expected.")
return [], ""
swagger_tags = raw_spec_data_dict.get("tags", [])
paths = raw_spec_data_dict.get("paths", {})
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:
logger.error(f"Error processing Swagger endpoint: {method.upper()} {path}. Error: {e_ep}", exc_info=True)
return all_endpoints, raw_spec_data_dict.get("basePath", "") if raw_spec_data_dict.get("basePath") else ""
except FileNotFoundError:
# It's better to log this error. Assuming a logger is available at self.logger
logger.error(f"Swagger spec file not found: {file_path}")
return [], ""
except json.JSONDecodeError as e:
logger.error(f"Error decoding JSON from Swagger spec file {file_path}: {e}")
return [], ""
except Exception as e:
logger.error(f"An unexpected error occurred while parsing {file_path}: {e}", exc_info=True)
return [], ""
def parse_input_to_endpoints(input_type: str, input_path: str, dms_config: DmsConfig = None) -> Tuple[List[Endpoint], str]:
"""
Parses input from a given type (YAPI, Swagger, DMS) and returns a list of Endpoint objects
and the base path of the API.
"""
parser = InputParser()
if input_type == "yapi":
parsed_spec = parser.parse_yapi_spec(input_path)
if parsed_spec:
return parsed_spec.endpoints, "" # YAPI doesn't have a base path in the same sense as Swagger
elif input_type == "swagger":
# The standalone get_endpoints_from_swagger is simple, but for consistency let's use the parser
parsed_spec = parser.parse_swagger_spec(input_path)
if parsed_spec:
base_path = parsed_spec.spec.get("basePath", "") or ""
# servers URL might be more modern (OpenAPI 3)
if not base_path and "servers" in parsed_spec.spec and parsed_spec.spec["servers"]:
# Use the first server URL
base_path = parsed_spec.spec["servers"][0].get("url", "")
return parsed_spec.endpoints, base_path
elif input_type == "dms":
if dms_config:
parsed_spec = parser.parse_dms_spec(dms_config.domain_map_file, dms_config.base_url, dms_config.headers)
if parsed_spec:
return parsed_spec.endpoints, dms_config.base_url
else:
logger.error("DMS configuration not provided for DMS input type.")
return [], ""
else:
logger.error(f"Unsupported input type: {input_type}")
return [], ""
return [], ""
+56 -15
View File
@@ -24,7 +24,7 @@ from pydantic import BaseModel, Field, create_model, HttpUrl # Added HttpUrl for
from pydantic.networks import EmailStr
from pydantic.types import Literal # Explicitly import Literal
from .input_parser.parser import InputParser, YAPIEndpoint, SwaggerEndpoint, ParsedYAPISpec, ParsedSwaggerSpec, ParsedAPISpec
from .input_parser.parser import InputParser, YAPIEndpoint, SwaggerEndpoint, ParsedYAPISpec, ParsedSwaggerSpec, ParsedAPISpec, DMSEndpoint, ParsedDMSSpec
from .api_caller.caller import APICaller, APIRequest, APIResponse, APICallDetail # Ensure APICallDetail is imported
from .json_schema_validator.validator import JSONSchemaValidator
from .test_framework_core import ValidationResult, TestSeverity, APIRequestContext, APIResponseContext, BaseAPITestCase
@@ -761,8 +761,8 @@ class APITestOrchestrator:
def _execute_single_test_case(
self,
test_case_class: Type[BaseAPITestCase],
endpoint_spec: Union[YAPIEndpoint, SwaggerEndpoint], # 当前端点的规格
global_api_spec: Union[ParsedYAPISpec, ParsedSwaggerSpec] # 整个API的规格
endpoint_spec: Union[YAPIEndpoint, SwaggerEndpoint, DMSEndpoint], # 当前端点的规格
global_api_spec: Union[ParsedYAPISpec, ParsedSwaggerSpec, ParsedDMSSpec] # 整个API的规格
) -> ExecutedTestCaseResult:
"""
执行单个测试用例。
@@ -797,7 +797,7 @@ class APITestOrchestrator:
if not endpoint_spec_dict: # 如果 to_dict() 返回空字典
# self.logger.warning(f"endpoint_spec.to_dict() (类型: {type(endpoint_spec)}) 返回了一个空字典。")
# 尝试备用转换
if isinstance(endpoint_spec, (YAPIEndpoint, SwaggerEndpoint)):
if isinstance(endpoint_spec, (YAPIEndpoint, SwaggerEndpoint, DMSEndpoint)):
# self.logger.debug(f"尝试从 {type(endpoint_spec).__name__} 对象的属性手动构建 endpoint_spec_dict。")
endpoint_spec_dict = {
"method": getattr(endpoint_spec, 'method', 'UNKNOWN_METHOD').upper(),
@@ -815,7 +815,7 @@ class APITestOrchestrator:
endpoint_spec_dict = {} # 重置为空,触发下方错误处理
except Exception as e:
self.logger.error(f"调用 endpoint_spec (类型: {type(endpoint_spec)}) 的 to_dict() 方法时出错: {e}。尝试备用转换。")
if isinstance(endpoint_spec, (YAPIEndpoint, SwaggerEndpoint)):
if isinstance(endpoint_spec, (YAPIEndpoint, SwaggerEndpoint, DMSEndpoint)):
self.logger.debug(f"尝试从 {type(endpoint_spec).__name__} 对象的属性手动构建 endpoint_spec_dict。")
endpoint_spec_dict = {
"method": getattr(endpoint_spec, 'method', 'UNKNOWN_METHOD').upper(),
@@ -837,7 +837,7 @@ class APITestOrchestrator:
endpoint_spec_dict = getattr(endpoint_spec, 'data')
# self.logger.debug(f"使用了类型为 {type(endpoint_spec)} 的 endpoint_spec 的 .data 属性。")
else: # 如果没有 to_dict, 也不是已知可直接访问 .data 的类型,则尝试最后的通用转换或手动构建
if isinstance(endpoint_spec, (YAPIEndpoint, SwaggerEndpoint)):
if isinstance(endpoint_spec, (YAPIEndpoint, SwaggerEndpoint, DMSEndpoint)):
# self.logger.debug(f"类型为 {type(endpoint_spec).__name__} 的 endpoint_spec 没有 to_dict() 或 data,尝试从属性手动构建。")
endpoint_spec_dict = {
"method": getattr(endpoint_spec, 'method', 'UNKNOWN_METHOD').upper(),
@@ -1477,8 +1477,8 @@ class APITestOrchestrator:
self.logger.info(f"[{operation_id}] 常规方法生成的 {param_type} 参数: {generated_params}")
return generated_params
def run_test_for_endpoint(self, endpoint: Union[YAPIEndpoint, SwaggerEndpoint],
global_api_spec: Union[ParsedYAPISpec, ParsedSwaggerSpec]
def run_test_for_endpoint(self, endpoint: Union[YAPIEndpoint, SwaggerEndpoint, DMSEndpoint],
global_api_spec: Union[ParsedYAPISpec, ParsedSwaggerSpec, ParsedDMSSpec]
) -> TestResult:
endpoint_id = f"{getattr(endpoint, 'method', 'GET').upper()} {getattr(endpoint, 'path', '/')}"
endpoint_name = getattr(endpoint, 'title', '') or getattr(endpoint, 'summary', '') or endpoint_id
@@ -2168,19 +2168,31 @@ class APITestOrchestrator:
stage_instance.before_step(step=step_definition, stage_context=stage_context, global_api_spec=parsed_spec, api_group_name=api_group_name)
self.logger.debug(f"{step_log_prefix}: 查找端点定义. Key='{step_definition.endpoint_spec_lookup_key}', Group='{api_group_name}'")
api_op_spec: Optional[APIOperationSpec] = stage_instance.get_api_spec_for_operation(
api_op_spec: Optional[Union[APIOperationSpec, BaseEndpoint]] = stage_instance.get_api_spec_for_operation(
lookup_key=step_definition.endpoint_spec_lookup_key,
global_api_spec=parsed_spec,
api_group_name=api_group_name
)
if not api_op_spec or not api_op_spec.spec:
actual_endpoint_spec_dict = None
endpoint_path = "N/A"
endpoint_method = "N/A"
if isinstance(api_op_spec, BaseEndpoint):
actual_endpoint_spec_dict = api_op_spec.to_dict()
endpoint_path = api_op_spec.path
endpoint_method = api_op_spec.method
elif hasattr(api_op_spec, 'spec') and api_op_spec.spec:
actual_endpoint_spec_dict = api_op_spec.spec
endpoint_path = getattr(api_op_spec, 'path', 'N/A')
endpoint_method = getattr(api_op_spec, 'method', 'N/A')
if not api_op_spec or not actual_endpoint_spec_dict:
current_step_result.status = ExecutedStageStepResult.Status.ERROR
current_step_result.message = f"找不到端点定义 (Key: '{step_definition.endpoint_spec_lookup_key}', Group: '{api_group_name}')."
self.logger.error(f"{step_log_prefix}: {current_step_result.message}")
else:
actual_endpoint_spec_dict = api_op_spec.spec
current_step_result.resolved_endpoint = f"{api_op_spec.method.upper()} {api_op_spec.path}"
current_step_result.resolved_endpoint = f"{endpoint_method.upper()} {endpoint_path}"
current_step_result.status = ExecutedStageStepResult.Status.PASSED # Assume pass initially
self.logger.info(f"{step_log_prefix}: 已解析端点 '{current_step_result.resolved_endpoint}'. 准备请求数据.")
@@ -2207,9 +2219,9 @@ class APITestOrchestrator:
final_headers['Content-Type'] = 'application/json'
self.logger.debug(f"{step_log_prefix}: 为JSON请求体设置默认Content-Type: application/json")
full_request_url = urljoin(self.base_url, self._format_url_with_path_params(api_op_spec.path, final_path_params)) # <-- MODIFIED
full_request_url = urljoin(self.base_url, self._format_url_with_path_params(endpoint_path, final_path_params))
api_request_obj = APIRequest(
method=api_op_spec.method,
method=endpoint_method,
url=full_request_url,
params=final_query_params,
headers=final_headers,
@@ -2555,7 +2567,7 @@ class APITestOrchestrator:
self.logger.info(f"Re-initializing TestCaseRegistry with new directory: {custom_test_cases_dir}")
self.test_case_registry = TestCaseRegistry(test_cases_dir=custom_test_cases_dir)
endpoints_to_test: List[Union[YAPIEndpoint, SwaggerEndpoint]] = []
endpoints_to_test: List[Union[YAPIEndpoint, SwaggerEndpoint, DMSEndpoint]] = []
if isinstance(parsed_spec, ParsedYAPISpec):
endpoints_to_test = parsed_spec.endpoints
if categories:
@@ -2564,6 +2576,10 @@ class APITestOrchestrator:
endpoints_to_test = parsed_spec.endpoints
if tags:
endpoints_to_test = [ep for ep in endpoints_to_test if hasattr(ep, 'tags') and isinstance(ep.tags, list) and any(tag in ep.tags for tag in tags)]
elif isinstance(parsed_spec, ParsedDMSSpec):
endpoints_to_test = parsed_spec.endpoints
if categories:
endpoints_to_test = [ep for ep in endpoints_to_test if hasattr(ep, 'category_name') and ep.category_name in categories]
summary.set_total_endpoints_defined(summary.total_endpoints_defined + len(endpoints_to_test))
@@ -2580,3 +2596,28 @@ class APITestOrchestrator:
return summary
def run_tests_from_dms(self, domain_mapping_path: str,
categories: Optional[List[str]] = None,
custom_test_cases_dir: Optional[str] = None
) -> Tuple[TestSummary, Optional[ParsedAPISpec]]:
"""
通过动态DMS服务发现来执行测试。
"""
summary = TestSummary()
parser = InputParser()
self.logger.info("从DMS动态服务启动测试...")
parsed_spec = parser.parse_dms_spec(domain_mapping_path, base_url=self.base_url)
if not parsed_spec:
self.logger.error("无法从DMS服务解析API,测试终止。")
summary.add_error("Could not parse APIs from DMS service.")
summary.finalize_summary()
return summary, None
self.run_stages_from_spec(parsed_spec, summary)
summary = self._execute_tests_from_parsed_spec(parsed_spec, summary, categories=categories, custom_test_cases_dir=custom_test_cases_dir)
summary.finalize_summary()
return summary, parsed_spec