#!/usr/bin/env python # -*- coding: utf-8 -*- """ API测试工具 此工具使用DDMS测试编排器从YAPI或Swagger定义执行API测试。 支持使用规则库进行高级验证。 """ import os import sys import json import logging import argparse from pathlib import Path from typing import List, Optional from ddms_compliance_suite.api_caller.caller import APICallDetail from ddms_compliance_suite.test_orchestrator import APITestOrchestrator, TestSummary # 配置日志 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) def parse_args(): """解析命令行参数""" parser = argparse.ArgumentParser(description='DDMS API测试工具') # 基本参数 parser.add_argument('--base-url', required=True, help='API基础URL') parser.add_argument('--verbose', '-v', action='store_true', help='启用详细日志') parser.add_argument('--output', '-o', help='输出目录或主报告文件路径 (例如 ./test_reports/ 或 ./test_reports/summary.json)') parser.add_argument('--format', choices=['json', 'html'], default='json', help='主测试摘要报告的输出格式') parser.add_argument('--api-calls-output', help='API 调用详情的 Markdown 输出文件路径 (例如 ./test_reports/api_calls.md)。如果未提供,将尝试使用 --output 目录和默认文件名 api_call_details.md。始终会额外生成一个同名的 .txt 文件包含纯 cURL 命令。') # API定义参数 api_group = parser.add_argument_group('API定义源') api_group.add_argument('--yapi', help='YAPI定义文件路径') api_group.add_argument('--swagger', help='Swagger定义文件路径') # 过滤参数 filter_group = parser.add_argument_group('过滤选项') filter_group.add_argument('--categories', help='YAPI分类,逗号分隔') filter_group.add_argument('--tags', help='Swagger标签,逗号分隔') filter_group.add_argument('--list-categories', action='store_true', help='列出YAPI分类') filter_group.add_argument('--list-tags', action='store_true', help='列出Swagger标签') # 新增:自定义测试用例参数组 custom_tc_group = parser.add_argument_group('自定义测试用例选项') custom_tc_group.add_argument('--custom-test-cases-dir', default=None, # 或者 './custom_testcases' 如果想设为默认 help='存放自定义APITestCase Python文件的目录路径。如果未提供,则不加载自定义测试。') # 新增:LLM 配置选项 llm_group = parser.add_argument_group('LLM 配置选项 (可选)') llm_group.add_argument('--llm-api-key', default=os.environ.get("OPENAI_API_KEY"), # 尝试从环境变量获取 help='LLM服务的API密钥 (例如 OpenAI API Key)。默认从环境变量 OPENAI_API_KEY 读取。') llm_group.add_argument('--llm-base-url', default="https://dashscope.aliyuncs.com/compatible-mode/v1", help='LLM服务的自定义基础URL (例如 OpenAI API代理)。') llm_group.add_argument('--llm-model-name', default="qwen-plus", # 设置一个常用的默认模型 help='要使用的LLM模型名称 (例如 "gpt-3.5-turbo", "gpt-4")。') llm_group.add_argument('--use-llm-for-request-body', action='store_true', default=False, # 默认不使用LLM生成请求体 help='是否启用LLM为API请求生成请求体数据。') llm_group.add_argument('--use-llm-for-path-params', action='store_true', default=False, help='是否启用LLM为API请求生成路径参数。') llm_group.add_argument('--use-llm-for-query-params', action='store_true', default=False, help='是否启用LLM为API请求生成查询参数。') llm_group.add_argument('--use-llm-for-headers', action='store_true', default=False, help='是否启用LLM为API请求生成头部参数。') return parser.parse_args() def list_yapi_categories(yapi_file: str): """列出YAPI分类""" from ddms_compliance_suite.input_parser.parser import InputParser logger.info(f"从YAPI文件解析分类: {yapi_file}") parser = InputParser() parsed_yapi = parser.parse_yapi_spec(yapi_file) if not parsed_yapi: logger.error(f"解析YAPI文件失败: {yapi_file}") return print("\nYAPI分类:") for i, category in enumerate(parsed_yapi.categories, 1): print(f"{i}. {category.get('name', '未命名')} - {category.get('desc', '无描述')}") def list_swagger_tags(swagger_file: str): """列出Swagger标签""" from ddms_compliance_suite.input_parser.parser import InputParser logger.info(f"从Swagger文件解析标签: {swagger_file}") parser = InputParser() parsed_swagger = parser.parse_swagger_spec(swagger_file) if not parsed_swagger: logger.error(f"解析Swagger文件失败: {swagger_file}") return print("\nSwagger标签:") for i, tag in enumerate(parsed_swagger.tags, 1): print(f"{i}. {tag.get('name', '未命名')} - {tag.get('description', '无描述')}") def save_results(summary: TestSummary, output_file_path: str, format_type: str): """保存主测试摘要结果""" output_path = Path(output_file_path) # Ensure the directory for the output file exists try: output_path.parent.mkdir(parents=True, exist_ok=True) except OSError as e: logger.error(f"Error creating directory for output file {output_path.parent}: {e}") return if format_type == 'json': with open(output_path, 'w', encoding='utf-8') as f: f.write(summary.to_json(pretty=True)) logger.info(f"测试结果已保存为JSON: {output_path}") elif format_type == 'html': # Creating simple HTML report html_content = f""" API测试报告

