swaggerforge 0.1.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.
File without changes
File without changes
swaggerforge/cli.py ADDED
@@ -0,0 +1,67 @@
1
+ """Command-line interface for SwaggerForge."""
2
+
3
+ import click
4
+ from swaggerforge.validator import load_and_validate, SpecValidationError
5
+ from swaggerforge.parser import parse_spec, SpecParseError
6
+ from swaggerforge.generator import generate_scenarios
7
+ from swaggerforge.output import write_test_files
8
+
9
+
10
+ @click.group()
11
+ @click.version_option()
12
+ def main():
13
+ """SwaggerForge generate pytest tests from OpenAPI specifications."""
14
+ pass
15
+
16
+
17
+ @main.command()
18
+ @click.option(
19
+ "--spec",
20
+ required=True,
21
+ type=click.Path(exists=True),
22
+ help="Path to the OpenAPI specification file (JSON or YAML).",
23
+ )
24
+ @click.option(
25
+ "--url",
26
+ required=True,
27
+ help="Base URL of the API to be tested.",
28
+ )
29
+ @click.option(
30
+ "--output",
31
+ default="tests_generated",
32
+ type=click.Path(),
33
+ help="Directory where generated test files will be written.",
34
+ )
35
+ def generate(spec, url, output):
36
+ """Generate pytest test files from an OpenAPI specification."""
37
+ click.echo(f"Reading specification: {spec}")
38
+
39
+ try:
40
+ spec_dict = load_and_validate(spec)
41
+ except SpecValidationError as error:
42
+ raise click.ClickException(str(error))
43
+
44
+ click.echo("Specification is valid.")
45
+ click.echo(f"API title: {spec_dict['info']['title']}")
46
+
47
+ try:
48
+ endpoints = parse_spec(spec)
49
+ except SpecParseError as error:
50
+ raise click.ClickException(str(error))
51
+
52
+ tags = sorted({endpoint.tag for endpoint in endpoints})
53
+ click.echo(f"Found {len(endpoints)} endpoints across {len(tags)} resources: {', '.join(tags)}")
54
+
55
+ scenarios = generate_scenarios(endpoints)
56
+ click.echo(f"Generated {len(scenarios)} test scenarios.")
57
+
58
+ try:
59
+ written = write_test_files(scenarios, url, output)
60
+ except OSError as error:
61
+ raise click.ClickException(
62
+ f"Could not write test files to '{output}': {error}"
63
+ )
64
+
65
+ click.echo(f"Wrote {len(written)} test file(s) to '{output}':")
66
+ for path in written:
67
+ click.echo(f" - {path}")
@@ -0,0 +1,441 @@
1
+ """Generation of test scenarios from parsed OpenAPI endpoints.
2
+
3
+ This module turns the internal endpoint model into concrete test scenarios.
4
+ Its first building block is a deterministic example-value generator that
5
+ produces valid sample values from a JSON schema.
6
+ """
7
+
8
+ from swaggerforge.models import TestScenario
9
+
10
+
11
+ def example_value(schema):
12
+ """Produce a deterministic example value conforming to a JSON schema.
13
+
14
+ The same schema always yields the same value, which is required for
15
+ reproducible test generation (WNF11).
16
+
17
+ Args:
18
+ schema: A resolved JSON schema fragment (a dictionary).
19
+
20
+ Returns:
21
+ A Python value (str, int, float, bool, list, or dict) valid under
22
+ the schema.
23
+ """
24
+ if not isinstance(schema, dict):
25
+ return None
26
+
27
+ enum = schema.get("enum")
28
+ if enum:
29
+ return enum[0]
30
+
31
+ schema_type = schema.get("type")
32
+
33
+ if schema_type == "string":
34
+ return _example_string(schema)
35
+ if schema_type == "integer":
36
+ return _example_integer(schema)
37
+ if schema_type == "number":
38
+ return _example_integer(schema)
39
+ if schema_type == "boolean":
40
+ return True
41
+ if schema_type == "array":
42
+ item_schema = schema.get("items", {})
43
+ return [example_value(item_schema)]
44
+ if schema_type == "object":
45
+ return _example_object(schema)
46
+
47
+ return None
48
+
49
+
50
+ def _example_integer(schema):
51
+ """Return a valid integer, respecting 'minimum' if present."""
52
+ minimum = schema.get("minimum")
53
+ if minimum is not None:
54
+ return minimum
55
+ return 0
56
+
57
+
58
+ # deterministic values for common string formats
59
+ _STRING_FORMATS = {
60
+ "date-time": "2024-01-01T00:00:00Z",
61
+ "date": "2024-01-01",
62
+ "email": "user@example.com",
63
+ "uuid": "00000000-0000-0000-0000-000000000000",
64
+ "uri": "https://example.com",
65
+ }
66
+
67
+
68
+ def _example_string(schema):
69
+ """Return a string value, honoring a recognized 'format' if present."""
70
+ string_format = schema.get("format")
71
+ return _STRING_FORMATS.get(string_format, "string")
72
+
73
+
74
+ def _example_object(schema):
75
+ """Build a dict with an example value for each declared property."""
76
+ result = {}
77
+ properties = schema.get("properties", {})
78
+ for name, prop_schema in properties.items():
79
+ result[name] = example_value(prop_schema)
80
+ return result
81
+
82
+
83
+ def generate_positive_scenario(endpoint):
84
+ """Build a positive (happy-path) test scenario for an endpoint.
85
+
86
+ Sends a valid request conforming to the schema and expects a 2xx
87
+ response code, following the equivalence-partitioning technique for
88
+ the valid input class.
89
+
90
+ Args:
91
+ endpoint: The Endpoint to generate a scenario for.
92
+
93
+ Returns:
94
+ A single-element list containing the positive-case TestScenario.
95
+ """
96
+ request_body = None
97
+ if endpoint.request_body is not None:
98
+ request_body = example_value(endpoint.request_body)
99
+
100
+ return [TestScenario(
101
+ endpoint=endpoint,
102
+ name="success",
103
+ scenario_type="positive",
104
+ description=f"{endpoint.method.upper()} {endpoint.path} - positive scenario",
105
+ path_params=_example_path_params(endpoint),
106
+ query_params=_example_query_params(endpoint),
107
+ request_body=request_body,
108
+ expected_status=_success_codes(endpoint),
109
+ )]
110
+
111
+
112
+ def _success_codes(endpoint):
113
+ """Return the 2xx response codes declared for an endpoint."""
114
+ return [code for code in endpoint.responses if code.startswith("2")]
115
+
116
+
117
+ def _example_path_params(endpoint):
118
+ """Build example values for an endpoint's path parameters."""
119
+ params = {}
120
+ for param in endpoint.parameters:
121
+ if param.location == "path":
122
+ params[param.name] = example_value(param.schema)
123
+ return params
124
+
125
+
126
+ def _example_query_params(endpoint):
127
+ """Build example values for an endpoint's required query parameters."""
128
+ params = {}
129
+ for param in endpoint.parameters:
130
+ if param.location == "query" and param.required:
131
+ params[param.name] = example_value(param.schema)
132
+ return params
133
+
134
+
135
+ def generate_missing_field_scenario(endpoint):
136
+ """Build a negative scenario that omits a required field from the body.
137
+
138
+ Takes a valid request body and removes the first required field,
139
+ following the equivalence-partitioning technique for the invalid
140
+ input class.
141
+
142
+ Args:
143
+ endpoint: The Endpoint to generate a scenario for.
144
+
145
+ Returns:
146
+ A single-element list with the missing-field TestScenario, or an
147
+ empty list if the endpoint has no request body with required fields.
148
+ """
149
+ if endpoint.request_body is None:
150
+ return []
151
+
152
+ required_fields = endpoint.request_body.get("required", [])
153
+ if not required_fields:
154
+ return []
155
+
156
+ field_to_remove = required_fields[0]
157
+
158
+ body = example_value(endpoint.request_body)
159
+ body.pop(field_to_remove, None)
160
+
161
+ return [TestScenario(
162
+ endpoint=endpoint,
163
+ name=f"missing_{field_to_remove}",
164
+ scenario_type="negative",
165
+ description=(
166
+ f"{endpoint.method.upper()} {endpoint.path} - "
167
+ f"missing required field '{field_to_remove}' (expects 400)"
168
+ ),
169
+ path_params=_example_path_params(endpoint),
170
+ query_params=_example_query_params(endpoint),
171
+ request_body=body,
172
+ expected_status=["400"],
173
+ )]
174
+
175
+
176
+ # A deterministic, unlikely-to-exist identifier used for "resource not found" tests.
177
+ NONEXISTENT_INT_ID = 999999999
178
+ NONEXISTENT_STR_ID = "nonexistent-id-000000"
179
+
180
+
181
+ def generate_nonexistent_scenario(endpoint):
182
+ """Build a negative scenario requesting a resource that does not exist.
183
+
184
+ Applies to endpoints with a path parameter (a resource identifier).
185
+ Uses a deterministic, unlikely identifier and expects a 404 response.
186
+ This is a best-effort heuristic: the spec describes the API's shape,
187
+ not its data, so the absence of the resource cannot be guaranteed.
188
+
189
+ Args:
190
+ endpoint: The Endpoint to generate a scenario for.
191
+
192
+ Returns:
193
+ A single-element list with the not-found TestScenario, or an empty
194
+ list if the endpoint has no path parameter.
195
+ """
196
+ path_params = {}
197
+ has_path_param = False
198
+ for param in endpoint.parameters:
199
+ if param.location == "path":
200
+ has_path_param = True
201
+ path_params[param.name] = _nonexistent_value(param.schema)
202
+
203
+ if not has_path_param:
204
+ return []
205
+
206
+ expected = "404"
207
+
208
+ return [TestScenario(
209
+ endpoint=endpoint,
210
+ name="nonexistent",
211
+ scenario_type="negative",
212
+ description=(
213
+ f"{endpoint.method.upper()} {endpoint.path} - "
214
+ f"nonexistent resource (expects 404)"
215
+ ),
216
+ path_params=path_params,
217
+ query_params=_example_query_params(endpoint),
218
+ request_body=None,
219
+ expected_status=[expected],
220
+ )]
221
+
222
+
223
+ def generate_wrong_type_scenario(endpoint):
224
+ """Build a negative scenario sending a wrong-typed field in the body.
225
+
226
+ Takes a valid request body and replaces the first numeric field with a
227
+ string value, violating the declared type. Expects a 400 or 422
228
+ response.
229
+
230
+ Args:
231
+ endpoint: The Endpoint to generate a scenario for.
232
+
233
+ Returns:
234
+ A single-element list with the wrong-type TestScenario, or an empty
235
+ list if the body has no numeric field to corrupt.
236
+ """
237
+ if endpoint.request_body is None:
238
+ return []
239
+
240
+ properties = endpoint.request_body.get("properties", {})
241
+ field_to_corrupt = _first_numeric_field(properties)
242
+ if field_to_corrupt is None:
243
+ return []
244
+
245
+ body = example_value(endpoint.request_body)
246
+ body[field_to_corrupt] = "not_a_number"
247
+
248
+ return [TestScenario(
249
+ endpoint=endpoint,
250
+ name=f"wrong_type_{field_to_corrupt}",
251
+ scenario_type="negative",
252
+ description=(
253
+ f"{endpoint.method.upper()} {endpoint.path} - "
254
+ f"wrong type for field '{field_to_corrupt}' (expects 400 or 422)"
255
+ ),
256
+ path_params=_example_path_params(endpoint),
257
+ query_params=_example_query_params(endpoint),
258
+ request_body=body,
259
+ expected_status=["400", "422"],
260
+ )]
261
+
262
+
263
+ def generate_boundary_scenarios(endpoint):
264
+ """Build boundary-value scenarios for an endpoint's constrained fields.
265
+
266
+ Applies Boundary Value Analysis: for each body field that declares
267
+ numeric (minimum/maximum) or string-length (minLength/maxLength)
268
+ constraints, generates scenarios at and just beyond each edge. Valid
269
+ edges expect a 2xx response; out-of-range values expect 400 or 422.
270
+
271
+ Args:
272
+ endpoint: The Endpoint to generate scenarios for.
273
+
274
+ Returns:
275
+ A list of TestScenario objects (empty if no constrained fields).
276
+ """
277
+ if endpoint.request_body is None:
278
+ return []
279
+
280
+ scenarios = []
281
+ properties = endpoint.request_body.get("properties", {})
282
+ for field_name, field_schema in properties.items():
283
+ if not isinstance(field_schema, dict):
284
+ continue
285
+ for value, is_valid, label in _boundary_cases(field_schema):
286
+ scenarios.append(
287
+ _make_boundary_scenario(endpoint, field_name, value, is_valid, label)
288
+ )
289
+ return scenarios
290
+
291
+
292
+ def generate_boolean_scenarios(endpoint):
293
+ """Build scenarios exercising both values of each boolean body field.
294
+
295
+ For every boolean field in the request body, generates two scenarios -
296
+ one sending True and one sending False - since both are valid values an
297
+ API should accept. This gives coverage of logical parameters (WF12).
298
+
299
+ Args:
300
+ endpoint: The Endpoint to generate scenarios for.
301
+
302
+ Returns:
303
+ A list of TestScenario objects (empty if no boolean fields).
304
+ """
305
+ if endpoint.request_body is None:
306
+ return []
307
+
308
+ scenarios = []
309
+ properties = endpoint.request_body.get("properties", {})
310
+ for field_name, field_schema in properties.items():
311
+ if not isinstance(field_schema, dict):
312
+ continue
313
+ if field_schema.get("type") != "boolean":
314
+ continue
315
+ for value in (True, False):
316
+ scenarios.append(
317
+ _make_boolean_scenario(endpoint, field_name, value)
318
+ )
319
+ return scenarios
320
+
321
+
322
+ def _make_boolean_scenario(endpoint, field_name, value):
323
+ """Construct one boolean TestScenario for a field set to True or False."""
324
+ body = example_value(endpoint.request_body)
325
+ body[field_name] = value
326
+
327
+ return TestScenario(
328
+ endpoint=endpoint,
329
+ name=f"boolean_{field_name}_{str(value).lower()}",
330
+ scenario_type="boolean",
331
+ description=(
332
+ f"{endpoint.method.upper()} {endpoint.path} - "
333
+ f"boolean field '{field_name}' = {str(value).lower()}"
334
+ ),
335
+ path_params=_example_path_params(endpoint),
336
+ query_params=_example_query_params(endpoint),
337
+ request_body=body,
338
+ expected_status=_success_codes(endpoint) or ["200"],
339
+ )
340
+
341
+
342
+ def _boundary_cases(field_schema):
343
+ """Return (value, is_valid, label) tuples for a field's declared boundaries."""
344
+ field_type = field_schema.get("type")
345
+ if field_type in ("integer", "number"):
346
+ return _numeric_boundary_cases(field_schema)
347
+ if field_type == "string":
348
+ return _string_boundary_cases(field_schema)
349
+ return []
350
+
351
+
352
+ def _numeric_boundary_cases(field_schema):
353
+ """Boundary cases for a numeric field with minimum/maximum."""
354
+ cases = []
355
+ minimum = field_schema.get("minimum")
356
+ maximum = field_schema.get("maximum")
357
+ if minimum is not None:
358
+ cases.append((minimum - 1, False, "below_min"))
359
+ cases.append((minimum, True, "at_min"))
360
+ if maximum is not None:
361
+ cases.append((maximum, True, "at_max"))
362
+ cases.append((maximum + 1, False, "above_max"))
363
+ return cases
364
+
365
+
366
+ def _string_boundary_cases(field_schema):
367
+ """Boundary cases for a string field with minLength/maxLength."""
368
+ cases = []
369
+ min_length = field_schema.get("minLength")
370
+ max_length = field_schema.get("maxLength")
371
+ if min_length is not None and min_length > 0:
372
+ cases.append(("x" * (min_length - 1), False, "below_min_length"))
373
+ cases.append(("x" * min_length, True, "at_min_length"))
374
+ if max_length is not None:
375
+ cases.append(("x" * max_length, True, "at_max_length"))
376
+ cases.append(("x" * (max_length + 1), False, "above_max_length"))
377
+ return cases
378
+
379
+
380
+ def _make_boundary_scenario(endpoint, field_name, value, is_valid, label):
381
+ """Construct one boundary TestScenario for a field set to a given value."""
382
+ body = example_value(endpoint.request_body)
383
+ body[field_name] = value
384
+
385
+ if is_valid:
386
+ expected = _success_codes(endpoint) or ["200"]
387
+ else:
388
+ expected = ["400", "422"]
389
+
390
+ return TestScenario(
391
+ endpoint=endpoint,
392
+ name=f"boundary_{field_name}_{label}",
393
+ scenario_type="boundary",
394
+ description=(
395
+ f"{endpoint.method.upper()} {endpoint.path} - "
396
+ f"boundary value for '{field_name}' ({label})"
397
+ ),
398
+ path_params=_example_path_params(endpoint),
399
+ query_params=_example_query_params(endpoint),
400
+ request_body=body,
401
+ expected_status=expected,
402
+ )
403
+
404
+
405
+ def _first_numeric_field(properties):
406
+ """Return the name of the first integer/number property, or None."""
407
+ for name, prop_schema in properties.items():
408
+ if isinstance(prop_schema, dict) and prop_schema.get("type") in ("integer", "number"):
409
+ return name
410
+ return None
411
+
412
+
413
+ def _nonexistent_value(schema):
414
+ """Return a deterministic, unlikely identifier matching the schema type."""
415
+ if schema.get("type") == "integer":
416
+ return NONEXISTENT_INT_ID
417
+ return NONEXISTENT_STR_ID
418
+
419
+
420
+ def generate_scenarios(endpoints):
421
+ """Generate all applicable test scenarios for a list of endpoints.
422
+
423
+ Runs every scenario generator over each endpoint. Every generator
424
+ returns a (possibly empty) list of scenarios, so the results are simply
425
+ flattened into one list.
426
+
427
+ Args:
428
+ endpoints: A list of Endpoint objects.
429
+
430
+ Returns:
431
+ A list of TestScenario objects.
432
+ """
433
+ scenarios = []
434
+ for endpoint in endpoints:
435
+ scenarios.extend(generate_positive_scenario(endpoint))
436
+ scenarios.extend(generate_missing_field_scenario(endpoint))
437
+ scenarios.extend(generate_nonexistent_scenario(endpoint))
438
+ scenarios.extend(generate_wrong_type_scenario(endpoint))
439
+ scenarios.extend(generate_boundary_scenarios(endpoint))
440
+ scenarios.extend(generate_boolean_scenarios(endpoint))
441
+ return scenarios
swaggerforge/models.py ADDED
@@ -0,0 +1,46 @@
1
+ """Internal data model for SwaggerForge.
2
+
3
+ These dataclasses represent the structured form of an OpenAPI specification
4
+ used internally by the library. The Parser produces them from a raw
5
+ specification; the Generator and Template modules consume them.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+
11
+ @dataclass
12
+ class Parameter:
13
+ """A single operation parameter (path, query, or header)."""
14
+
15
+ name: str
16
+ location: str
17
+ required: bool
18
+ schema: dict = field(default_factory=dict)
19
+
20
+
21
+ @dataclass
22
+ class Endpoint:
23
+ """A single API operation: one HTTP method on one path."""
24
+
25
+ path: str
26
+ method: str
27
+ tag: str
28
+ operation_id: str
29
+ parameters: list[Parameter] = field(default_factory=list)
30
+ request_body: dict | None = None
31
+ responses: list[str] = field(default_factory=list)
32
+ response_schema: dict | None = None
33
+
34
+
35
+ @dataclass
36
+ class TestScenario:
37
+ """A single generated test case for one endpoint."""
38
+
39
+ endpoint: Endpoint
40
+ name: str
41
+ scenario_type: str
42
+ description: str
43
+ path_params: dict = field(default_factory=dict)
44
+ query_params: dict = field(default_factory=dict)
45
+ request_body: dict | None = None
46
+ expected_status: list[str] = field(default_factory=list)
swaggerforge/output.py ADDED
@@ -0,0 +1,53 @@
1
+ """Writing generated test files to disk.
2
+
3
+ This module groups scenarios by resource tag, renders each group into a
4
+ pytest file, and writes the files into the output directory. It only ever
5
+ writes into the output directory and never modifies the input
6
+ specification (WNF08).
7
+ """
8
+
9
+ import re
10
+ from pathlib import Path
11
+
12
+ from swaggerforge.template import render_test_file
13
+
14
+
15
+ def write_test_files(scenarios, base_url, output_dir):
16
+ """Group scenarios by tag, render them, and write one file per tag.
17
+
18
+ Args:
19
+ scenarios: A list of TestScenario objects.
20
+ base_url: The base URL of the API under test.
21
+ output_dir: Directory where the test files will be written.
22
+
23
+ Returns:
24
+ A sorted list of paths to the files that were written.
25
+ """
26
+ grouped = _group_by_tag(scenarios)
27
+
28
+ output_path = Path(output_dir)
29
+ output_path.mkdir(parents=True, exist_ok=True)
30
+
31
+ written = []
32
+ for tag in sorted(grouped):
33
+ filename = f"test_{_sanitize(tag)}.py"
34
+ file_path = output_path / filename
35
+ content = render_test_file(filename, base_url, grouped[tag])
36
+ file_path.write_text(content, encoding="utf-8")
37
+ written.append(str(file_path))
38
+
39
+ return written
40
+
41
+
42
+ def _group_by_tag(scenarios):
43
+ """Group scenarios into a dict keyed by their endpoint's tag."""
44
+ grouped = {}
45
+ for scenario in scenarios:
46
+ tag = scenario.endpoint.tag
47
+ grouped.setdefault(tag, []).append(scenario)
48
+ return grouped
49
+
50
+
51
+ def _sanitize(tag):
52
+ """Replace characters not valid in a filename with underscores."""
53
+ return re.sub(r"[^a-zA-Z0-9_]", "_", tag)
swaggerforge/parser.py ADDED
@@ -0,0 +1,108 @@
1
+ """Parsing of OpenAPI specifications into the internal data model.
2
+
3
+ This module reads an OpenAPI specification, resolves any $ref references,
4
+ and extracts a structured list of endpoints grouped by resource tag.
5
+ """
6
+
7
+ from prance import ResolvingParser
8
+
9
+ from swaggerforge.models import Endpoint, Parameter
10
+
11
+ # HTTP methods that represent operations we generate tests for.
12
+ HTTP_METHODS = ("get", "post", "put", "patch", "delete")
13
+
14
+
15
+ class SpecParseError(Exception):
16
+ """Raised when the specification cannot be parsed into the data model."""
17
+ pass
18
+
19
+
20
+ def parse_spec(spec_path):
21
+ """Parse an OpenAPI specification into a list of endpoints.
22
+
23
+ Args:
24
+ spec_path: Path to the OpenAPI specification file (JSON or YAML).
25
+
26
+ Returns:
27
+ A list of Endpoint objects extracted from the specification.
28
+
29
+ Raises:
30
+ SpecParseError: If the specification cannot be parsed.
31
+ """
32
+ try:
33
+ parser = ResolvingParser(spec_path, backend="openapi-spec-validator")
34
+ spec = parser.specification
35
+ except Exception as error:
36
+ raise SpecParseError(
37
+ f"Could not parse specification '{spec_path}': {error}"
38
+ )
39
+
40
+ endpoints = []
41
+
42
+ paths = spec.get("paths", {})
43
+ for path, path_item in paths.items():
44
+ for method in HTTP_METHODS:
45
+ operation = path_item.get(method)
46
+ if operation is None:
47
+ continue
48
+
49
+ endpoint = Endpoint(
50
+ path=path,
51
+ method=method,
52
+ tag=_extract_tag(operation),
53
+ operation_id=operation.get("operationId", ""),
54
+ parameters=_extract_parameters(operation),
55
+ request_body=_extract_request_body(operation),
56
+ responses=list(operation.get("responses", {}).keys()),
57
+ response_schema=_extract_response_schema(operation),
58
+ )
59
+ endpoints.append(endpoint)
60
+
61
+ return endpoints
62
+
63
+
64
+ def _extract_tag(operation):
65
+ """Return the first tag of an operation, or 'default' if none."""
66
+ tags = operation.get("tags", [])
67
+ if tags:
68
+ return tags[0]
69
+ return "default"
70
+
71
+
72
+ def _extract_parameters(operation):
73
+ """Build a list of Parameter objects from an operation."""
74
+ parameters = []
75
+ for param in operation.get("parameters", []):
76
+ parameters.append(
77
+ Parameter(
78
+ name=param.get("name", ""),
79
+ location=param.get("in", ""),
80
+ required=param.get("required", False),
81
+ schema=param.get("schema", {}),
82
+ )
83
+ )
84
+ return parameters
85
+
86
+
87
+ def _extract_request_body(operation):
88
+ """Return the resolved JSON request body schema, or None."""
89
+ request_body = operation.get("requestBody")
90
+ if request_body is None:
91
+ return None
92
+ content = request_body.get("content", {})
93
+ json_content = content.get("application/json")
94
+ if json_content is None:
95
+ return None
96
+ return json_content.get("schema")
97
+
98
+
99
+ def _extract_response_schema(operation):
100
+ """Return the resolved JSON schema of the success (2xx) response, or None."""
101
+ responses = operation.get("responses", {})
102
+ for code, response in responses.items():
103
+ if code.startswith("2"):
104
+ content = response.get("content", {})
105
+ json_content = content.get("application/json")
106
+ if json_content is not None:
107
+ return json_content.get("schema")
108
+ return None
@@ -0,0 +1,116 @@
1
+ """Rendering of test scenarios into pytest source code.
2
+
3
+ This module turns TestScenario objects into the text of a pytest test
4
+ file, using a Jinja2 template. All computation (building function names,
5
+ request calls, and literals) happens here; the template only places the
6
+ pre-computed values.
7
+ """
8
+
9
+ from jinja2 import Environment, PackageLoader
10
+
11
+ _env = Environment(
12
+ loader=PackageLoader("swaggerforge"),
13
+ trim_blocks=True,
14
+ lstrip_blocks=True,
15
+ keep_trailing_newline=True,
16
+ autoescape=False,
17
+ )
18
+
19
+
20
+ def render_test_file(filename, base_url, scenarios):
21
+ """Render a list of scenarios into the text of a pytest test file.
22
+
23
+ Args:
24
+ filename: The name of the file being generated (e.g. 'test_pet.py').
25
+ base_url: The base URL of the API under test.
26
+ scenarios: A list of TestScenario objects for one resource tag.
27
+
28
+ Returns:
29
+ The rendered file content as a string.
30
+ """
31
+ template = _env.get_template("test_file.py.j2")
32
+ rendered_scenarios = [_prepare_scenario(s) for s in scenarios]
33
+ return template.render(
34
+ filename=filename,
35
+ base_url=base_url,
36
+ scenarios=rendered_scenarios,
37
+ )
38
+
39
+
40
+ def _prepare_scenario(scenario):
41
+ """Pre-compute all values the template needs for one scenario."""
42
+ return {
43
+ "function_name": build_function_name(scenario),
44
+ "docstring": scenario.description,
45
+ "request_call": build_request_call(scenario),
46
+ "expected_status": _status_codes_literal(scenario),
47
+ "response_schema": _response_schema_literal(scenario),
48
+ }
49
+
50
+
51
+ def build_function_name(scenario):
52
+ """Build a unique pytest function name for a scenario.
53
+
54
+ Uses the operationId (unique per operation in OpenAPI) when available,
55
+ falling back to a method-and-path-derived identifier otherwise.
56
+ """
57
+ endpoint = scenario.endpoint
58
+ if endpoint.operation_id:
59
+ base = _to_snake_case(endpoint.operation_id)
60
+ else:
61
+ base = _path_to_identifier(endpoint.method, endpoint.path)
62
+ return f"test_{base}_{scenario.name}"
63
+
64
+
65
+ def _to_snake_case(operation_id):
66
+ """Convert a camelCase operationId to snake_case (e.g. getPetById -> get_pet_by_id)."""
67
+ result = []
68
+ for char in operation_id:
69
+ if char.isupper():
70
+ result.append("_")
71
+ result.append(char.lower())
72
+ else:
73
+ result.append(char)
74
+ return "".join(result).strip("_")
75
+
76
+
77
+ def _path_to_identifier(method, path):
78
+ """Build an identifier from method and path when no operationId exists."""
79
+ cleaned = path.replace("/", "_").replace("{", "").replace("}", "")
80
+ cleaned = cleaned.strip("_")
81
+ return f"{method}_{cleaned}"
82
+
83
+
84
+ def build_request_call(scenario):
85
+ """Assemble the full requests.<method>(...) call as a string."""
86
+ endpoint = scenario.endpoint
87
+ url = _build_url(endpoint.path, scenario.path_params)
88
+
89
+ parts = [f'f"{{base_url}}{url}"']
90
+ if scenario.query_params:
91
+ parts.append(f"params={scenario.query_params!r}")
92
+ if scenario.request_body is not None:
93
+ parts.append(f"json={scenario.request_body!r}")
94
+
95
+ args = ", ".join(parts)
96
+ return f"requests.{endpoint.method}({args})"
97
+
98
+
99
+ def _build_url(path, path_params):
100
+ """Substitute path parameter values into a path template."""
101
+ result = path
102
+ for name, value in path_params.items():
103
+ result = result.replace("{" + name + "}", str(value))
104
+ return result
105
+
106
+
107
+ def _status_codes_literal(scenario):
108
+ """Return the expected status codes as a list of ints, e.g. [200, 201]."""
109
+ return [int(code) for code in scenario.expected_status]
110
+
111
+
112
+ def _response_schema_literal(scenario):
113
+ """Return the response schema for positive scenarios, else None."""
114
+ if scenario.scenario_type == "positive":
115
+ return scenario.endpoint.response_schema
116
+ return None
@@ -0,0 +1,24 @@
1
+ {# Template for a generated pytest test file (one per resource tag). #}
2
+ {# Receives: tag, base_url, and a list of pre-rendered scenario dicts. #}
3
+ # {{ filename }}
4
+ # Generated by SwaggerForge - do not edit manually.
5
+ import pytest
6
+ import requests
7
+ import jsonschema
8
+
9
+
10
+ @pytest.fixture(scope="session")
11
+ def base_url():
12
+ return "{{ base_url }}"
13
+
14
+ {% for scenario in scenarios %}
15
+
16
+ def {{ scenario.function_name }}(base_url):
17
+ """{{ scenario.docstring }}"""
18
+ response = {{ scenario.request_call }}
19
+ assert response.status_code in {{ scenario.expected_status }}
20
+ {% if scenario.response_schema %}
21
+ schema = {{ scenario.response_schema }}
22
+ jsonschema.validate(instance=response.json(), schema=schema)
23
+ {% endif %}
24
+ {% endfor %}
@@ -0,0 +1,44 @@
1
+ """Validation of OpenAPI specification files.
2
+
3
+ This module is responsible for reading an OpenAPI specification from disk
4
+ and verifying that it is structurally valid before any further processing.
5
+ """
6
+
7
+ from openapi_spec_validator import validate
8
+ from openapi_spec_validator.readers import read_from_filename
9
+ from openapi_spec_validator.validation.exceptions import OpenAPIValidationError
10
+
11
+
12
+ class SpecValidationError(Exception):
13
+ """Raised when an OpenAPI specification is invalid or cannot be read."""
14
+ pass
15
+
16
+
17
+ def load_and_validate(spec_path):
18
+ """Read and validate an OpenAPI specification file.
19
+
20
+ Args:
21
+ spec_path: Path to the OpenAPI specification file (JSON or YAML).
22
+
23
+ Returns:
24
+ The validated specification as a dictionary.
25
+
26
+ Raises:
27
+ SpecValidationError: If the file cannot be read or is not a valid
28
+ OpenAPI specification.
29
+ """
30
+ try:
31
+ spec_dict, base_uri = read_from_filename(spec_path)
32
+ except Exception as error:
33
+ raise SpecValidationError(
34
+ f"Could not read specification file '{spec_path}': {error}"
35
+ )
36
+
37
+ try:
38
+ validate(spec_dict)
39
+ except OpenAPIValidationError as error:
40
+ raise SpecValidationError(
41
+ f"Invalid OpenAPI specification '{spec_path}': {error.message}"
42
+ )
43
+
44
+ return spec_dict
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: swaggerforge
3
+ Version: 0.1.0
4
+ Summary: Automatic pytest test generation from OpenAPI (Swagger) specifications
5
+ Author: Viktor Pylypenko
6
+ License-Expression: MIT
7
+ Keywords: openapi,swagger,pytest,test generation,api testing
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: click>=8.0
12
+ Requires-Dist: prance>=23.6.21.0
13
+ Requires-Dist: openapi-core>=0.18
14
+ Requires-Dist: openapi-spec-validator>=0.7
15
+ Requires-Dist: jinja2>=3.0
16
+ Requires-Dist: requests>=2.28
17
+ Requires-Dist: jsonschema>=4.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=9.0; extra == "dev"
20
+ Requires-Dist: flake8>=7.0; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # SwaggerForge
24
+
25
+ Automatic pytest test generation from OpenAPI (Swagger) specifications.
26
+
27
+ SwaggerForge is a Python library and command-line tool that reads an OpenAPI
28
+ specification and generates ready-to-run pytest test files - one per resource
29
+ covering positive, negative, boundary, and boolean scenarios grounded in
30
+ established test-design techniques.
31
+
32
+ ## Features
33
+
34
+ - Reads OpenAPI 3.x specifications in JSON or YAML
35
+ - Resolves `$ref` references automatically
36
+ - Generates one pytest file per resource tag
37
+ - Produces six scenario types per endpoint where applicable:
38
+ - **Positive** >>> valid request, expects a 2xx response and validates the
39
+ response schema
40
+ - **Missing required field** >>> omits a required field, expects 400
41
+ - **Wrong data type** >>> sends a mistyped field, expects 400/422
42
+ - **Nonexistent resource** >>> requests an unlikely identifier, expects 404
43
+ - **Boundary values** >>> tests values at and just beyond declared
44
+ numeric/length limits (Boundary Value Analysis)
45
+ - **Boolean coverage** >>> exercises both `true` and `false` for boolean fields
46
+ - Deterministic output: the same specification always produces identical tests
47
+ - Generated files use session-scoped pytest fixtures and run with no manual edits
48
+
49
+ ## Requirements
50
+
51
+ - Python 3.10 or newer
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install swaggerforge
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ Generate tests from a specification, pointing at the base URL of the API
62
+ under test:
63
+
64
+ ```bash
65
+ swaggerforge generate --spec swagger.json --url http://localhost:8080
66
+ ```
67
+
68
+ This reads `swagger.json`, writes one `test_<resource>.py` file per resource
69
+ tag into the output directory (default: `tests_generated/`), and the files can
70
+ be run immediately:
71
+
72
+ ```bash
73
+ pytest tests_generated
74
+ ```
75
+
76
+ ### Options
77
+
78
+ | Option | Description | Default |
79
+ |------------|----------------------------------------------------|--------------------|
80
+ | `--spec` | Path to the OpenAPI specification (JSON or YAML) | *(required)* |
81
+ | `--url` | Base URL of the API under test | *(required)* |
82
+ | `--output` | Directory for the generated test files | `tests_generated` |
83
+
84
+ ## How it works
85
+
86
+ SwaggerForge runs a six-stage pipeline: the specification is validated, parsed
87
+ into an internal model (with `$ref`s resolved), turned into test scenarios
88
+ based on test-design techniques, rendered into pytest code via templates, and
89
+ written to per-resource files.
90
+
91
+ ## Limitations
92
+
93
+ - Targets OpenAPI 3.x with JSON request bodies
94
+ - Authentication is not yet handled (planned)
95
+ - Boundary tests require the specification to declare numeric/length constraints
96
+
97
+ ## License
98
+
99
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file.
@@ -0,0 +1,16 @@
1
+ swaggerforge/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ swaggerforge/__main__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ swaggerforge/cli.py,sha256=wJSm4NuITUncwgQTwbbpYx1KiNXtKu94y4aU-Oxwv6E,2048
4
+ swaggerforge/generator.py,sha256=B7Jj9Uq-yx02n41v0boUIH8eINz4cnfHb3flmSx25c0,14979
5
+ swaggerforge/models.py,sha256=rlPOYSqcZFsn8glUCG4T5Mi3XFl8BvWoEAW-tXYQrbY,1263
6
+ swaggerforge/output.py,sha256=t_0Dms4-2Gp96Swo15VAs3Mfc87OODFzh8Ee_1zJTcA,1662
7
+ swaggerforge/parser.py,sha256=r4vUzafeq6KCbIpVJec5QEB4FaOt_HBXsOXqsfwJ7_A,3545
8
+ swaggerforge/template.py,sha256=sy6yH7xqcUFVIsrffu5iteOvemheFxdvT26CwNMqaSo,3920
9
+ swaggerforge/validator.py,sha256=Cyn6uhC5O8Poihm_HsSZ-yk2TtSAWgPsg1wNI6Ek_F8,1373
10
+ swaggerforge/templates/test_file.py.j2,sha256=zkJS2WlzmnRcHDVj3ovBRZzp56BKDK1Ba903Vefb_oQ,754
11
+ swaggerforge-0.1.0.dist-info/licenses/LICENSE,sha256=xEuAv8EHV44TLaTEgveWUaVa_iXumSw-UY8kBHhqpTg,1092
12
+ swaggerforge-0.1.0.dist-info/METADATA,sha256=QYYLgV3TBl3Ln6EQeHUyLtbJA3p1HX9uPcIYFBYsUy8,3543
13
+ swaggerforge-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
+ swaggerforge-0.1.0.dist-info/entry_points.txt,sha256=KVzakVZtrieiY8HgXo_RVyZlTQUBfyJPB_jiUtuIZ6c,55
15
+ swaggerforge-0.1.0.dist-info/top_level.txt,sha256=ytRVBaDXndBGnlln9nkkxmYeIoHygfuV2-IO11mxnd4,13
16
+ swaggerforge-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ swaggerforge = swaggerforge.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Viktor Pylypenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ swaggerforge