mvp
This commit is contained in:
Vendored
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,505 @@
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Callable, Union
|
||||
|
||||
from ddms_compliance_suite.stage_framework import BaseAPIStage, StageStepDefinition, APIOperationSpec, ExecutedStageResult, ExecutedStageStepResult
|
||||
from ddms_compliance_suite.test_framework_core import ValidationResult, APIResponseContext
|
||||
from ddms_compliance_suite.input_parser.parser import ParsedAPISpec, YAPIEndpoint, SwaggerEndpoint
|
||||
|
||||
# --- Action Keywords for Discovery (Usually these can remain as is) ---
|
||||
CREATE_ACTION_KEYWORDS = ["add", "create", "new", "添加", "新建", "创建"]
|
||||
LIST_ACTION_KEYWORDS = ["list", "query", "search", "getall", "getlist", "列表查询", "查询列表", "获取列表", "分页查询"]
|
||||
DETAIL_ACTION_KEYWORDS = ["detail", "getbyid", "getone", "查询详情", "获取详情", "根据ID获取"]
|
||||
UPDATE_ACTION_KEYWORDS = ["update", "edit", "put", "修改", "更新"]
|
||||
DELETE_ACTION_KEYWORDS = ["delete", "remove", "删除"]
|
||||
|
||||
# --- Default values that might come from config or context ---
|
||||
DEFAULT_DMS_INSTANCE_CODE_PLACEHOLDER = "your_dms_instance_code"
|
||||
DEFAULT_API_VERSION_PLACEHOLDER = "1.0.0"
|
||||
DEFAULT_TENANT_ID_PLACEHOLDER = "your-tenant-id"
|
||||
DEFAULT_AUTHORIZATION_PLACEHOLDER = "Bearer your-test-token"
|
||||
|
||||
|
||||
# --- Helper to navigate JSON path ---
|
||||
def _get_value_by_path(data: Optional[Dict[str, Any]], path_str: Optional[str]):
|
||||
if data is None or path_str is None or not path_str:
|
||||
return data
|
||||
|
||||
current = data
|
||||
try:
|
||||
for part in path_str.split('.'):
|
||||
match_list_index = re.fullmatch(r"([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]", part)
|
||||
if match_list_index:
|
||||
list_name, index_str = match_list_index.groups()
|
||||
index = int(index_str)
|
||||
if isinstance(current, dict) and list_name in current and isinstance(current[list_name], list) and 0 <= index < len(current[list_name]):
|
||||
current = current[list_name][index]
|
||||
else: return None
|
||||
elif isinstance(current, list) and part.isdigit():
|
||||
idx = int(part)
|
||||
if 0 <= idx < len(current):
|
||||
current = current[idx]
|
||||
else:
|
||||
return None
|
||||
elif isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return None
|
||||
except (TypeError, ValueError, IndexError, KeyError) as e:
|
||||
logging.getLogger(__name__).debug(f"Error navigating path '{path_str}': {e}")
|
||||
return None
|
||||
return current
|
||||
|
||||
# --- Custom Assertion Helper Functions & Context Manipulators ---
|
||||
# These functions now read field names and paths from stage_ctx,
|
||||
# which should be populated by the Stage based on its configuration.
|
||||
|
||||
def find_resource_in_list_and_extract_id(response_ctx: APIResponseContext, stage_ctx: dict) -> ValidationResult:
|
||||
logger = logging.getLogger(__name__)
|
||||
unique_name_to_find = stage_ctx.get("unique_resource_name")
|
||||
|
||||
list_path = stage_ctx.get("cfg_list_path_in_listresponse", "data.list")
|
||||
name_field = stage_ctx.get("cfg_name_field_in_listitem", "name")
|
||||
id_field = stage_ctx.get("cfg_id_field_in_listitem", "id")
|
||||
|
||||
if not unique_name_to_find:
|
||||
return ValidationResult(passed=False, message="Context error: 'unique_resource_name' not found in stage_context.")
|
||||
|
||||
response_data = response_ctx.json_content
|
||||
resource_list = _get_value_by_path(response_data, list_path)
|
||||
|
||||
if not isinstance(resource_list, list):
|
||||
return ValidationResult(passed=False, message=f"Response format error: Expected path '{list_path}' to be a list, got {type(resource_list)}.")
|
||||
|
||||
found_item = None
|
||||
for item in resource_list:
|
||||
if isinstance(item, dict) and item.get(name_field) == unique_name_to_find:
|
||||
found_item = item
|
||||
break
|
||||
|
||||
if not found_item:
|
||||
return ValidationResult(passed=False, message=f"Resource not found: Name '{unique_name_to_find}' (field: {name_field}) not found in list at path '{list_path}'.")
|
||||
|
||||
resource_id = found_item.get(id_field)
|
||||
if resource_id is None:
|
||||
return ValidationResult(passed=False, message=f"Resource found by name, but it lacks the ID field '{id_field}'.")
|
||||
|
||||
stage_ctx["created_resource_id"] = resource_id
|
||||
logger.info(f"Successfully found resource '{unique_name_to_find}' in list and extracted its ID ({id_field}): {resource_id}.")
|
||||
return ValidationResult(passed=True, message=f"Found resource '{unique_name_to_find}' by name (field '{name_field}'). ID ('{id_field}': {resource_id}) available in context. Prefer 'outputs_to_context' for extraction.")
|
||||
|
||||
|
||||
def check_resource_details(response_ctx: APIResponseContext, stage_ctx: dict) -> ValidationResult:
|
||||
resource_object_path = stage_ctx.get("cfg_resource_object_path_in_detailresponse", "")
|
||||
name_field = stage_ctx.get("cfg_name_field_in_detailresponse", "name")
|
||||
id_field = stage_ctx.get("cfg_id_field_in_detailresponse", "id")
|
||||
feature_field_name = stage_ctx.get("cfg_feature_field_name_for_validation")
|
||||
expected_feature_value = stage_ctx.get("unique_feature_value")
|
||||
|
||||
created_id = stage_ctx.get("created_resource_id")
|
||||
initial_name = stage_ctx.get("unique_resource_name")
|
||||
|
||||
resource_data = _get_value_by_path(response_ctx.json_content, resource_object_path)
|
||||
|
||||
if not isinstance(resource_data, dict):
|
||||
return ValidationResult(passed=False, message=f"Response format error: Expected path '{resource_object_path}' to be an object, got {type(resource_data)}.")
|
||||
|
||||
validation_results = []
|
||||
if resource_data.get(id_field) != created_id:
|
||||
validation_results.append(ValidationResult(passed=False, message=f"ID mismatch. Expected '{created_id}', got '{resource_data.get(id_field)}' (field: {id_field})."))
|
||||
else:
|
||||
validation_results.append(ValidationResult(passed=True, message=f"ID match: '{created_id}' (field: {id_field})."))
|
||||
|
||||
if name_field and resource_data.get(name_field) != initial_name:
|
||||
validation_results.append(ValidationResult(passed=False, message=f"Name mismatch. Expected '{initial_name}', got '{resource_data.get(name_field)}' (field: {name_field})."))
|
||||
elif name_field:
|
||||
validation_results.append(ValidationResult(passed=True, message=f"Name match: '{initial_name}' (field: {name_field})."))
|
||||
|
||||
if feature_field_name and expected_feature_value is not None:
|
||||
actual_feature_value = resource_data.get(feature_field_name)
|
||||
if actual_feature_value != expected_feature_value:
|
||||
validation_results.append(ValidationResult(passed=False, message=f"Feature field '{feature_field_name}' mismatch. Expected '{expected_feature_value}', got '{actual_feature_value}'."))
|
||||
else:
|
||||
validation_results.append(ValidationResult(passed=True, message=f"Feature field '{feature_field_name}' match: '{expected_feature_value}'."))
|
||||
|
||||
overall_passed = all(vr.passed for vr in validation_results)
|
||||
combined_message = "Resource details check: " + "; ".join([vr.message for vr in validation_results])
|
||||
return ValidationResult(passed=overall_passed, message=combined_message, details=[vr.to_dict() for vr in validation_results])
|
||||
|
||||
|
||||
def check_resource_updated_details(response_ctx: APIResponseContext, stage_ctx: dict) -> ValidationResult:
|
||||
resource_object_path = stage_ctx.get("cfg_resource_object_path_in_updateresponse", stage_ctx.get("cfg_resource_object_path_in_detailresponse", ""))
|
||||
name_field = stage_ctx.get("cfg_name_field_in_detailresponse", "name")
|
||||
id_field = stage_ctx.get("cfg_id_field_in_detailresponse", "id")
|
||||
feature_field_name = stage_ctx.get("cfg_feature_field_name_for_validation")
|
||||
expected_updated_feature_value = stage_ctx.get("updated_feature_value")
|
||||
|
||||
created_id = stage_ctx.get("created_resource_id")
|
||||
updated_name = stage_ctx.get("updated_resource_name")
|
||||
|
||||
resource_data = _get_value_by_path(response_ctx.json_content, resource_object_path)
|
||||
|
||||
if not isinstance(resource_data, dict):
|
||||
return ValidationResult(passed=False, message=f"Updated detail response format error: Expected path '{resource_object_path}' to be an object, got {type(resource_data)}.")
|
||||
|
||||
validation_results = []
|
||||
if resource_data.get(id_field) != created_id:
|
||||
validation_results.append(ValidationResult(passed=False, message=f"Updated ID mismatch. Expected '{created_id}', got '{resource_data.get(id_field)}'."))
|
||||
else:
|
||||
validation_results.append(ValidationResult(passed=True, message=f"Updated ID match: '{created_id}'."))
|
||||
|
||||
if name_field and resource_data.get(name_field) != updated_name:
|
||||
validation_results.append(ValidationResult(passed=False, message=f"Updated name mismatch. Expected '{updated_name}', got '{resource_data.get(name_field)}'."))
|
||||
elif name_field:
|
||||
validation_results.append(ValidationResult(passed=True, message=f"Updated name match: '{updated_name}'."))
|
||||
|
||||
if feature_field_name and expected_updated_feature_value is not None:
|
||||
actual_feature_value = resource_data.get(feature_field_name)
|
||||
if actual_feature_value != expected_updated_feature_value:
|
||||
validation_results.append(ValidationResult(passed=False, message=f"Updated feature field '{feature_field_name}' mismatch. Expected '{expected_updated_feature_value}', got '{actual_feature_value}'."))
|
||||
else:
|
||||
validation_results.append(ValidationResult(passed=True, message=f"Updated feature field '{feature_field_name}' match: '{expected_updated_feature_value}'."))
|
||||
|
||||
overall_passed = all(vr.passed for vr in validation_results)
|
||||
combined_message = "Updated resource details check: " + "; ".join([vr.message for vr in validation_results])
|
||||
return ValidationResult(passed=overall_passed, message=combined_message, details=[vr.to_dict() for vr in validation_results])
|
||||
|
||||
|
||||
class GenericCRUDValidationStage(BaseAPIStage):
|
||||
id = "generic_crud_validation_stage"
|
||||
name = "Generic CRUD Validation Stage"
|
||||
description = (
|
||||
"Performs a generic CRUD (Create, List, Read, Update, Delete) flow. "
|
||||
"It tries to dynamically discover API operations based on common keywords and HTTP methods. "
|
||||
"Field names for IDs, names, features, and JSON paths for list/detail objects are configurable "
|
||||
"via class attributes or can be set in `before_stage` based on `api_group_name`."
|
||||
)
|
||||
tags = ["crud", "generic_validation"]
|
||||
continue_on_failure = False
|
||||
fail_if_not_applicable_to_any_group = False
|
||||
|
||||
# --- Configurable field names and JSON paths ---
|
||||
cfg_id_field_in_create_response: str = "data.0.dsid"
|
||||
cfg_name_field_in_create_payload: str = "wellCommonName"
|
||||
cfg_feature_field_in_create_payload: str = "dataRegion"
|
||||
|
||||
cfg_list_path_in_list_response: str = "data.list"
|
||||
cfg_id_field_in_list_item: str = "dsid"
|
||||
cfg_name_field_in_list_item: str = "wellCommonName"
|
||||
cfg_feature_field_in_list_item: str = "dataRegion"
|
||||
|
||||
cfg_resource_object_path_in_detail_response: str = "data.list.0"
|
||||
cfg_id_field_in_detail_response: str = "dsid"
|
||||
cfg_name_field_in_detail_response: str = "wellCommonName"
|
||||
cfg_feature_field_in_detail_response: str = "dataRegion"
|
||||
|
||||
cfg_resource_object_path_in_update_response: str = "data.list.0"
|
||||
|
||||
cfg_path_param_name_for_id: str = "id"
|
||||
|
||||
discovered_op_keys: Dict[str, Optional[str]] = {}
|
||||
steps: list = []
|
||||
|
||||
def _determine_resource_config(self, api_group_name: Optional[str], global_api_spec: ParsedAPISpec):
|
||||
self.logger.info(f"Using default field/path configurations for group '{api_group_name}'. Customize by overriding _determine_resource_config or cfg_* attributes.")
|
||||
pass
|
||||
|
||||
|
||||
def _find_operation_best_match(self, endpoints: List[Union[YAPIEndpoint, SwaggerEndpoint]],
|
||||
action_kws: List[str],
|
||||
target_method: Optional[str] = None,
|
||||
path_must_contain_id: bool = False,
|
||||
path_must_not_contain_id: bool = False) -> Optional[str]:
|
||||
self.logger.debug(f"Attempting to find operation: target_actions='{action_kws}', target_method='{target_method}', path_id_required={path_must_contain_id}, path_id_forbidden={path_must_not_contain_id}")
|
||||
|
||||
best_candidate_key: Optional[str] = None
|
||||
|
||||
for ep_obj in endpoints:
|
||||
ep_title = getattr(ep_obj, 'title', 'N/A')
|
||||
ep_method_actual = getattr(ep_obj, 'method', 'N/A').upper()
|
||||
ep_path = getattr(ep_obj, 'path', 'N/A')
|
||||
self.logger.debug(f" Checking endpoint: Title='{ep_title}', Method='{ep_method_actual}', Path='{ep_path}', Type='{type(ep_obj).__name__}'")
|
||||
# Log all attributes of the endpoint object to see what the parser provided
|
||||
if isinstance(ep_obj, YAPIEndpoint):
|
||||
self.logger.debug(f" Raw YAPIEndpoint object attributes for '{ep_title}': {vars(ep_obj)}")
|
||||
|
||||
text_to_match = getattr(ep_obj, 'title', None) or \
|
||||
getattr(ep_obj, 'summary', None) or \
|
||||
getattr(ep_obj, 'operation_id', None)
|
||||
|
||||
if not text_to_match:
|
||||
self.logger.debug(f" Skipping endpoint Title='{ep_title}', Path='{ep_path}': No text_to_match (title/summary/opId).")
|
||||
continue
|
||||
|
||||
lookup_key = getattr(ep_obj, 'title', None) or \
|
||||
getattr(ep_obj, 'operation_id', None) or \
|
||||
f"{ep_method_actual} {ep_path}"
|
||||
|
||||
text_lower = text_to_match.lower()
|
||||
|
||||
action_match = any(action_kw.lower() in text_lower for action_kw in action_kws)
|
||||
self.logger.debug(f" For '{ep_title}': Action match with '{action_kws}'? {action_match}")
|
||||
|
||||
method_match = (not target_method) or (ep_method_actual == target_method.upper())
|
||||
self.logger.debug(f" For '{ep_title}': Method match with '{target_method}'? {method_match} (Endpoint method: {ep_method_actual})")
|
||||
|
||||
path_contains_id_param = False
|
||||
if isinstance(ep_obj, YAPIEndpoint):
|
||||
self.logger.debug(f" For YAPI Endpoint '{ep_title}': Checking req_params for path ID: {ep_obj.req_params}")
|
||||
for param in ep_obj.req_params: # YAPI req_params are path parameters
|
||||
if isinstance(param, dict):
|
||||
param_name = param.get('name', 'UnnamedParam')
|
||||
if param_name == self.cfg_path_param_name_for_id or 'id' in param_name.lower():
|
||||
path_contains_id_param = True
|
||||
self.logger.debug(f" Found ID path parameter in YAPI req_params: '{param_name}'")
|
||||
break
|
||||
elif isinstance(ep_obj, SwaggerEndpoint):
|
||||
swagger_params = getattr(ep_obj, 'parameters', None)
|
||||
self.logger.debug(f" For Swagger Endpoint '{ep_title}': Checking swagger_params for path ID: {swagger_params}")
|
||||
if isinstance(swagger_params, list):
|
||||
for param in swagger_params:
|
||||
if isinstance(param, dict):
|
||||
param_name = param.get('name', 'UnnamedParam')
|
||||
param_in = param.get('in', 'N/A')
|
||||
if param_in == 'path' and \
|
||||
(param_name == self.cfg_path_param_name_for_id or 'id' in param_name.lower()):
|
||||
path_contains_id_param = True
|
||||
self.logger.debug(f" Found ID path parameter in Swagger parameters: '{param_name}'")
|
||||
break
|
||||
else:
|
||||
self.logger.warning(f" For '{ep_title}': Endpoint is of unknown type {type(ep_obj).__name__} for parameter extraction. Assuming no path ID param.")
|
||||
|
||||
self.logger.debug(f" For '{ep_title}': Calculated path_contains_id_param={path_contains_id_param} (cfg_path_param_name_for_id='{self.cfg_path_param_name_for_id}')")
|
||||
|
||||
path_condition_met = True
|
||||
if path_must_contain_id and not path_contains_id_param:
|
||||
path_condition_met = False
|
||||
if path_must_not_contain_id and path_contains_id_param:
|
||||
path_condition_met = False
|
||||
self.logger.debug(f" For '{ep_title}': Calculated path_condition_met={path_condition_met} (required={path_must_contain_id}, forbidden={path_must_not_contain_id})")
|
||||
|
||||
if action_match and method_match and path_condition_met:
|
||||
self.logger.info(f" SUCCESS: Op match for actions '{action_kws}': Key='{lookup_key}', MatchedText='{text_to_match}', Method='{ep_method_actual}', Path='{ep_path}'")
|
||||
best_candidate_key = lookup_key
|
||||
break
|
||||
else:
|
||||
self.logger.debug(f" Criteria NOT met for '{ep_title}' (Action: {action_match}, Method: {method_match}, PathCond: {path_condition_met}). Continuing search.")
|
||||
|
||||
if not best_candidate_key:
|
||||
self.logger.warning(f" FAILURE: No operation found after checking all endpoints for: actions='{action_kws}', method='{target_method}', path_id_required={path_must_contain_id}, path_id_forbidden={path_must_not_contain_id}")
|
||||
return best_candidate_key
|
||||
|
||||
|
||||
def is_applicable_to_api_group(self, api_group_name: Optional[str], global_api_spec: ParsedAPISpec) -> bool:
|
||||
self.logger.info(f"Checking applicability of '{self.name}' for API group '{api_group_name}'.")
|
||||
if not global_api_spec or not global_api_spec.endpoints:
|
||||
self.logger.warning(f"'{self.name}' cannot determine applicability: global_api_spec or its endpoints are missing.")
|
||||
return False
|
||||
|
||||
self._determine_resource_config(api_group_name, global_api_spec)
|
||||
|
||||
self.discovered_op_keys = {}
|
||||
endpoints_in_group = self.apis_in_group
|
||||
|
||||
self.discovered_op_keys["create"] = self._find_operation_best_match(endpoints_in_group, CREATE_ACTION_KEYWORDS, "POST", path_must_not_contain_id=True)
|
||||
self.discovered_op_keys["list"] = self._find_operation_best_match(endpoints_in_group, LIST_ACTION_KEYWORDS, None, path_must_not_contain_id=True)
|
||||
self.discovered_op_keys["detail"] = self._find_operation_best_match(endpoints_in_group, DETAIL_ACTION_KEYWORDS, "GET", path_must_contain_id=True)
|
||||
self.discovered_op_keys["update"] = self._find_operation_best_match(endpoints_in_group, UPDATE_ACTION_KEYWORDS, "PUT", path_must_contain_id=True)
|
||||
self.discovered_op_keys["delete"] = self._find_operation_best_match(endpoints_in_group, DELETE_ACTION_KEYWORDS, "DELETE", path_must_contain_id=False)
|
||||
|
||||
required_ops = ["create", "detail", "delete"]
|
||||
missing_required_ops = [op for op in required_ops if not self.discovered_op_keys.get(op)]
|
||||
|
||||
if not missing_required_ops:
|
||||
self.logger.info(f"'{self.name}' is APPLICABLE for group '{api_group_name}'. Required CRUD operations found. Discovered keys: {self.discovered_op_keys}")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(f"'{self.name}' is NOT APPLICABLE for group '{api_group_name}'. Missing required operations: {missing_required_ops}. Discovered keys: {self.discovered_op_keys}")
|
||||
return False
|
||||
|
||||
def before_stage(self, stage_context: dict, global_api_spec: ParsedAPISpec, api_group_name: str | None):
|
||||
self._determine_resource_config(api_group_name, global_api_spec)
|
||||
|
||||
self.logger.info(f"Starting stage '{self.name}' for API group '{api_group_name}'. Discovered op keys: {self.discovered_op_keys}")
|
||||
|
||||
run_timestamp = int(time.time())
|
||||
run_uuid_short = uuid.uuid4().hex[:6]
|
||||
|
||||
unique_name_for_run = f"TestResource_{run_timestamp}_{run_uuid_short}"
|
||||
updated_name_for_run = f"{unique_name_for_run}_UPDATED"
|
||||
unique_feature_val = f"Feature_{run_timestamp}"
|
||||
updated_feature_val = f"{unique_feature_val}_UPDATED"
|
||||
|
||||
stage_context["unique_resource_name"] = unique_name_for_run
|
||||
stage_context["updated_resource_name"] = updated_name_for_run
|
||||
stage_context["unique_feature_value"] = unique_feature_val
|
||||
stage_context["updated_feature_value"] = updated_feature_val
|
||||
|
||||
stage_context["cfg_list_path_in_listresponse"] = self.cfg_list_path_in_list_response
|
||||
stage_context["cfg_name_field_in_listitem"] = self.cfg_name_field_in_list_item
|
||||
stage_context["cfg_id_field_in_listitem"] = self.cfg_id_field_in_list_item
|
||||
stage_context["cfg_resource_object_path_in_detailresponse"] = self.cfg_resource_object_path_in_detail_response
|
||||
stage_context["cfg_name_field_in_detailresponse"] = self.cfg_name_field_in_detail_response
|
||||
stage_context["cfg_id_field_in_detailresponse"] = self.cfg_id_field_in_detail_response
|
||||
stage_context["cfg_resource_object_path_in_updateresponse"] = self.cfg_resource_object_path_in_update_response
|
||||
stage_context["cfg_feature_field_name_for_validation"] = self.cfg_feature_field_in_create_payload
|
||||
|
||||
stage_context["dms_instance_code"] = DEFAULT_DMS_INSTANCE_CODE_PLACEHOLDER
|
||||
stage_context["api_version"] = DEFAULT_API_VERSION_PLACEHOLDER
|
||||
|
||||
self.logger.info(f"Initial stage context (with cfg): {stage_context}")
|
||||
|
||||
if not self.discovered_op_keys.get("create") or \
|
||||
not self.discovered_op_keys.get("detail") or \
|
||||
not self.discovered_op_keys.get("delete"):
|
||||
self.logger.error("Cannot build steps as not all essential operation keys (create, detail, delete) were discovered. Stage will have no steps.")
|
||||
self.steps = []
|
||||
return
|
||||
|
||||
create_body_data = {
|
||||
self.cfg_name_field_in_create_payload: "{{stage_context.unique_resource_name}}",
|
||||
}
|
||||
if self.cfg_feature_field_in_create_payload:
|
||||
create_body_data[self.cfg_feature_field_in_create_payload] = "{{stage_context.unique_feature_value}}"
|
||||
|
||||
final_create_body = {
|
||||
"version": "{{stage_context.api_version}}",
|
||||
"data": [create_body_data]
|
||||
}
|
||||
|
||||
self.steps = []
|
||||
|
||||
if self.discovered_op_keys["create"]:
|
||||
self.steps.append(StageStepDefinition(
|
||||
name="Create New Resource",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["create"],
|
||||
request_overrides={
|
||||
"path_params": {"dms_instance_code": "{{stage_context.dms_instance_code}}"},
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
|
||||
"body": final_create_body
|
||||
},
|
||||
expected_status_codes=[200, 201],
|
||||
outputs_to_context={"created_resource_id": self.cfg_id_field_in_create_response}
|
||||
))
|
||||
|
||||
if self.discovered_op_keys["list"] and self.discovered_op_keys["create"]:
|
||||
list_query_filter = {
|
||||
"key": self.cfg_name_field_in_list_item,
|
||||
"symbol": "=",
|
||||
"realValue": ["{{stage_context.unique_resource_name}}"]
|
||||
}
|
||||
self.steps.append(StageStepDefinition(
|
||||
name="List and Find Created Resource",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["list"],
|
||||
request_overrides={
|
||||
"path_params": {
|
||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||
"version": "{{stage_context.api_version}}"
|
||||
},
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
|
||||
"query_params": {"pageNo": 1, "pageSize": 10},
|
||||
"body": {
|
||||
"isSearchCount": True,
|
||||
"query": {
|
||||
"fields": [self.cfg_id_field_in_list_item, self.cfg_name_field_in_list_item, self.cfg_feature_field_in_list_item],
|
||||
"filter": list_query_filter
|
||||
}
|
||||
}
|
||||
},
|
||||
expected_status_codes=[200],
|
||||
response_assertions=[find_resource_in_list_and_extract_id],
|
||||
outputs_to_context={"found_id_from_list": f"{self.cfg_list_path_in_list_response}.0.{self.cfg_id_field_in_list_item}"}
|
||||
))
|
||||
|
||||
if self.discovered_op_keys["detail"] and self.discovered_op_keys["create"]:
|
||||
self.steps.append(StageStepDefinition(
|
||||
name="Get Created Resource Details",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["detail"],
|
||||
request_overrides={
|
||||
"path_params": {
|
||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||
"version": "{{stage_context.api_version}}",
|
||||
self.cfg_path_param_name_for_id: "{{stage_context.created_resource_id}}"
|
||||
},
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER}
|
||||
},
|
||||
expected_status_codes=[200],
|
||||
response_assertions=[check_resource_details]
|
||||
))
|
||||
|
||||
if self.discovered_op_keys["update"] and self.discovered_op_keys["create"]:
|
||||
update_body_data = {
|
||||
self.cfg_id_field_in_detail_response: "{{stage_context.created_resource_id}}",
|
||||
self.cfg_name_field_in_detail_response: "{{stage_context.updated_resource_name}}",
|
||||
}
|
||||
if self.cfg_feature_field_in_create_payload:
|
||||
update_body_data[self.cfg_feature_field_in_create_payload] = "{{stage_context.updated_feature_value}}"
|
||||
|
||||
final_update_body = {
|
||||
"id": "{{stage_context.created_resource_id}}",
|
||||
"version": "{{stage_context.api_version}}",
|
||||
**update_body_data
|
||||
}
|
||||
self.steps.append(StageStepDefinition(
|
||||
name="Update Created Resource",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["update"],
|
||||
request_overrides={
|
||||
"path_params": {
|
||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||
self.cfg_path_param_name_for_id: "{{stage_context.created_resource_id}}"
|
||||
},
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
|
||||
"body": final_update_body
|
||||
},
|
||||
expected_status_codes=[200],
|
||||
))
|
||||
|
||||
if self.discovered_op_keys["detail"] and self.discovered_op_keys["update"] and self.discovered_op_keys["create"]:
|
||||
self.steps.append(StageStepDefinition(
|
||||
name="Get Updated Resource Details",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["detail"],
|
||||
request_overrides={
|
||||
"path_params": {
|
||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||
"version": "{{stage_context.api_version}}",
|
||||
self.cfg_path_param_name_for_id: "{{stage_context.created_resource_id}}"
|
||||
},
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER}
|
||||
},
|
||||
expected_status_codes=[200],
|
||||
response_assertions=[check_resource_updated_details]
|
||||
))
|
||||
|
||||
if self.discovered_op_keys["delete"] and self.discovered_op_keys["create"]:
|
||||
self.steps.append(StageStepDefinition(
|
||||
name="Delete Created Resource",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["delete"],
|
||||
request_overrides={
|
||||
"path_params": {
|
||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||
self.cfg_path_param_name_for_id: "{{stage_context.created_resource_id}}"
|
||||
},
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
|
||||
},
|
||||
expected_status_codes=[200, 204],
|
||||
))
|
||||
|
||||
if self.discovered_op_keys["detail"] and self.discovered_op_keys["delete"] and self.discovered_op_keys["create"]:
|
||||
self.steps.append(StageStepDefinition(
|
||||
name="Verify Resource Deletion",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["detail"],
|
||||
request_overrides={
|
||||
"path_params": {
|
||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||
"version": "{{stage_context.api_version}}",
|
||||
self.cfg_path_param_name_for_id: "{{stage_context.created_resource_id}}"
|
||||
},
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER}
|
||||
},
|
||||
expected_status_codes=[404],
|
||||
))
|
||||
|
||||
def after_stage(self, stage_result: ExecutedStageResult, stage_context: dict, global_api_spec: ParsedAPISpec, api_group_name: str | None):
|
||||
self.logger.info(f"Finished stage '{self.name}'. API Group: '{api_group_name}'. Final Status: {stage_result.overall_status}. Final Context Keys: {list(stage_context.keys())}")
|
||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
DROP TABLE IF EXISTS user;
|
||||
|
||||
CREATE TABLE user (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API 测试工具</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>API 合规性测试</h1>
|
||||
|
||||
<div class="configuration-section">
|
||||
<h2>测试配置</h2>
|
||||
<form id="test-form">
|
||||
<div class="form-group">
|
||||
<label for="base_url">API 基础URL (必填):</label>
|
||||
<input type="text" id="base_url" name="base_url" required placeholder="例如:http://localhost:8080/api/v1">
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>API 定义源 (选择一个)</legend>
|
||||
<div class="form-group">
|
||||
<label for="api_spec_type">API 规范类型:</label>
|
||||
<select id="api_spec_type" name="api_spec_type">
|
||||
<option value="YAPI">YAPI (.json)</option>
|
||||
<option value="Swagger">Swagger/OpenAPI (.json, .yaml)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="api_spec_file">上传 API 规范文件:</label>
|
||||
<input type="file" id="api_spec_file" name="api_spec_file" accept=".json,.yaml,.yml" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="button" id="load-spec-btn">加载分类/标签</button>
|
||||
</div>
|
||||
<div id="yapi-categories-container" class="checkbox-container"></div>
|
||||
<div id="swagger-tags-container" class="checkbox-container"></div>
|
||||
</fieldset>
|
||||
|
||||
<details>
|
||||
<summary>高级配置 (点击展开)</summary>
|
||||
<div class="form-group">
|
||||
<label for="custom_test_cases_dir">自定义测试用例目录:</label>
|
||||
<input type="text" id="custom_test_cases_dir" name="custom_test_cases_dir" placeholder="例如:./custom_testcases" value="./custom_testcases">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stages_dir">自定义阶段目录:</label>
|
||||
<input type="text" id="stages_dir" name="stages_dir" placeholder="例如:./custom_stages" value="./custom_stages">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="output_dir">报告输出目录:</label>
|
||||
<input type="text" id="output_dir" name="output_dir" placeholder="例如:./test_reports" value="./test_reports">
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>LLM 配置 (可选, 点击展开)</summary>
|
||||
<fieldset>
|
||||
<legend>LLM 配置 (可选)</legend>
|
||||
<div class="form-group">
|
||||
<label for="llm_api_key">LLM API Key:</label>
|
||||
<input type="password" id="llm_api_key" name="llm_api_key" placeholder="留空则尝试读取环境变量">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="llm_base_url">LLM Base URL:</label>
|
||||
<input type="text" id="llm_base_url" name="llm_base_url" placeholder="例如:https://dashscope.aliyuncs.com/compatible-mode/v1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="llm_model_name">LLM 模型名称:</label>
|
||||
<input type="text" id="llm_model_name" name="llm_model_name" placeholder="例如:qwen-plus">
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="use_llm_for_request_body" name="use_llm_for_request_body">
|
||||
<label for="use_llm_for_request_body">使用LLM生成请求体</label>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="use_llm_for_path_params" name="use_llm_for_path_params">
|
||||
<label for="use_llm_for_path_params">使用LLM生成路径参数</label>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="use_llm_for_query_params" name="use_llm_for_query_params">
|
||||
<label for="use_llm_for_query_params">使用LLM生成查询参数</label>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="use_llm_for_headers" name="use_llm_for_headers">
|
||||
<label for="use_llm_for_headers">使用LLM生成头部参数</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</details>
|
||||
|
||||
<button type="submit" class="submit-button">运行测试</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="results-section">
|
||||
<h2>测试日志与结果</h2>
|
||||
<label for="log-output">实时日志:</label>
|
||||
<textarea id="log-output" readonly style="width:100%"></textarea>
|
||||
<div id="results-container">
|
||||
<!-- 测试结果将在此处动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,139 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('test-form');
|
||||
const logOutput = document.getElementById('log-output');
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
const loadSpecBtn = document.getElementById('load-spec-btn');
|
||||
const apiSpecFileInput = document.getElementById('api_spec_file');
|
||||
const apiSpecTypeSelect = document.getElementById('api_spec_type');
|
||||
const yapiCategoriesContainer = document.getElementById('yapi-categories-container');
|
||||
const swaggerTagsContainer = document.getElementById('swagger-tags-container');
|
||||
|
||||
// Make the log output area larger as per user request
|
||||
if (logOutput) {
|
||||
logOutput.rows = 25;
|
||||
}
|
||||
|
||||
// Event listener for the new "Load Categories/Tags" button
|
||||
if (loadSpecBtn) {
|
||||
loadSpecBtn.addEventListener('click', async () => {
|
||||
const specType = apiSpecTypeSelect.value;
|
||||
const file = apiSpecFileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
alert('请先选择一个 API 规范文件。');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('api_spec_file', file);
|
||||
|
||||
let url = '';
|
||||
let container = null;
|
||||
|
||||
if (specType === 'YAPI') {
|
||||
url = '/list-yapi-categories';
|
||||
container = yapiCategoriesContainer;
|
||||
swaggerTagsContainer.style.display = 'none';
|
||||
yapiCategoriesContainer.style.display = 'block';
|
||||
} else { // Swagger
|
||||
url = '/list-swagger-tags';
|
||||
container = swaggerTagsContainer;
|
||||
yapiCategoriesContainer.style.display = 'none';
|
||||
swaggerTagsContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
container.innerHTML = '<p>正在加载...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData, // Send FormData directly
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: '无法解析错误响应' }));
|
||||
throw new Error(errorData.error || `请求 ${specType} 分类/标签时出错`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
renderItemsList(container, data, specType); // Using a simplified renderer
|
||||
} catch (error) {
|
||||
console.error(`请求${specType}分类时出错:`, error);
|
||||
container.innerHTML = `<p class="error">加载失败: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
logOutput.value = '正在开始测试...\n';
|
||||
resultsContainer.innerHTML = '';
|
||||
|
||||
// FormData will correctly handle all form fields, including the file upload
|
||||
const formData = new FormData(form);
|
||||
|
||||
try {
|
||||
const response = await fetch('/run-tests', {
|
||||
method: 'POST',
|
||||
// For FormData, the browser sets the Content-Type to multipart/form-data with the correct boundary.
|
||||
// Do not set the 'Content-Type' header manually.
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to parse the error, provide a fallback message.
|
||||
const errorMessage = result.error || '运行测试时发生未知错误';
|
||||
logOutput.value += `\n错误: ${errorMessage}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Restore summary to the log output
|
||||
logOutput.value += '\n测试执行完成。\n\n';
|
||||
logOutput.value += '--- 测试摘要 ---\n';
|
||||
logOutput.value += JSON.stringify(result.summary, null, 2);
|
||||
|
||||
displayResults(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('运行测试时捕获到错误:', error);
|
||||
logOutput.value += `\n\n发生严重错误: ${error.message}`;
|
||||
resultsContainer.innerHTML = `<p class="error">测试运行失败: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
// A simplified function to render categories/tags as a list
|
||||
function renderItemsList(container, items, type) {
|
||||
if (!items || items.length === 0) {
|
||||
container.innerHTML = '<p>未找到任何项。</p>';
|
||||
return;
|
||||
}
|
||||
let html = `<h4>${type} ${type === 'YAPI' ? '分类' : '标签'}:</h4><ul>`;
|
||||
items.forEach(item => {
|
||||
html += `<li><strong>${item.name}</strong>: ${item.description || '无描述'}</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function displayResults(result) {
|
||||
// Per user request, only show download links and remove the summary view.
|
||||
let linksHtml = '<h3>下载报告</h3>';
|
||||
if (result.summary_report_path) {
|
||||
linksHtml += `<p><a href="${result.summary_report_path}" target="_blank" class="report-link">摘要报告 (JSON)</a></p>`;
|
||||
}
|
||||
if (result.details_report_path) {
|
||||
linksHtml += `<p><a href="${result.details_report_path}" target="_blank" class="report-link">API 调用详情 (Markdown)</a></p>`;
|
||||
}
|
||||
|
||||
if (!result.summary_report_path && !result.details_report_path) {
|
||||
linksHtml += '<p>没有可用的报告文件。</p>';
|
||||
}
|
||||
|
||||
resultsContainer.innerHTML = linksHtml;
|
||||
}
|
||||
});
|
||||
|
||||
// The old functions fetchYapiCategories, fetchSwaggerTags, and renderCheckboxes are no longer needed
|
||||
// and should be removed if they exist elsewhere in this file.
|
||||
@@ -0,0 +1,161 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f4f7f6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background-color: #fff;
|
||||
padding: 25px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #34495e;
|
||||
border-bottom: 2px solid #ecf0f1;
|
||||
padding-bottom: 10px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="password"],
|
||||
.form-group input[type="url"] {
|
||||
width: calc(100% - 22px);
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group input[type="password"]:focus,
|
||||
.form-group input[type="url"]:focus {
|
||||
border-color: #3498db;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
font-weight: normal;
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
margin-right: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0 10px;
|
||||
font-weight: bold;
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background-color: #2ecc71;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.1em;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
#status-area {
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background-color: #ecf0f1;
|
||||
border: 1px solid #bdc3c7;
|
||||
}
|
||||
|
||||
#results-output {
|
||||
background-color: #2c3e50;
|
||||
color: #ecf0f1;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap; /* Allows wrapping and preserves whitespace */
|
||||
word-wrap: break-word; /* Breaks long words to prevent overflow */
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
}
|
||||
|
||||
#report-link-area p {
|
||||
margin-top: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.categories-tags-container {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.categories-tags-container div {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.categories-tags-container label {
|
||||
font-weight: normal;
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user