CloudApiRequest 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,514 @@
1
+ """
2
+ cloud接口测试核心类
3
+ """
4
+ import json
5
+ import os
6
+ import time
7
+ from urllib.parse import urlparse
8
+
9
+ import pytest
10
+ import allure
11
+ import jsonpath
12
+ from requests_toolbelt import MultipartEncoder
13
+ from cloud.CloudLogUtil import log
14
+ from cloud.CloudRequestUtil import CloudHttpClient
15
+ from cloud.CloudSignUtil import CloudSignUtil
16
+ from cloud.CloudYamlUtil import CloudReadYaml, CloudPlaceholderYaml
17
+ from cloud.CloudApiConfig import config
18
+
19
+
20
+ class CloudAPIRequest:
21
+ """
22
+ cloud接口测试核心类
23
+ """
24
+
25
+ def __init__(self, commonCase=None):
26
+ """
27
+ :param commonCase: 公共用例文件
28
+ """
29
+ self.commonCase = commonCase
30
+ # 延迟初始化配置,避免循环导入
31
+ self._config = None
32
+ self.baseUrl = None
33
+ self.token = None
34
+ self.globalBean = None
35
+ self.assertFail = 'stop'
36
+ self.tenv = 'base'
37
+
38
+ def _get_config(self):
39
+ """延迟获取配置实例"""
40
+ if self._config is None:
41
+ from cloud import config
42
+ self._config = config
43
+ # 正确获取配置值,而不是property对象
44
+ self.baseUrl = str(config._baseUrl) if hasattr(config, '_baseUrl') and config._baseUrl else None
45
+ self.token = str(config._token) if hasattr(config, '_token') and config._token else None
46
+ self.globalBean = config
47
+ self.assertFail = str(config._assertFail) if hasattr(config, '_assertFail') and config._assertFail else 'stop'
48
+ self.tenv = str(config._tEnv) if hasattr(config, '_tEnv') and config._tEnv else 'base'
49
+ return self._config
50
+
51
+ def doRequest(self, file, bean):
52
+ """
53
+ 执行API请求测试
54
+ """
55
+ requestParameter = None
56
+ dataSaveBean = bean
57
+ yaml = CloudReadYaml(file).load_yaml()
58
+ yamlId = yaml.get('id')
59
+ yamlName = yaml.get('name')
60
+ yamlTestcase = yaml.get('testcases')
61
+
62
+ log.info(f"开始执行测试用例name: {yamlName}, id: {yamlId}")
63
+ config_instance = self._get_config()
64
+ clientSession = config_instance.Session
65
+
66
+ for index, testcase in enumerate(yamlTestcase, 1):
67
+ testcase_name = testcase.get('name', f'用例{index}')
68
+ testcase_id = testcase.get('id', f'case_{index}')
69
+ log.info(f"{'='*60}")
70
+ log.info(f"正在执行第 {index} 个用例: {testcase_name} (ID: {testcase_id})")
71
+ log.info(f"{'='*60}")
72
+
73
+ with allure.step(testcase.get('name')):
74
+ if testcase.get('skip'):
75
+ log.info(f"用例: {testcase.get('name')}跳过")
76
+ continue
77
+
78
+ sleeps = testcase.get('sleep')
79
+ if sleeps:
80
+ time.sleep(int(sleeps))
81
+ log.info(f"当前用例: {testcase.get('name')}执行前等待{sleeps}秒")
82
+
83
+ # 处理公共用例
84
+ config_instance = self._get_config()
85
+ if testcase.get('kind') and testcase.get('kind').lower() == 'common' and hasattr(config_instance, '_commonTestCasePath') and config_instance._commonTestCasePath is not None:
86
+ testcase = self.getCommonTestCase(testcase, config_instance._commonTestCasePath, testcase.get('id'))
87
+ elif testcase.get('kind') and testcase.get('kind').lower() == 'common' and (not hasattr(config_instance, '_commonTestCasePath') or config_instance._commonTestCasePath is None):
88
+ log.error(f"commonPath路径未配置,请检查配置文件")
89
+ raise Exception(f"commonPath路径未配置,请检查配置文件")
90
+
91
+ # 参数替换
92
+ if testcase.get('requestType') is None:
93
+ requestType = 'json'
94
+ else:
95
+ requestType = testcase.get('requestType')
96
+ repParameter = self.replaceParameterAttr(dataSaveBean, testcase.get('parameter'), requestType)
97
+ repApi = self.replaceParameterAttr(dataSaveBean, testcase.get('api'))
98
+ headers = self.replaceParameterAttr(dataSaveBean, testcase.get('headers'))
99
+
100
+ # 鉴权处理
101
+ requestParameter, requestUrl, authHeaders = self.authType(testcase.get('authType'), repApi, testcase.get('method'), repParameter)
102
+
103
+ # 请求类型处理
104
+ dataRequestParameter, jsonRequestParameter, paramsData, ModelData, requestType = self.requestType(requestType, requestParameter)
105
+
106
+ # 合并请求头
107
+ if headers:
108
+ headers.update(authHeaders)
109
+ else:
110
+ headers = authHeaders
111
+
112
+ # 执行请求
113
+
114
+ if dataRequestParameter is not None and requestType.lower() in ['form-data', 'form-file']:
115
+ headers['Content-Type'] = dataRequestParameter.content_type
116
+
117
+ # 调试日志:打印实际发送的JSON数据
118
+ if jsonRequestParameter is not None:
119
+ log.info(f"发送的JSON数据: {json.dumps(jsonRequestParameter, ensure_ascii=False)}")
120
+ log.info(f"JSON数据类型: {type(jsonRequestParameter)}")
121
+ if isinstance(jsonRequestParameter, dict):
122
+ for key, value in jsonRequestParameter.items():
123
+ log.info(f" {key}: {value} (类型: {type(value)})")
124
+
125
+ if testcase.get('stream_check'):
126
+ response = self.handle_stream_response(clientSession, testcase.get('method'), requestUrl, dataRequestParameter, jsonRequestParameter, paramsData, ModelData, headers)
127
+ else:
128
+ response = clientSession.request(method=testcase.get('method'), url=requestUrl, data=dataRequestParameter, json=jsonRequestParameter, params=paramsData, files=ModelData, headers=headers)
129
+
130
+ # 记录响应
131
+ try:
132
+ response_json = response.json()
133
+ # 打印响应状态码
134
+ log.info(f"[{testcase_name}] 响应状态码: {response.status_code}")
135
+ # 打印完整的响应结果
136
+ log.info(f"[{testcase_name}] 响应结果: {json.dumps(response_json, ensure_ascii=False, indent=2)}")
137
+ except json.JSONDecodeError as e:
138
+ log.error(f"[{testcase_name}] JSON解析失败: {e}")
139
+ log.error(f"[{testcase_name}] 原始响应内容: {response.text}")
140
+ response_json = None
141
+ except Exception as e:
142
+ log.error(f"[{testcase_name}] 响应处理异常: {e}")
143
+ response_json = None
144
+
145
+ # 处理断言
146
+ if testcase.get('assertFail'):
147
+ failtype = testcase.get('assertFail')
148
+ else:
149
+ failtype = self.assertFail
150
+ self.assertType(testcase.get('assert'), response, dataSaveBean, failtype)
151
+
152
+ # 保存数据
153
+ try:
154
+ if response_json is not None:
155
+ self.addAttrSaveBean(dataSaveBean, self.globalBean, testcase.get('saveData'), response_json)
156
+ else:
157
+ log.warning(f"[{testcase_name}] 响应不是JSON格式,跳过数据保存")
158
+ except Exception as e:
159
+ log.error(f"[{testcase_name}] 数据保存异常: {e}")
160
+ self.addAttrSaveBean(dataSaveBean, self.globalBean, testcase.get('saveData'), response.text)
161
+
162
+ return clientSession
163
+
164
+ def assertType(self, assertType, response, bean, failType):
165
+ """
166
+ 处理断言
167
+ """
168
+ if assertType is None:
169
+ log.info(f"断言为空,跳过断言")
170
+ return None
171
+
172
+ for ass in assertType:
173
+ key = list(ass.keys())[0]
174
+
175
+ if 'status_code' in ass:
176
+ if ass.get('status_code'):
177
+ self.assertChoose(str(response.status_code) == str(ass.get('status_code')),
178
+ f"status_code断言失败: {ass.get('status_code')} ,response结果: {response.status_code}", failType)
179
+ continue
180
+
181
+ # 安全地解析JSON响应
182
+ try:
183
+ response_data = response.json()
184
+ jsonpathResults = jsonpath.jsonpath(response_data, ass.get(key)[0])
185
+ except json.JSONDecodeError:
186
+ # 尝试解析JSONP格式
187
+ try:
188
+ response_text = response.text
189
+ # 检查是否是JSONP格式:callback(json_data)
190
+ if '(' in response_text and ')' in response_text:
191
+ # 提取JSON部分
192
+ start = response_text.find('(') + 1
193
+ end = response_text.rfind(')')
194
+ json_str = response_text[start:end]
195
+
196
+ # 处理JSONP中的字符串转义(单引号包围的JSON)
197
+ if json_str.startswith("'") and json_str.endswith("'"):
198
+ json_str = json_str[1:-1] # 移除首尾的单引号
199
+
200
+ response_data = json.loads(json_str)
201
+ log.info(f"成功解析JSONP格式响应: {json_str}")
202
+ jsonpathResults = jsonpath.jsonpath(response_data, ass.get(key)[0])
203
+ else:
204
+ raise json.JSONDecodeError("不是JSONP格式", response_text, 0)
205
+ except (json.JSONDecodeError, ValueError) as e:
206
+ log.error(f"JSON和JSONP解析都失败,无法执行断言: {ass.get(key)[0]}")
207
+ log.error(f"原始响应内容: {response.text}")
208
+ self.assertChoose(False, f"响应不是有效的JSON或JSONP格式,无法执行断言: {ass.get(key)[0]}", failType)
209
+ continue
210
+ except Exception as e:
211
+ log.error(f"断言处理异常: {e}")
212
+ self.assertChoose(False, f"断言处理异常: {e}", failType)
213
+ continue
214
+
215
+ if jsonpathResults is False and 'not_found' not in ass:
216
+ self.assertChoose(1 > 2, f"提取{ass.get(key)[0]}失败,断言失败", failType)
217
+ continue
218
+
219
+ if 'eq' in ass:
220
+ expectedResults = CloudPlaceholderYaml(attrObj=bean, reString=ass.get('eq')[1]).replace().replaced_str
221
+ assResults = str(expectedResults) in [str(item) for item in jsonpathResults]
222
+ self.assertChoose(assResults is True, f"eq断言失败: {jsonpathResults} 不等于 {expectedResults}", failType)
223
+ elif 'neq' in ass:
224
+ expectedResults = CloudPlaceholderYaml(attrObj=bean, reString=ass.get('neq')[1]).replace().replaced_str
225
+ assResults = str(expectedResults) not in [str(item) for item in jsonpathResults]
226
+ self.assertChoose(assResults is True, f"neq断言失败: {jsonpathResults} 等于 {expectedResults}", failType)
227
+ elif 'sge' in ass:
228
+ expectedResults = CloudPlaceholderYaml(attrObj=bean, reString=ass.get('sge')[1]).replace().replaced_str
229
+ self.assertChoose(len(jsonpathResults) >= int(expectedResults), f"sge断言失败: {jsonpathResults} 小于 {expectedResults}", failType)
230
+ elif 'nn' in ass:
231
+ self.assertChoose(jsonpathResults is not None, f"not none断言失败: {ass.get('nn')[0]}", failType)
232
+ elif 'none' in ass:
233
+ self.assertChoose(jsonpathResults is True, f"none断言失败: {ass.get('none')[0]}", failType)
234
+ elif 'not_found' in ass:
235
+ self.assertChoose(jsonpathResults is False, f"not_found断言失败,字段存在", failType)
236
+ elif 'in' in ass:
237
+ expectedResults = CloudPlaceholderYaml(attrObj=bean, reString=ass.get('in')[1]).replace().replaced_str
238
+ self.assertChoose(str(expectedResults) in str(jsonpathResults), f"断言in失败: {expectedResults} 不在 {jsonpathResults} 内", failType)
239
+ elif 'len' in ass:
240
+ expectedResults = CloudPlaceholderYaml(attrObj=bean, reString=ass.get('len')[1]).replace().replaced_str
241
+ jsonpathResults_len = len(jsonpathResults[0])
242
+ self.assertChoose(jsonpathResults_len == int(expectedResults), f"断言len失败: {jsonpathResults_len} 长度不等于 {expectedResults}", failType)
243
+ elif 'contains' in ass:
244
+ expectedResults = CloudPlaceholderYaml(attrObj=bean, reString=ass.get('contains')[1]).replace().replaced_str
245
+ self.assertChoose(str(expectedResults) in str(jsonpathResults), f"断言contains失败: {expectedResults} 不在 {jsonpathResults} 内", failType)
246
+
247
+ def addAttrSaveBean(self, bean, globalBean, data: list, response):
248
+ """
249
+ 保存响应数据到Bean
250
+ """
251
+ if data is None:
252
+ return
253
+ for d in data:
254
+ if 'json' in d:
255
+ jsonPath = d.get('json')[1]
256
+ value = jsonpath.jsonpath(response, jsonPath)
257
+ if value is False:
258
+ value = None
259
+
260
+ saveBean = bean
261
+ if d.get('json').__len__() == 3 and d.get('json')[2].lower() == 'global':
262
+ saveBean = globalBean
263
+
264
+ key_parts = d.get('json')[0].split(':')
265
+ d.get('json')[0] = key_parts[0]
266
+
267
+ if value is not None and len(value) > 1:
268
+ setattr(saveBean, d.get('json')[0], list(value))
269
+ elif value is not None and len(value) == 1:
270
+ if len(key_parts) > 1 and key_parts[1].lower() == 'str':
271
+ value[0] = str(value[0])
272
+ setattr(saveBean, d.get('json')[0], value[0])
273
+
274
+ def replaceParameterAttr(self, bean, parameter, requestType='json'):
275
+ """
276
+ 替换参数中的占位符
277
+ """
278
+ if parameter is None:
279
+ return None
280
+
281
+ # 获取配置实例
282
+ config_instance = self._get_config()
283
+
284
+ if requestType.lower() == 'json-text':
285
+ repParameter = CloudPlaceholderYaml(yaml_str=parameter, attrObj=bean, methObj=config_instance.methObj, gloObj=config_instance).replace().textLoad()
286
+ else:
287
+ repParameter = CloudPlaceholderYaml(yaml_str=parameter, attrObj=bean, methObj=config_instance.methObj, gloObj=config_instance).replace().jsonLoad()
288
+
289
+ return repParameter
290
+
291
+ def requestType(self, requestType, data):
292
+ """
293
+ 处理请求类型
294
+ """
295
+ jsonRequestParameter = None
296
+ dataRequestParameter = None
297
+ paramsData = None
298
+ ModelData = None
299
+
300
+ if isinstance(data, dict) and data.get('MIME'):
301
+ MIME = data.get('MIME')
302
+ else:
303
+ MIME = 'application/octet-stream'
304
+
305
+ if requestType is None:
306
+ jsonRequestParameter = data
307
+ elif requestType.lower() in ["json", "json-text"]:
308
+ jsonRequestParameter = data
309
+ elif requestType.lower() == "form-data":
310
+ dataRequestParameter = MultipartEncoder(fields=data)
311
+ elif requestType.lower() == "form-model":
312
+ filename = data['filename']
313
+ file_name = data[filename].split('\\')
314
+ data[filename] = (file_name[-1], open(data[filename], 'rb'), MIME)
315
+ for k, v in data.items():
316
+ if type(v) == dict:
317
+ data[k] = (None, json.dumps(data[k]))
318
+ ModelData = data
319
+ elif requestType.lower() == "form-file":
320
+ filename = data['filename']
321
+ data[filename] = (os.path.basename(data[filename]), open(data[filename], 'rb'), MIME)
322
+ dataRequestParameter = MultipartEncoder(fields=data)
323
+ elif requestType == "PARAMS":
324
+ paramsData = data
325
+ elif requestType == "DATA":
326
+ dataRequestParameter = data
327
+ else:
328
+ log.error("请求方式不支持")
329
+
330
+ return dataRequestParameter, jsonRequestParameter, paramsData, ModelData, requestType
331
+
332
+ def authType(self, authType, url, method, parameter):
333
+ """
334
+ 处理鉴权方式
335
+ """
336
+ if self.baseUrl is None or self.isValidUrl(url):
337
+ requestUrl = url
338
+ else:
339
+ requestUrl = self.baseUrl + url
340
+ requestParameter = None
341
+ authHeaders = {}
342
+
343
+ if authType == "SIGN":
344
+ # 云服务MD5签名鉴权
345
+ config_instance = self._get_config()
346
+ # 从config中获取企业ID
347
+ enterprise_id = config_instance._enterpriseId if hasattr(config_instance, '_enterpriseId') else None
348
+ if not enterprise_id:
349
+ log.error("企业ID未配置,请在环境配置中设置enterpriseId")
350
+ raise ValueError("企业ID未配置,请在环境配置中设置enterpriseId")
351
+
352
+ if method.upper() == "GET":
353
+ # GET请求:将签名参数添加到URL参数中
354
+ # 添加默认的validateType参数
355
+ if parameter is None:
356
+ parameter = {}
357
+ if 'validateType' not in parameter:
358
+ parameter['validateType'] = config_instance._validateType if hasattr(config_instance, '_validateType') else '2' # 从配置中获取验证类型
359
+
360
+ signed_params = CloudSignUtil.generate_params_with_signature(
361
+ enterprise_id=enterprise_id,
362
+ token=config_instance._token if hasattr(config_instance, '_token') else None,
363
+ additional_params=parameter
364
+ )
365
+ # 构建带签名的URL
366
+ from urllib.parse import urlencode
367
+ query_string = urlencode(signed_params)
368
+ if '?' in requestUrl:
369
+ requestUrl += '&' + query_string
370
+ else:
371
+ requestUrl += '?' + query_string
372
+ requestParameter = None
373
+ log.info(f"GET请求URL: {requestUrl}")
374
+ else:
375
+ # POST/PUT/PATCH请求:签名信息放在请求头中,但URL中也需要包含签名参数
376
+ # 添加签名参数到URL查询参数中
377
+ url_params = {
378
+ 'validateType': config_instance._validateType if hasattr(config_instance, '_validateType') else '2',
379
+ 'enterpriseId': enterprise_id,
380
+ 'timestamp': int(time.time()),
381
+ 'sign': CloudSignUtil.generate_md5_signature(
382
+ enterprise_id,
383
+ int(time.time()),
384
+ config_instance._token if hasattr(config_instance, '_token') else None
385
+ )
386
+ }
387
+
388
+ # 如果parameter中包含validateType,使用parameter中的值
389
+ if parameter and 'validateType' in parameter:
390
+ url_params['validateType'] = parameter.pop('validateType')
391
+
392
+ # 构建URL(包含所有签名参数)
393
+ from urllib.parse import urlencode
394
+ query_string = urlencode(url_params)
395
+ if '?' in requestUrl:
396
+ requestUrl += '&' + query_string
397
+ else:
398
+ requestUrl += '?' + query_string
399
+ log.info(f"POST请求URL: {requestUrl}")
400
+
401
+ # 生成请求头签名(包含请求体内容)
402
+ authHeaders = CloudSignUtil.generate_headers(
403
+ method=method,
404
+ url=requestUrl,
405
+ params=None, # POST请求不需要URL参数签名
406
+ enterprise_id=enterprise_id,
407
+ token=config_instance._token if hasattr(config_instance, '_token') else None,
408
+ body=parameter
409
+ )
410
+ requestParameter = parameter
411
+
412
+ return requestParameter, requestUrl, authHeaders
413
+ elif authType == "COOKIE" or authType is None:
414
+ requestParameter = parameter
415
+ return requestParameter, requestUrl, authHeaders
416
+ else:
417
+ log.error("鉴权方式不支持")
418
+ return requestParameter, requestUrl, authHeaders
419
+
420
+ def getCommonTestCase(self, testcase, commonFile, caseId):
421
+ """
422
+ 获取公共测试用例
423
+ """
424
+ if self.commonCase is None:
425
+ commonFile = commonFile
426
+ else:
427
+ commonFile = os.path.join(commonFile.split('common')[0], f'common/{self.commonCase}')
428
+
429
+ yaml = CloudReadYaml(commonFile).load_yaml()
430
+ commonCase = yaml.get('testcases')
431
+
432
+ for case in commonCase:
433
+ if case.get('id') == caseId:
434
+ case['assert'] = [item for item in (case.get('assert') or []) + (testcase.get('assert') or []) if item is not None]
435
+ case['saveData'] = [item for item in (case.get('saveData') or []) + (testcase.get('saveData') or []) if item is not None]
436
+ return case
437
+
438
+ raise ValueError("Case with id {} not found".format(caseId))
439
+
440
+ def assertChoose(self, ass, tips, type):
441
+ """
442
+ 断言选择器
443
+ """
444
+ if type == 'stop':
445
+ assert ass, tips
446
+ elif type == 'continue':
447
+ pytest.assume(ass, tips)
448
+
449
+ def isValidUrl(self, url):
450
+ """
451
+ 验证URL是否有效
452
+ """
453
+ try:
454
+ result = urlparse(url)
455
+ return all([result.scheme, result.netloc])
456
+ except ValueError:
457
+ return False
458
+
459
+ def handle_stream_response(self, clientSession, method, url, data, json_data, params, files, headers):
460
+ """
461
+ 处理流式响应
462
+ """
463
+ try:
464
+ response_dict = {}
465
+ answer_count = 0
466
+ complete_message = ""
467
+
468
+ with clientSession.request(
469
+ method=method,
470
+ url=url,
471
+ data=data,
472
+ json=json_data,
473
+ params=params,
474
+ files=files,
475
+ headers=headers,
476
+ stream=True
477
+ ) as response:
478
+ for chunk in response.iter_lines():
479
+ if chunk:
480
+ data = chunk.decode('utf-8')
481
+ if data.startswith('data: '):
482
+ try:
483
+ json_str = data[6:]
484
+ if json_str.strip() == '[DONE]':
485
+ if complete_message:
486
+ response_dict["complete_message"] = complete_message
487
+ continue
488
+
489
+ json_data = json.loads(json_str)
490
+
491
+ if 'answer' in json_data:
492
+ answer_count += 1
493
+ key = f"answer{answer_count}"
494
+ response_dict[key] = json_data
495
+
496
+ answer_content = json_data.get('answer', '')
497
+ if isinstance(answer_content, list):
498
+ answer_content = ''.join(str(item) for item in answer_content)
499
+ elif not isinstance(answer_content, str):
500
+ answer_content = str(answer_content)
501
+
502
+ complete_message += answer_content
503
+
504
+ except json.JSONDecodeError as e:
505
+ log.error(f"JSON解析错误: {e}")
506
+ continue
507
+
508
+ response.json = lambda: response_dict
509
+ response._content = json.dumps(response_dict).encode()
510
+ return response
511
+
512
+ except Exception as e:
513
+ log.error(f"流式处理错误: {e}")
514
+ raise