API测试报告

测试结果摘要

总测试数: {summary.total_test_cases_executed}

通过: {summary.test_cases_passed}

失败: {summary.test_cases_failed}

错误: {summary.test_cases_error}

跳过: {summary.test_cases_skipped_in_endpoint}

成功率: {summary.test_case_success_rate:.2f}%

总耗时: {summary.duration:.2f}秒

开始时间: {summary.start_time.isoformat()}

结束时间: {summary.end_time.isoformat() if summary.end_time else 'N/A'}

详细测试结果

""" for endpoint_result in summary.detailed_results: for tc_result in endpoint_result.executed_test_cases: status_class = "pass" if tc_result.status == ExecutedTestCaseResult.Status.PASSED else \ "fail" if tc_result.status == ExecutedTestCaseResult.Status.FAILED else \ "error" if tc_result.status == ExecutedTestCaseResult.Status.ERROR else "skip" html_content += f""" """ html_content += """
端点 测试用例ID 测试用例名称 状态 消息 耗时(秒)
{endpoint_result.endpoint_name} ({endpoint_result.endpoint_id}) {tc_result.test_case_id} {tc_result.test_case_name} {tc_result.status.value} {tc_result.message} {tc_result.duration:.4f}
""" with open(output_path, 'w', encoding='utf-8') as f: f.write(html_content) logger.info(f"测试结果已保存为HTML: {output_path}") def save_api_call_details_to_file(api_call_details: List[APICallDetail], output_dir_path: str, filename: str = "api_call_details.md"): """ 将API调用详情列表保存到指定目录下的 Markdown 文件中。 同时,额外生成一个纯文本文件 (.txt),每行包含一个 cURL 命令。 """ if not api_call_details: logger.info("没有API调用详情可供保存。") return output_dir = Path(output_dir_path) try: output_dir.mkdir(parents=True, exist_ok=True) except OSError as e: logger.error(f"创建API调用详情输出目录 {output_dir} 失败: {e}") return # 主文件是 Markdown 文件 md_output_file = output_dir / filename # 确保它是 .md,尽管 main 函数应该已经处理了 if md_output_file.suffix.lower() not in ['.md', '.markdown']: md_output_file = md_output_file.with_suffix('.md') markdown_content = [] for detail in api_call_details: # Request URL with params (if any) url_to_display = detail.request_url if detail.request_params: try: # Ensure urllib is available for this formatting step import urllib.parse query_string = urllib.parse.urlencode(detail.request_params) url_to_display = f"{detail.request_url}?{query_string}" except Exception as e: logger.warning(f"Error formatting URL with params for display: {e}") # Fallback to just the base URL if params formatting fails markdown_content.append(f"## `{detail.request_method} {url_to_display}`") markdown_content.append("**cURL Command:**") markdown_content.append("```sh") markdown_content.append(detail.curl_command) markdown_content.append("```") markdown_content.append("### Request Details") markdown_content.append(f"- **Method:** `{detail.request_method}`") markdown_content.append(f"- **Full URL:** `{url_to_display}`") markdown_content.append("- **Headers:**") markdown_content.append("```json") markdown_content.append(json.dumps(detail.request_headers, indent=2, ensure_ascii=False)) markdown_content.append(" ```") if detail.request_params: markdown_content.append("- **Query Parameters:**") markdown_content.append("```json") markdown_content.append(json.dumps(detail.request_params, indent=2, ensure_ascii=False)) markdown_content.append(" ```") if detail.request_body is not None: markdown_content.append("- **Body:**") body_lang = "text" formatted_body = str(detail.request_body) try: # Try to parse as JSON for pretty printing if isinstance(detail.request_body, str): try: parsed_json = json.loads(detail.request_body) formatted_body = json.dumps(parsed_json, indent=2, ensure_ascii=False) body_lang = "json" except json.JSONDecodeError: pass # Keep as text elif isinstance(detail.request_body, (dict, list)): formatted_body = json.dumps(detail.request_body, indent=2, ensure_ascii=False) body_lang = "json" except Exception as e: logger.warning(f"Error formatting request body for Markdown: {e}") markdown_content.append(f"```{body_lang}") markdown_content.append(formatted_body) markdown_content.append(" ```") markdown_content.append("### Response Details") markdown_content.append(f"- **Status Code:** `{detail.response_status_code}`") markdown_content.append(f"- **Elapsed Time:** `{detail.response_elapsed_time:.4f}s`") markdown_content.append("- **Headers:**") markdown_content.append("```json") markdown_content.append(json.dumps(detail.response_headers, indent=2, ensure_ascii=False)) markdown_content.append(" ```") if detail.response_body is not None: markdown_content.append("- **Body:**") resp_body_lang = "text" formatted_resp_body = str(detail.response_body) try: # Try to parse as JSON for pretty printing if isinstance(detail.response_body, str): try: # If it's already a string that might be JSON, try parsing and re-dumping parsed_json_resp = json.loads(detail.response_body) formatted_resp_body = json.dumps(parsed_json_resp, indent=2, ensure_ascii=False) resp_body_lang = "json" except json.JSONDecodeError: # It's a string, but not valid JSON, keep as text pass elif isinstance(detail.response_body, (dict, list)): # It's already a dict/list, dump it as JSON formatted_resp_body = json.dumps(detail.response_body, indent=2, ensure_ascii=False) resp_body_lang = "json" # If it's neither string nor dict/list (e.g. int, bool from parsed json), str() is fine. except Exception as e: logger.warning(f"Error formatting response body for Markdown: {e}") markdown_content.append(f"```{resp_body_lang}") markdown_content.append(formatted_resp_body) markdown_content.append(" ```") markdown_content.append("") # Add a blank line for spacing before next --- or EOF markdown_content.append("---") # Separator try: with open(md_output_file, 'w', encoding='utf-8') as f_md: f_md.write("\n".join(markdown_content)) logger.info(f"API调用详情已保存为 Markdown: {md_output_file}") except Exception as e: logger.error(f"保存API调用详情到 Markdown 文件 {md_output_file} 失败: {e}", exc_info=True) # 额外生成 .txt 文件,只包含 cURL 命令 txt_output_filename = md_output_file.with_suffix('.txt').name txt_output_file_path = output_dir / txt_output_filename try: with open(txt_output_file_path, 'w', encoding='utf-8') as f_txt: for detail in api_call_details: f_txt.write(detail.curl_command + '\n') logger.info(f"可直接执行的cURL命令已保存到纯文本文件: {txt_output_file_path}") except Exception as e: logger.error(f"保存cURL命令到文本文件 {txt_output_file_path} 失败: {e}", exc_info=True) def main(): """主函数""" args = parse_args() if args.verbose: logging.getLogger('ddms_compliance_suite').setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG) logger.debug("已启用详细日志模式") if not args.yapi and not args.swagger: logger.error("请提供API定义源:--yapi 或 --swagger") sys.exit(1) if args.list_categories and args.yapi: list_yapi_categories(args.yapi) sys.exit(0) if args.list_tags and args.swagger: list_swagger_tags(args.swagger) sys.exit(0) categories = args.categories.split(',') if args.categories else None tags = args.tags.split(',') if args.tags else None DEFAULT_OUTPUT_DIR = Path("./test_reports") output_directory: Path main_report_file_path: Path if args.output: output_arg_path = Path(args.output) if output_arg_path.suffix and output_arg_path.name: # Check if it looks like a file output_directory = output_arg_path.parent main_report_file_path = output_arg_path else: output_directory = output_arg_path main_report_file_path = output_directory / f"summary.{args.format}" else: output_directory = DEFAULT_OUTPUT_DIR main_report_file_path = output_directory / f"summary.{args.format}" try: output_directory.mkdir(parents=True, exist_ok=True) logger.info(f"主输出目录设置为: {output_directory.resolve()}") except OSError as e: logger.error(f"创建主输出目录失败 {output_directory}: {e}") sys.exit(1) orchestrator = APITestOrchestrator( base_url=args.base_url, custom_test_cases_dir=args.custom_test_cases_dir, llm_api_key=args.llm_api_key, llm_base_url=args.llm_base_url, llm_model_name=args.llm_model_name, use_llm_for_request_body=args.use_llm_for_request_body, use_llm_for_path_params=args.use_llm_for_path_params, use_llm_for_query_params=args.use_llm_for_query_params, use_llm_for_headers=args.use_llm_for_headers, output_dir=str(output_directory) ) test_summary: Optional[TestSummary] = None try: if args.yapi: logger.info(f"从YAPI文件运行测试: {args.yapi}") test_summary = orchestrator.run_tests_from_yapi( yapi_file_path=args.yapi, categories=categories, custom_test_cases_dir=args.custom_test_cases_dir ) elif args.swagger: logger.info(f"从Swagger文件运行测试: {args.swagger}") test_summary = orchestrator.run_tests_from_swagger( swagger_file_path=args.swagger, tags=tags, custom_test_cases_dir=args.custom_test_cases_dir ) except Exception as e: logger.error(f"执行测试时发生意外错误: {e}", exc_info=True) sys.exit(1) if test_summary: save_results(test_summary, str(main_report_file_path), args.format) api_calls_output_path_str: Optional[str] = None # 默认文件名现在是 .md api_calls_filename: str = "api_call_details.md" if args.api_calls_output: api_calls_output_file = Path(args.api_calls_output) # 确保后缀是 .md,如果用户提供了其他后缀或没有后缀 if api_calls_output_file.suffix.lower() not in ['.md', '.markdown']: api_calls_output_file = api_calls_output_file.with_suffix('.md') logger.info(f"API调用详情输出文件名已调整为 Markdown 格式: {api_calls_output_file.name}") api_calls_output_path_str = str(api_calls_output_file.parent) api_calls_filename = api_calls_output_file.name logger.info(f"API调用详情将以 Markdown 格式保存到: {api_calls_output_file}") elif args.output: output_arg_path = Path(args.output) if output_arg_path.is_dir(): api_calls_output_path_str = str(output_arg_path) else: api_calls_output_path_str = str(output_arg_path.parent) logger.info(f"API调用详情将以 Markdown 格式保存到目录 '{api_calls_output_path_str}' (使用默认文件名 '{api_calls_filename}')") else: api_calls_output_path_str = "." logger.info(f"API调用详情将以 Markdown 格式保存到当前目录 '.' (使用默认文件名 '{api_calls_filename}')") # 保存API调用详情 if orchestrator and api_calls_output_path_str: save_api_call_details_to_file( orchestrator.get_api_call_details(), api_calls_output_path_str, filename=api_calls_filename ) # Improved HTML report summary access failed_count = getattr(test_summary, 'endpoints_failed', 0) + getattr(test_summary, 'test_cases_failed', 0) error_count = getattr(test_summary, 'endpoints_error', 0) + getattr(test_summary, 'test_cases_error', 0) if failed_count > 0 or error_count > 0: logger.info("部分测试失败或出错,请检查报告。") # sys.exit(1) # Keep this commented if a report is always desired regardless of outcome else: logger.info("所有测试完成。") else: logger.error("未能生成测试摘要。") sys.exit(1) sys.exit(0) if __name__ == '__main__': main() # python run_api_tests.py --base-url http://127.0.0.1:4523/m1/6389742-6086420-default --swagger assets/doc/井筒API示例swagger.json --custom-test-cases-dir ./custom_testcases \ # --verbose \ # --output test_report.json # python run_api_tests.py --base-url https://127.0.0.1:4523/m1/6389742-6086420-default --yapi assets/doc/井筒API示例_simple.json --custom-test-cases-dir ./custom_testcases \ # --verbose \ # --output test_report.json