pytest-openapi 0.1.1.dev202601050151__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.
- pytest_openapi/__init__.py +1 -0
- pytest_openapi/case_generator.py +433 -0
- pytest_openapi/contract.py +834 -0
- pytest_openapi/openapi.py +259 -0
- pytest_openapi/plugin.py +92 -0
- pytest_openapi-0.1.1.dev202601050151.dist-info/METADATA +182 -0
- pytest_openapi-0.1.1.dev202601050151.dist-info/RECORD +11 -0
- pytest_openapi-0.1.1.dev202601050151.dist-info/WHEEL +5 -0
- pytest_openapi-0.1.1.dev202601050151.dist-info/entry_points.txt +2 -0
- pytest_openapi-0.1.1.dev202601050151.dist-info/licenses/LICENSE +21 -0
- pytest_openapi-0.1.1.dev202601050151.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
"""OpenAPI contract testing - execute tests against live endpoints."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .case_generator import generate_test_cases_for_schema
|
|
8
|
+
|
|
9
|
+
# Global list to store test reports
|
|
10
|
+
test_reports = []
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_request(method, url, json=None, timeout=10):
|
|
14
|
+
"""Wrapper for HTTP requests that logs all requests and responses
|
|
15
|
+
for reporting.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
|
19
|
+
url: Full URL to request
|
|
20
|
+
json: Optional JSON body for request
|
|
21
|
+
timeout: Request timeout in seconds
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
requests.Response object
|
|
25
|
+
"""
|
|
26
|
+
# Make the actual request
|
|
27
|
+
if method.upper() == "GET":
|
|
28
|
+
response = requests.get(url, timeout=timeout)
|
|
29
|
+
elif method.upper() == "POST":
|
|
30
|
+
response = requests.post(url, json=json, timeout=timeout)
|
|
31
|
+
elif method.upper() == "PUT":
|
|
32
|
+
response = requests.put(url, json=json, timeout=timeout)
|
|
33
|
+
elif method.upper() == "DELETE":
|
|
34
|
+
response = requests.delete(url, timeout=timeout)
|
|
35
|
+
else:
|
|
36
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
37
|
+
|
|
38
|
+
return response
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def log_test_result(
|
|
42
|
+
method,
|
|
43
|
+
path,
|
|
44
|
+
request_body,
|
|
45
|
+
expected_status,
|
|
46
|
+
expected_body,
|
|
47
|
+
actual_status,
|
|
48
|
+
actual_body,
|
|
49
|
+
success,
|
|
50
|
+
error_message=None,
|
|
51
|
+
):
|
|
52
|
+
"""Log a test result for the final report.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
|
56
|
+
path: URL path
|
|
57
|
+
request_body: Request body (if any)
|
|
58
|
+
expected_status: Expected HTTP status code
|
|
59
|
+
expected_body: Expected response body
|
|
60
|
+
actual_status: Actual HTTP status code
|
|
61
|
+
actual_body: Actual response body
|
|
62
|
+
success: Whether the test passed
|
|
63
|
+
error_message: Error message if test failed
|
|
64
|
+
"""
|
|
65
|
+
report = {
|
|
66
|
+
"method": method,
|
|
67
|
+
"path": path,
|
|
68
|
+
"request_body": request_body,
|
|
69
|
+
"expected_status": expected_status,
|
|
70
|
+
"expected_body": expected_body,
|
|
71
|
+
"actual_status": actual_status,
|
|
72
|
+
"actual_body": actual_body,
|
|
73
|
+
"success": success,
|
|
74
|
+
"error_message": error_message,
|
|
75
|
+
}
|
|
76
|
+
test_reports.append(report)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_test_report():
|
|
80
|
+
"""Generate a human-readable test report.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
str: Formatted report of all tests
|
|
84
|
+
"""
|
|
85
|
+
if not test_reports:
|
|
86
|
+
return "No tests have been run yet."
|
|
87
|
+
|
|
88
|
+
report_lines = []
|
|
89
|
+
report_lines.append("=" * 80)
|
|
90
|
+
report_lines.append("OpenAPI Contract Test Report")
|
|
91
|
+
report_lines.append("=" * 80)
|
|
92
|
+
report_lines.append("")
|
|
93
|
+
|
|
94
|
+
for i, test in enumerate(test_reports, 1):
|
|
95
|
+
status_symbol = "✅" if test["success"] else "❌"
|
|
96
|
+
report_lines.append(f"Test #{i} {status_symbol}")
|
|
97
|
+
report_lines.append(f"{test['method']} {test['path']}")
|
|
98
|
+
|
|
99
|
+
if test["request_body"] is not None:
|
|
100
|
+
formatted_request = json.dumps(test["request_body"], indent=2)
|
|
101
|
+
report_lines.append("Requested:")
|
|
102
|
+
for line in formatted_request.split("\n"):
|
|
103
|
+
report_lines.append(f" {line}")
|
|
104
|
+
|
|
105
|
+
report_lines.append("")
|
|
106
|
+
|
|
107
|
+
# Format expected body
|
|
108
|
+
if test["expected_body"] == "" or test["expected_body"] is None:
|
|
109
|
+
expected_body_str = "(empty)"
|
|
110
|
+
else:
|
|
111
|
+
try:
|
|
112
|
+
expected_body_str = json.dumps(test["expected_body"], indent=2)
|
|
113
|
+
expected_body_str = "\n ".join(expected_body_str.split("\n"))
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
expected_body_str = str(test["expected_body"])
|
|
116
|
+
|
|
117
|
+
# Format actual body
|
|
118
|
+
if test["actual_body"] == "" or test["actual_body"] is None:
|
|
119
|
+
actual_body_str = "(empty)"
|
|
120
|
+
else:
|
|
121
|
+
try:
|
|
122
|
+
actual_body_str = json.dumps(test["actual_body"], indent=2)
|
|
123
|
+
actual_body_str = "\n ".join(actual_body_str.split("\n"))
|
|
124
|
+
except (TypeError, ValueError):
|
|
125
|
+
actual_body_str = str(test["actual_body"])
|
|
126
|
+
|
|
127
|
+
report_lines.append(f"Expected {test['expected_status']}")
|
|
128
|
+
report_lines.append(f" {expected_body_str}")
|
|
129
|
+
report_lines.append("")
|
|
130
|
+
report_lines.append(f"Actual {test['actual_status']}")
|
|
131
|
+
report_lines.append(f" {actual_body_str}")
|
|
132
|
+
|
|
133
|
+
if not test["success"] and test["error_message"]:
|
|
134
|
+
report_lines.append("")
|
|
135
|
+
report_lines.append(f"Error: {test['error_message']}")
|
|
136
|
+
|
|
137
|
+
report_lines.append("")
|
|
138
|
+
report_lines.append("-" * 80)
|
|
139
|
+
report_lines.append("")
|
|
140
|
+
|
|
141
|
+
return "\n".join(report_lines)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def compare_responses(expected, actual):
|
|
145
|
+
"""Compare expected and actual responses with detailed error
|
|
146
|
+
messages.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
expected: Expected response (from OpenAPI example)
|
|
150
|
+
actual: Actual response from API
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
tuple: (matches: bool, error_message: str or None)
|
|
154
|
+
"""
|
|
155
|
+
if expected == actual:
|
|
156
|
+
return True, None
|
|
157
|
+
|
|
158
|
+
# Check for missing keys in actual
|
|
159
|
+
if isinstance(expected, dict) and isinstance(actual, dict):
|
|
160
|
+
for key in expected:
|
|
161
|
+
if key not in actual:
|
|
162
|
+
return (
|
|
163
|
+
False,
|
|
164
|
+
f"Missing key in actual response: '{key}'. Expected: {expected}, Actual: {actual}",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Check for extra keys in actual
|
|
168
|
+
for key in actual:
|
|
169
|
+
if key not in expected:
|
|
170
|
+
return (
|
|
171
|
+
False,
|
|
172
|
+
f"Extra key in actual response: '{key}'. Expected: {expected}, Actual: {actual}",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Check for type mismatches
|
|
176
|
+
for key in expected:
|
|
177
|
+
if key in actual:
|
|
178
|
+
expected_type = type(expected[key]).__name__
|
|
179
|
+
actual_type = type(actual[key]).__name__
|
|
180
|
+
if expected_type != actual_type:
|
|
181
|
+
return (
|
|
182
|
+
False,
|
|
183
|
+
f"Type mismatch for key '{key}': expected {expected_type}, got {actual_type}. "
|
|
184
|
+
f"Expected value: {expected[key]}, Actual value: {actual[key]}",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Recursively check nested dicts/lists
|
|
188
|
+
if isinstance(expected[key], (dict, list)):
|
|
189
|
+
matches, error = compare_responses(
|
|
190
|
+
expected[key], actual[key]
|
|
191
|
+
)
|
|
192
|
+
if not matches:
|
|
193
|
+
return False, error
|
|
194
|
+
|
|
195
|
+
# If we've checked keys and types and any nested structures
|
|
196
|
+
# recursively without finding a mismatch, consider this a match
|
|
197
|
+
return True, None
|
|
198
|
+
|
|
199
|
+
# For lists, check element-wise types/structure
|
|
200
|
+
if isinstance(expected, list) and isinstance(actual, list):
|
|
201
|
+
if len(expected) != len(actual):
|
|
202
|
+
return (
|
|
203
|
+
False,
|
|
204
|
+
f"List length mismatch: expected {len(expected)}, got {len(actual)}. Expected: {expected}, Actual: {actual}",
|
|
205
|
+
)
|
|
206
|
+
for e_item, a_item in zip(expected, actual):
|
|
207
|
+
matches, error = compare_responses(e_item, a_item)
|
|
208
|
+
if not matches:
|
|
209
|
+
return False, error
|
|
210
|
+
return True, None
|
|
211
|
+
|
|
212
|
+
return False, f"Response mismatch.\nExpected: {expected}\nActual: {actual}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_get_endpoint(base_url, path, operation):
|
|
216
|
+
"""Test a GET endpoint using the example from the OpenAPI spec.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
base_url: Base URL of the API server
|
|
220
|
+
path: API endpoint path
|
|
221
|
+
operation: OpenAPI operation object
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
tuple: (success: bool, error_message: str or None)
|
|
225
|
+
"""
|
|
226
|
+
url = f"{base_url}{path}"
|
|
227
|
+
|
|
228
|
+
# Get the expected response from examples or generate from schema
|
|
229
|
+
responses = operation.get("responses", {})
|
|
230
|
+
response_200 = responses.get("200", {})
|
|
231
|
+
content = response_200.get("content", {})
|
|
232
|
+
|
|
233
|
+
expected_response = None
|
|
234
|
+
for media_type, media_obj in content.items():
|
|
235
|
+
if "example" in media_obj:
|
|
236
|
+
expected_response = media_obj["example"]
|
|
237
|
+
break
|
|
238
|
+
elif "examples" in media_obj:
|
|
239
|
+
examples = media_obj["examples"]
|
|
240
|
+
if examples:
|
|
241
|
+
first_example = next(iter(examples.values()))
|
|
242
|
+
expected_response = first_example.get("value")
|
|
243
|
+
break
|
|
244
|
+
elif "schema" in media_obj:
|
|
245
|
+
# Generate test cases from schema
|
|
246
|
+
schema = media_obj["schema"]
|
|
247
|
+
response_test_cases, warnings = generate_test_cases_for_schema(
|
|
248
|
+
schema
|
|
249
|
+
)
|
|
250
|
+
if warnings:
|
|
251
|
+
print(f"\nWarning for GET {path} response schema:")
|
|
252
|
+
for warning in warnings:
|
|
253
|
+
print(f" - {warning}")
|
|
254
|
+
if response_test_cases:
|
|
255
|
+
expected_response = response_test_cases[0]
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
if expected_response is None:
|
|
259
|
+
return False, "No example or schema found for 200 response"
|
|
260
|
+
|
|
261
|
+
# Make the GET request
|
|
262
|
+
try:
|
|
263
|
+
response = make_request("GET", url)
|
|
264
|
+
except requests.exceptions.RequestException as e:
|
|
265
|
+
error_msg = f"Request failed: {e}"
|
|
266
|
+
log_test_result(
|
|
267
|
+
"GET",
|
|
268
|
+
path,
|
|
269
|
+
None,
|
|
270
|
+
200,
|
|
271
|
+
expected_response,
|
|
272
|
+
None,
|
|
273
|
+
None,
|
|
274
|
+
False,
|
|
275
|
+
error_msg,
|
|
276
|
+
)
|
|
277
|
+
return False, error_msg
|
|
278
|
+
|
|
279
|
+
# Check status code
|
|
280
|
+
actual_response = (
|
|
281
|
+
response.json() if response.status_code == 200 else response.text
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if response.status_code != 200:
|
|
285
|
+
error_msg = f"Expected status 200, got {response.status_code}. Response: {response.text}"
|
|
286
|
+
log_test_result(
|
|
287
|
+
"GET",
|
|
288
|
+
path,
|
|
289
|
+
None,
|
|
290
|
+
200,
|
|
291
|
+
expected_response,
|
|
292
|
+
response.status_code,
|
|
293
|
+
actual_response,
|
|
294
|
+
False,
|
|
295
|
+
error_msg,
|
|
296
|
+
)
|
|
297
|
+
return False, error_msg
|
|
298
|
+
|
|
299
|
+
# Check response matches example
|
|
300
|
+
matches, error = compare_responses(expected_response, actual_response)
|
|
301
|
+
if not matches:
|
|
302
|
+
log_test_result(
|
|
303
|
+
"GET",
|
|
304
|
+
path,
|
|
305
|
+
None,
|
|
306
|
+
200,
|
|
307
|
+
expected_response,
|
|
308
|
+
response.status_code,
|
|
309
|
+
actual_response,
|
|
310
|
+
False,
|
|
311
|
+
error,
|
|
312
|
+
)
|
|
313
|
+
return False, error
|
|
314
|
+
|
|
315
|
+
log_test_result(
|
|
316
|
+
"GET",
|
|
317
|
+
path,
|
|
318
|
+
None,
|
|
319
|
+
200,
|
|
320
|
+
expected_response,
|
|
321
|
+
response.status_code,
|
|
322
|
+
actual_response,
|
|
323
|
+
True,
|
|
324
|
+
)
|
|
325
|
+
return True, None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_post_endpoint(base_url, path, operation):
|
|
329
|
+
"""Test a POST endpoint using examples from OpenAPI spec AND
|
|
330
|
+
generated test cases from schema.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
base_url: Base URL of the API server
|
|
334
|
+
path: API endpoint path
|
|
335
|
+
operation: OpenAPI operation object
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
tuple: (success: bool, error_message: str or None)
|
|
339
|
+
"""
|
|
340
|
+
url = f"{base_url}{path}"
|
|
341
|
+
|
|
342
|
+
# Collect ALL test cases: both from examples AND generated from schema
|
|
343
|
+
request_test_cases = []
|
|
344
|
+
warnings = []
|
|
345
|
+
|
|
346
|
+
request_body = operation.get("requestBody", {})
|
|
347
|
+
request_content = request_body.get("content", {})
|
|
348
|
+
|
|
349
|
+
for media_type, media_obj in request_content.items():
|
|
350
|
+
# Collect explicit examples
|
|
351
|
+
if "example" in media_obj:
|
|
352
|
+
request_test_cases.append(media_obj["example"])
|
|
353
|
+
if "examples" in media_obj:
|
|
354
|
+
examples_dict = media_obj["examples"]
|
|
355
|
+
for ex_name, ex_obj in examples_dict.items():
|
|
356
|
+
if "value" in ex_obj:
|
|
357
|
+
request_test_cases.append(ex_obj["value"])
|
|
358
|
+
|
|
359
|
+
# Also generate test cases from schema
|
|
360
|
+
if "schema" in media_obj:
|
|
361
|
+
schema = media_obj["schema"]
|
|
362
|
+
generated, warning = generate_test_cases_for_schema(
|
|
363
|
+
schema, "request_body"
|
|
364
|
+
)
|
|
365
|
+
if warning:
|
|
366
|
+
if isinstance(warning, list):
|
|
367
|
+
warnings.extend(warning)
|
|
368
|
+
else:
|
|
369
|
+
warnings.append(warning)
|
|
370
|
+
request_test_cases.extend(generated)
|
|
371
|
+
break
|
|
372
|
+
|
|
373
|
+
if not request_test_cases:
|
|
374
|
+
return False, "No request body examples or schema found"
|
|
375
|
+
|
|
376
|
+
# Print warnings if any
|
|
377
|
+
for warning in warnings:
|
|
378
|
+
print(f"\n{warning}")
|
|
379
|
+
|
|
380
|
+
# Get expected response - collect examples AND generated test cases
|
|
381
|
+
responses = operation.get("responses", {})
|
|
382
|
+
response_200 = responses.get("200", {}) or responses.get("201", {})
|
|
383
|
+
content = response_200.get("content", {})
|
|
384
|
+
|
|
385
|
+
expected_response = None
|
|
386
|
+
expected_status = 201 if "201" in responses else 200
|
|
387
|
+
|
|
388
|
+
for media_type, media_obj in content.items():
|
|
389
|
+
# Prefer explicit example
|
|
390
|
+
if "example" in media_obj:
|
|
391
|
+
expected_response = media_obj["example"]
|
|
392
|
+
break
|
|
393
|
+
elif "examples" in media_obj:
|
|
394
|
+
examples_dict = media_obj["examples"]
|
|
395
|
+
if examples_dict:
|
|
396
|
+
first_example = next(iter(examples_dict.values()))
|
|
397
|
+
expected_response = first_example.get("value")
|
|
398
|
+
break
|
|
399
|
+
elif "schema" in media_obj:
|
|
400
|
+
# Generate test case from response schema
|
|
401
|
+
schema = media_obj["schema"]
|
|
402
|
+
generated, warning = generate_test_cases_for_schema(
|
|
403
|
+
schema, "response_body"
|
|
404
|
+
)
|
|
405
|
+
if warning:
|
|
406
|
+
if isinstance(warning, list):
|
|
407
|
+
warnings.extend(warning)
|
|
408
|
+
else:
|
|
409
|
+
warnings.append(warning)
|
|
410
|
+
if generated:
|
|
411
|
+
expected_response = generated[0]
|
|
412
|
+
break
|
|
413
|
+
|
|
414
|
+
if expected_response is None:
|
|
415
|
+
return False, "No example or schema found for 200/201 response"
|
|
416
|
+
|
|
417
|
+
# Test with all collected test cases (examples + generated)
|
|
418
|
+
errors = []
|
|
419
|
+
for request_test_case in request_test_cases:
|
|
420
|
+
# Make the POST request
|
|
421
|
+
try:
|
|
422
|
+
response = make_request("POST", url, json=request_test_case)
|
|
423
|
+
except requests.exceptions.RequestException as e:
|
|
424
|
+
error_msg = f"Request failed: {e}"
|
|
425
|
+
log_test_result(
|
|
426
|
+
"POST",
|
|
427
|
+
path,
|
|
428
|
+
request_test_case,
|
|
429
|
+
expected_status,
|
|
430
|
+
expected_response,
|
|
431
|
+
None,
|
|
432
|
+
None,
|
|
433
|
+
False,
|
|
434
|
+
error_msg,
|
|
435
|
+
)
|
|
436
|
+
errors.append(error_msg)
|
|
437
|
+
continue
|
|
438
|
+
|
|
439
|
+
# Check status code (accept both 200 and 201 for POST)
|
|
440
|
+
actual_response = (
|
|
441
|
+
response.json()
|
|
442
|
+
if response.status_code in [200, 201]
|
|
443
|
+
else response.text
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
if response.status_code not in [200, 201]:
|
|
447
|
+
error_msg = f"Expected status 200/201, got {response.status_code}. Response: {response.text}"
|
|
448
|
+
log_test_result(
|
|
449
|
+
"POST",
|
|
450
|
+
path,
|
|
451
|
+
request_test_case,
|
|
452
|
+
expected_status,
|
|
453
|
+
expected_response,
|
|
454
|
+
response.status_code,
|
|
455
|
+
actual_response,
|
|
456
|
+
False,
|
|
457
|
+
error_msg,
|
|
458
|
+
)
|
|
459
|
+
errors.append(error_msg)
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
# Check response matches example
|
|
463
|
+
matches, error = compare_responses(expected_response, actual_response)
|
|
464
|
+
if not matches:
|
|
465
|
+
log_test_result(
|
|
466
|
+
"POST",
|
|
467
|
+
path,
|
|
468
|
+
request_test_case,
|
|
469
|
+
expected_status,
|
|
470
|
+
expected_response,
|
|
471
|
+
response.status_code,
|
|
472
|
+
actual_response,
|
|
473
|
+
False,
|
|
474
|
+
error,
|
|
475
|
+
)
|
|
476
|
+
errors.append(error)
|
|
477
|
+
continue
|
|
478
|
+
|
|
479
|
+
log_test_result(
|
|
480
|
+
"POST",
|
|
481
|
+
path,
|
|
482
|
+
request_test_case,
|
|
483
|
+
expected_status,
|
|
484
|
+
expected_response,
|
|
485
|
+
response.status_code,
|
|
486
|
+
actual_response,
|
|
487
|
+
True,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
if errors:
|
|
491
|
+
# Return the first few errors for brevity
|
|
492
|
+
combined = "; ".join(str(e) for e in errors[:3])
|
|
493
|
+
return False, combined
|
|
494
|
+
|
|
495
|
+
return True, None
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def test_put_endpoint(base_url, path, operation):
|
|
499
|
+
"""Test a PUT endpoint using the example from the OpenAPI spec.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
base_url: Base URL of the API server
|
|
503
|
+
path: API endpoint path (may contain path parameters)
|
|
504
|
+
operation: OpenAPI operation object
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
tuple: (success: bool, error_message: str or None)
|
|
508
|
+
"""
|
|
509
|
+
# Collect ALL test cases: both from examples AND generated from schema
|
|
510
|
+
request_test_cases = []
|
|
511
|
+
warnings = []
|
|
512
|
+
|
|
513
|
+
request_body = operation.get("requestBody", {})
|
|
514
|
+
request_content = request_body.get("content", {})
|
|
515
|
+
|
|
516
|
+
for media_type, media_obj in request_content.items():
|
|
517
|
+
# Collect explicit examples
|
|
518
|
+
if "example" in media_obj:
|
|
519
|
+
request_test_cases.append(media_obj["example"])
|
|
520
|
+
if "examples" in media_obj:
|
|
521
|
+
examples_dict = media_obj["examples"]
|
|
522
|
+
for ex_name, ex_obj in examples_dict.items():
|
|
523
|
+
if "value" in ex_obj:
|
|
524
|
+
request_test_cases.append(ex_obj["value"])
|
|
525
|
+
|
|
526
|
+
# Also generate test cases from schema
|
|
527
|
+
if "schema" in media_obj:
|
|
528
|
+
schema = media_obj["schema"]
|
|
529
|
+
generated, warning = generate_test_cases_for_schema(schema)
|
|
530
|
+
if warning:
|
|
531
|
+
if isinstance(warning, list):
|
|
532
|
+
warnings.extend(warning)
|
|
533
|
+
else:
|
|
534
|
+
warnings.append(warning)
|
|
535
|
+
request_test_cases.extend(generated)
|
|
536
|
+
break
|
|
537
|
+
|
|
538
|
+
if not request_test_cases:
|
|
539
|
+
return False, "No request body examples or schema found"
|
|
540
|
+
|
|
541
|
+
# Print warnings if any
|
|
542
|
+
for warning in warnings:
|
|
543
|
+
print(f"\n{warning}")
|
|
544
|
+
|
|
545
|
+
# Get the expected response from examples or generate from schema
|
|
546
|
+
responses = operation.get("responses", {})
|
|
547
|
+
response_200 = responses.get("200", {})
|
|
548
|
+
content = response_200.get("content", {})
|
|
549
|
+
|
|
550
|
+
expected_response = None
|
|
551
|
+
for media_type, media_obj in content.items():
|
|
552
|
+
if "example" in media_obj:
|
|
553
|
+
expected_response = media_obj["example"]
|
|
554
|
+
break
|
|
555
|
+
elif "examples" in media_obj:
|
|
556
|
+
examples = media_obj["examples"]
|
|
557
|
+
if examples:
|
|
558
|
+
first_example = next(iter(examples.values()))
|
|
559
|
+
expected_response = first_example.get("value")
|
|
560
|
+
break
|
|
561
|
+
elif "schema" in media_obj:
|
|
562
|
+
# Generate test cases from schema
|
|
563
|
+
schema = media_obj["schema"]
|
|
564
|
+
response_test_cases, warning = generate_test_cases_for_schema(
|
|
565
|
+
schema
|
|
566
|
+
)
|
|
567
|
+
if warning:
|
|
568
|
+
if isinstance(warning, list):
|
|
569
|
+
for w in warning:
|
|
570
|
+
print(f"\n{w}")
|
|
571
|
+
else:
|
|
572
|
+
print(f"\n{warning}")
|
|
573
|
+
if response_test_cases:
|
|
574
|
+
expected_response = response_test_cases[0]
|
|
575
|
+
break
|
|
576
|
+
|
|
577
|
+
if expected_response is None:
|
|
578
|
+
return False, "No example or schema found for 200 response"
|
|
579
|
+
|
|
580
|
+
# Replace path parameters with values from the response example
|
|
581
|
+
url = f"{base_url}{path}"
|
|
582
|
+
resolved_path = path
|
|
583
|
+
if "{" in path:
|
|
584
|
+
import re
|
|
585
|
+
|
|
586
|
+
for match in re.finditer(r"\{(\w+)\}", path):
|
|
587
|
+
param_name = match.group(1)
|
|
588
|
+
|
|
589
|
+
# Try to find the value in the response example
|
|
590
|
+
# Common mappings: item_id -> id, user_id -> id, etc.
|
|
591
|
+
value = None
|
|
592
|
+
|
|
593
|
+
if param_name in expected_response:
|
|
594
|
+
value = expected_response[param_name]
|
|
595
|
+
elif param_name.endswith("_id") and "id" in expected_response:
|
|
596
|
+
# Map item_id -> id, user_id -> id, etc.
|
|
597
|
+
value = expected_response["id"]
|
|
598
|
+
elif "id" in expected_response:
|
|
599
|
+
# Default to using the id field
|
|
600
|
+
value = expected_response["id"]
|
|
601
|
+
else:
|
|
602
|
+
# Use a default test value
|
|
603
|
+
value = 1
|
|
604
|
+
|
|
605
|
+
url = url.replace(f"{{{param_name}}}", str(value))
|
|
606
|
+
resolved_path = resolved_path.replace(
|
|
607
|
+
f"{{{param_name}}}", str(value)
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# Test all collected request cases
|
|
611
|
+
errors = []
|
|
612
|
+
for request_test_case in request_test_cases:
|
|
613
|
+
# Make the PUT request
|
|
614
|
+
try:
|
|
615
|
+
response = make_request("PUT", url, json=request_test_case)
|
|
616
|
+
except requests.exceptions.RequestException as e:
|
|
617
|
+
error_msg = f"Request failed: {e}"
|
|
618
|
+
log_test_result(
|
|
619
|
+
"PUT",
|
|
620
|
+
resolved_path,
|
|
621
|
+
request_test_case,
|
|
622
|
+
200,
|
|
623
|
+
expected_response,
|
|
624
|
+
None,
|
|
625
|
+
None,
|
|
626
|
+
False,
|
|
627
|
+
error_msg,
|
|
628
|
+
)
|
|
629
|
+
errors.append(error_msg)
|
|
630
|
+
continue
|
|
631
|
+
|
|
632
|
+
# Check status code
|
|
633
|
+
actual_response = (
|
|
634
|
+
response.json() if response.status_code == 200 else response.text
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
if response.status_code != 200:
|
|
638
|
+
error_msg = f"Expected status 200, got {response.status_code}. Response: {response.text}"
|
|
639
|
+
log_test_result(
|
|
640
|
+
"PUT",
|
|
641
|
+
resolved_path,
|
|
642
|
+
request_test_case,
|
|
643
|
+
200,
|
|
644
|
+
expected_response,
|
|
645
|
+
response.status_code,
|
|
646
|
+
actual_response,
|
|
647
|
+
False,
|
|
648
|
+
error_msg,
|
|
649
|
+
)
|
|
650
|
+
errors.append(error_msg)
|
|
651
|
+
continue
|
|
652
|
+
|
|
653
|
+
# Check response matches example
|
|
654
|
+
matches, error = compare_responses(expected_response, actual_response)
|
|
655
|
+
if not matches:
|
|
656
|
+
log_test_result(
|
|
657
|
+
"PUT",
|
|
658
|
+
resolved_path,
|
|
659
|
+
request_test_case,
|
|
660
|
+
200,
|
|
661
|
+
expected_response,
|
|
662
|
+
response.status_code,
|
|
663
|
+
actual_response,
|
|
664
|
+
False,
|
|
665
|
+
error,
|
|
666
|
+
)
|
|
667
|
+
errors.append(error)
|
|
668
|
+
continue
|
|
669
|
+
|
|
670
|
+
log_test_result(
|
|
671
|
+
"PUT",
|
|
672
|
+
resolved_path,
|
|
673
|
+
request_test_case,
|
|
674
|
+
200,
|
|
675
|
+
expected_response,
|
|
676
|
+
response.status_code,
|
|
677
|
+
actual_response,
|
|
678
|
+
True,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
if errors:
|
|
682
|
+
combined = "; ".join(str(e) for e in errors[:3])
|
|
683
|
+
return False, combined
|
|
684
|
+
|
|
685
|
+
return True, None
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def test_delete_endpoint(base_url, path, operation):
|
|
689
|
+
"""Test a DELETE endpoint using the example from the OpenAPI spec.
|
|
690
|
+
|
|
691
|
+
Args:
|
|
692
|
+
base_url: Base URL of the API server
|
|
693
|
+
path: API endpoint path (may contain path parameters)
|
|
694
|
+
operation: OpenAPI operation object
|
|
695
|
+
|
|
696
|
+
Returns:
|
|
697
|
+
tuple: (success: bool, error_message: str or None)
|
|
698
|
+
"""
|
|
699
|
+
# For DELETE, we need to get path parameters from the 200/204 response example or schema
|
|
700
|
+
responses = operation.get("responses", {})
|
|
701
|
+
|
|
702
|
+
# Try to find an example with path parameters
|
|
703
|
+
response_example = None
|
|
704
|
+
expected_status = None
|
|
705
|
+
expected_body = None
|
|
706
|
+
|
|
707
|
+
for status_code in ["200", "204", "404"]:
|
|
708
|
+
resp_obj = responses.get(status_code, {})
|
|
709
|
+
if status_code in ["200", "204"]:
|
|
710
|
+
expected_status = int(status_code)
|
|
711
|
+
content = resp_obj.get("content", {})
|
|
712
|
+
for media_type, media_obj in content.items():
|
|
713
|
+
if "example" in media_obj:
|
|
714
|
+
response_example = media_obj["example"]
|
|
715
|
+
expected_body = response_example
|
|
716
|
+
break
|
|
717
|
+
elif "examples" in media_obj:
|
|
718
|
+
examples = media_obj["examples"]
|
|
719
|
+
if examples:
|
|
720
|
+
first_example = next(iter(examples.values()))
|
|
721
|
+
response_example = first_example.get("value")
|
|
722
|
+
expected_body = response_example
|
|
723
|
+
break
|
|
724
|
+
elif "schema" in media_obj:
|
|
725
|
+
# Generate test cases from schema
|
|
726
|
+
schema = media_obj["schema"]
|
|
727
|
+
response_test_cases, warning = generate_test_cases_for_schema(
|
|
728
|
+
schema
|
|
729
|
+
)
|
|
730
|
+
if warning:
|
|
731
|
+
if isinstance(warning, list):
|
|
732
|
+
for w in warning:
|
|
733
|
+
print(f"\n{w}")
|
|
734
|
+
else:
|
|
735
|
+
print(f"\n{warning}")
|
|
736
|
+
if response_test_cases:
|
|
737
|
+
response_example = response_test_cases[0]
|
|
738
|
+
expected_body = response_example
|
|
739
|
+
break
|
|
740
|
+
if response_example:
|
|
741
|
+
break
|
|
742
|
+
|
|
743
|
+
# If no expected body found (common for 204 responses), use empty string
|
|
744
|
+
if expected_status is None:
|
|
745
|
+
expected_status = 204
|
|
746
|
+
if expected_body is None:
|
|
747
|
+
expected_body = ""
|
|
748
|
+
|
|
749
|
+
# Replace path parameters with values from the example
|
|
750
|
+
url = f"{base_url}{path}"
|
|
751
|
+
resolved_path = path
|
|
752
|
+
if "{" in path:
|
|
753
|
+
import re
|
|
754
|
+
|
|
755
|
+
# For DELETE, use a hardcoded ID if no example provides it
|
|
756
|
+
# This is a simplification - in reality we might need to create a resource first
|
|
757
|
+
for match in re.finditer(r"\{(\w+)\}", path):
|
|
758
|
+
param_name = match.group(1)
|
|
759
|
+
|
|
760
|
+
# Try to find the value in the response example
|
|
761
|
+
# Common mappings: item_id -> id, user_id -> id, etc.
|
|
762
|
+
param_value = None
|
|
763
|
+
|
|
764
|
+
if response_example and isinstance(response_example, dict):
|
|
765
|
+
if param_name in response_example:
|
|
766
|
+
param_value = response_example[param_name]
|
|
767
|
+
elif param_name.endswith("_id") and "id" in response_example:
|
|
768
|
+
# Map item_id -> id, user_id -> id, etc.
|
|
769
|
+
param_value = response_example["id"]
|
|
770
|
+
elif "id" in response_example:
|
|
771
|
+
# Default to using the id field
|
|
772
|
+
param_value = response_example["id"]
|
|
773
|
+
|
|
774
|
+
# Default test value if not found
|
|
775
|
+
if param_value is None:
|
|
776
|
+
param_value = 1
|
|
777
|
+
|
|
778
|
+
url = url.replace(f"{{{param_name}}}", str(param_value))
|
|
779
|
+
resolved_path = resolved_path.replace(
|
|
780
|
+
f"{{{param_name}}}", str(param_value)
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
# Make the DELETE request
|
|
784
|
+
try:
|
|
785
|
+
response = make_request("DELETE", url)
|
|
786
|
+
except requests.exceptions.RequestException as e:
|
|
787
|
+
error_msg = f"Request failed: {e}"
|
|
788
|
+
log_test_result(
|
|
789
|
+
"DELETE",
|
|
790
|
+
resolved_path,
|
|
791
|
+
None,
|
|
792
|
+
expected_status,
|
|
793
|
+
expected_body,
|
|
794
|
+
None,
|
|
795
|
+
None,
|
|
796
|
+
False,
|
|
797
|
+
error_msg,
|
|
798
|
+
)
|
|
799
|
+
return False, error_msg
|
|
800
|
+
|
|
801
|
+
# Check status code (accept 200 or 204 for successful DELETE)
|
|
802
|
+
actual_response = ""
|
|
803
|
+
if response.status_code == 200 and response.text:
|
|
804
|
+
try:
|
|
805
|
+
actual_response = response.json()
|
|
806
|
+
except Exception:
|
|
807
|
+
actual_response = response.text
|
|
808
|
+
|
|
809
|
+
if response.status_code not in [200, 204]:
|
|
810
|
+
error_msg = f"Expected status 200/204, got {response.status_code}. Response: {response.text}"
|
|
811
|
+
log_test_result(
|
|
812
|
+
"DELETE",
|
|
813
|
+
resolved_path,
|
|
814
|
+
None,
|
|
815
|
+
expected_status,
|
|
816
|
+
expected_body,
|
|
817
|
+
response.status_code,
|
|
818
|
+
actual_response,
|
|
819
|
+
False,
|
|
820
|
+
error_msg,
|
|
821
|
+
)
|
|
822
|
+
return False, error_msg
|
|
823
|
+
|
|
824
|
+
log_test_result(
|
|
825
|
+
"DELETE",
|
|
826
|
+
resolved_path,
|
|
827
|
+
None,
|
|
828
|
+
expected_status,
|
|
829
|
+
expected_body,
|
|
830
|
+
response.status_code,
|
|
831
|
+
actual_response,
|
|
832
|
+
True,
|
|
833
|
+
)
|
|
834
|
+
return True, None
|