swaggerforge 0.3.1__tar.gz → 0.4.0__tar.gz

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.
Files changed (38) hide show
  1. {swaggerforge-0.3.1/src/swaggerforge.egg-info → swaggerforge-0.4.0}/PKG-INFO +91 -5
  2. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/README.md +90 -4
  3. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/pyproject.toml +1 -1
  4. swaggerforge-0.4.0/src/swaggerforge/dependencies.py +129 -0
  5. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge/generator.py +255 -24
  6. swaggerforge-0.4.0/src/swaggerforge/models.py +85 -0
  7. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge/parser.py +79 -5
  8. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge/template.py +171 -4
  9. swaggerforge-0.4.0/src/swaggerforge/templates/test_file.py.j2 +96 -0
  10. {swaggerforge-0.3.1 → swaggerforge-0.4.0/src/swaggerforge.egg-info}/PKG-INFO +91 -5
  11. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge.egg-info/SOURCES.txt +3 -0
  12. swaggerforge-0.4.0/tests/test_dependencies.py +218 -0
  13. swaggerforge-0.4.0/tests/test_e2e_stateful.py +207 -0
  14. swaggerforge-0.4.0/tests/test_generator.py +816 -0
  15. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/tests/test_parser.py +112 -0
  16. swaggerforge-0.4.0/tests/test_template.py +548 -0
  17. swaggerforge-0.3.1/src/swaggerforge/models.py +0 -47
  18. swaggerforge-0.3.1/src/swaggerforge/templates/test_file.py.j2 +0 -40
  19. swaggerforge-0.3.1/tests/test_generator.py +0 -332
  20. swaggerforge-0.3.1/tests/test_template.py +0 -290
  21. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/LICENSE +0 -0
  22. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/setup.cfg +0 -0
  23. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge/__init__.py +0 -0
  24. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge/__main__.py +0 -0
  25. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge/auth.py +0 -0
  26. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge/cli.py +0 -0
  27. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge/config.py +0 -0
  28. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge/output.py +0 -0
  29. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge/validator.py +0 -0
  30. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge.egg-info/dependency_links.txt +0 -0
  31. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge.egg-info/entry_points.txt +0 -0
  32. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge.egg-info/requires.txt +0 -0
  33. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/src/swaggerforge.egg-info/top_level.txt +0 -0
  34. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/tests/test_auth.py +0 -0
  35. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/tests/test_config.py +0 -0
  36. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/tests/test_e2e_auth.py +0 -0
  37. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/tests/test_output.py +0 -0
  38. {swaggerforge-0.3.1 → swaggerforge-0.4.0}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swaggerforge
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Automatic pytest test generation from OpenAPI (Swagger) specifications
5
5
  Author: Viktor Pylypenko
6
6
  License-Expression: MIT
@@ -43,12 +43,18 @@ established test-design techniques.
43
43
  response schema
44
44
  - **Missing required field** >>> omits a required field, expects 400
45
45
  - **Wrong data type** >>> sends a mistyped field, expects 400/422
46
- - **Nonexistent resource** >>> requests an unlikely identifier, expects 404
46
+ - **Nonexistent resource** >>> requests a resource that was created and then
47
+ deleted, so its absence is guaranteed (or, when the API offers no delete,
48
+ an unlikely identifier); expects 404
47
49
  - **Boundary values** >>> tests values at and just beyond declared
48
50
  numeric/length limits (Boundary Value Analysis)
49
51
  - **Boolean coverage** >>> exercises both `true` and `false` for boolean fields
52
+ - Stateful tests: operations on an existing resource run against one the test
53
+ creates itself and removes afterwards, instead of a guessed identifier
54
+ - Valid request data built from the specification's own examples, defaults,
55
+ enums and constraints
50
56
  - Deterministic output: the same specification always produces identical tests
51
- - Generated files use session-scoped pytest fixtures and run with no manual edits
57
+ - Generated files use pytest fixtures and run with no manual edits
52
58
  - Optional `swaggerforge.toml` config file for project-level defaults
53
59
  - Comprehensive authentication: bearer, basic, API key (header/query/cookie),
54
60
  multiple-scheme AND/OR requirements, and OAuth2 / OpenID Connect (optional
@@ -192,12 +198,79 @@ an error. If a specification requires a scheme whose credentials are not
192
198
  configured, SwaggerForge prints a warning naming the scheme and generates the
193
199
  tests without that authentication rather than failing.
194
200
 
201
+ ## Stateful tests
202
+
203
+ Endpoints that act on an existing resource - `GET`, `PUT`, `PATCH` or
204
+ `DELETE` on `/pets/{petId}` - need a resource that really exists. SwaggerForge
205
+ detects the operation that creates it and generates fixtures that create the
206
+ resource before a test and remove it afterwards.
207
+
208
+ A create operation is recognised by REST convention: a `POST` on a collection
209
+ path (`/pets`) produces resources addressed by that path plus one parameter
210
+ (`/pets/{petId}`). The new resource's identifier is taken from the response
211
+ field named like the path parameter, or else from `id`. When the
212
+ specification declares explicit OpenAPI `links`, they take priority.
213
+
214
+ For each such resource, generated files contain two fixtures:
215
+
216
+ - `created_<resource>` creates the resource, yields its identifier (read from
217
+ the response body, or from the `Location` header) and deletes it after the
218
+ test
219
+ - `deleted_<resource>` creates the resource and deletes it immediately, giving
220
+ the nonexistent-resource test an identifier that is guaranteed not to exist
221
+
222
+ ```python
223
+ @pytest.fixture
224
+ def created_pet(base_url, api_session):
225
+ response = api_session.post(f"{base_url}/pet", json={'id': uuid.uuid4().int >> 80, 'name': 'doggie', 'photoUrls': ['string']})
226
+ assert response.status_code in [200], (
227
+ f"Creating pet failed: "
228
+ f"{response.status_code} {response.text}"
229
+ )
230
+ resource_id = _extract_id(response, 'id')
231
+ yield resource_id
232
+ try:
233
+ api_session.delete(f"{base_url}/pet/{resource_id}")
234
+ except requests.RequestException:
235
+ pass
236
+
237
+
238
+ def test_get_pet_by_id_success(base_url, api_session, created_pet):
239
+ """GET /pet/{petId} - positive scenario"""
240
+ response = api_session.get(f"{base_url}/pet/{created_pet}")
241
+ assert response.status_code in [200]
242
+ ```
243
+
244
+ *(Abridged from a file generated for the Swagger Petstore specification.)*
245
+
246
+ Every scenario on such an endpoint uses the created resource - negative
247
+ scenarios included, so a 404 for a missing resource cannot hide the 400 a
248
+ validation test expects. A top-level `id` in the create request is sent as a
249
+ fresh unique value on every run, so tests never share or collide on one
250
+ resource; whether the server keeps that value or assigns its own, the fixture
251
+ reads the actual identifier from the response. The generated files themselves
252
+ stay identical for the same specification - only the runtime data varies.
253
+
254
+ Endpoints without a recognisable create operation are generated exactly as
255
+ before, with example identifiers.
256
+
257
+ ## Request data
258
+
259
+ Request bodies and parameters are built deterministically from the
260
+ specification, without random data. For each value, the first available
261
+ source is used: `const`, then `example` (or the first of `examples`), then
262
+ `default`, then the first `enum` member, and finally a placeholder matching
263
+ the declared type and format (for example `user@example.com` for an `email`).
264
+ Placeholder strings are fitted to `minLength` and `maxLength`. Properties
265
+ marked `readOnly` are left out of requests, as OpenAPI prescribes.
266
+
195
267
  ## How it works
196
268
 
197
269
  SwaggerForge runs a six-stage pipeline: the specification is validated, parsed
198
270
  into an internal model (with `$ref`s resolved), turned into test scenarios
199
- based on test-design techniques, rendered into pytest code via templates, and
200
- written to per-resource files.
271
+ based on test-design techniques - with dependencies between operations
272
+ inferred, so stateful scenarios can share created resources - rendered into
273
+ pytest code via templates, and written to per-resource files.
201
274
 
202
275
  ## Limitations
203
276
 
@@ -207,6 +280,19 @@ written to per-resource files.
207
280
  (authorization code) generate a placeholder for a manually obtained token,
208
281
  as they require a browser-based login that cannot be automated
209
282
  - Boundary tests require the specification to declare numeric/length constraints
283
+ - Stateful tests cover single-level resources (a collection and its items);
284
+ nested chains such as `/owners/{ownerId}/pets/{petId}` keep example
285
+ identifiers, and only `links` passing a top-level response field by
286
+ `operationId` are used
287
+ - APIs that soft-delete (a deleted resource still answers 200) fail the
288
+ nonexistent-resource test - a reflection of the API's semantics
289
+ - Resource fixtures use the file's session authentication; a create operation
290
+ that needs different credentials than the file's default is not overridden
291
+ - Only a top-level field named `id` is treated as a server-side surrogate key;
292
+ a differently named one is sent with its example value
293
+ - An identifier read from a `Location` header arrives as a string
294
+ - The create operation's own positive test does not remove the resource it
295
+ creates
210
296
 
211
297
  ## License
212
298
 
@@ -17,12 +17,18 @@ established test-design techniques.
17
17
  response schema
18
18
  - **Missing required field** >>> omits a required field, expects 400
19
19
  - **Wrong data type** >>> sends a mistyped field, expects 400/422
20
- - **Nonexistent resource** >>> requests an unlikely identifier, expects 404
20
+ - **Nonexistent resource** >>> requests a resource that was created and then
21
+ deleted, so its absence is guaranteed (or, when the API offers no delete,
22
+ an unlikely identifier); expects 404
21
23
  - **Boundary values** >>> tests values at and just beyond declared
22
24
  numeric/length limits (Boundary Value Analysis)
23
25
  - **Boolean coverage** >>> exercises both `true` and `false` for boolean fields
26
+ - Stateful tests: operations on an existing resource run against one the test
27
+ creates itself and removes afterwards, instead of a guessed identifier
28
+ - Valid request data built from the specification's own examples, defaults,
29
+ enums and constraints
24
30
  - Deterministic output: the same specification always produces identical tests
25
- - Generated files use session-scoped pytest fixtures and run with no manual edits
31
+ - Generated files use pytest fixtures and run with no manual edits
26
32
  - Optional `swaggerforge.toml` config file for project-level defaults
27
33
  - Comprehensive authentication: bearer, basic, API key (header/query/cookie),
28
34
  multiple-scheme AND/OR requirements, and OAuth2 / OpenID Connect (optional
@@ -166,12 +172,79 @@ an error. If a specification requires a scheme whose credentials are not
166
172
  configured, SwaggerForge prints a warning naming the scheme and generates the
167
173
  tests without that authentication rather than failing.
168
174
 
175
+ ## Stateful tests
176
+
177
+ Endpoints that act on an existing resource - `GET`, `PUT`, `PATCH` or
178
+ `DELETE` on `/pets/{petId}` - need a resource that really exists. SwaggerForge
179
+ detects the operation that creates it and generates fixtures that create the
180
+ resource before a test and remove it afterwards.
181
+
182
+ A create operation is recognised by REST convention: a `POST` on a collection
183
+ path (`/pets`) produces resources addressed by that path plus one parameter
184
+ (`/pets/{petId}`). The new resource's identifier is taken from the response
185
+ field named like the path parameter, or else from `id`. When the
186
+ specification declares explicit OpenAPI `links`, they take priority.
187
+
188
+ For each such resource, generated files contain two fixtures:
189
+
190
+ - `created_<resource>` creates the resource, yields its identifier (read from
191
+ the response body, or from the `Location` header) and deletes it after the
192
+ test
193
+ - `deleted_<resource>` creates the resource and deletes it immediately, giving
194
+ the nonexistent-resource test an identifier that is guaranteed not to exist
195
+
196
+ ```python
197
+ @pytest.fixture
198
+ def created_pet(base_url, api_session):
199
+ response = api_session.post(f"{base_url}/pet", json={'id': uuid.uuid4().int >> 80, 'name': 'doggie', 'photoUrls': ['string']})
200
+ assert response.status_code in [200], (
201
+ f"Creating pet failed: "
202
+ f"{response.status_code} {response.text}"
203
+ )
204
+ resource_id = _extract_id(response, 'id')
205
+ yield resource_id
206
+ try:
207
+ api_session.delete(f"{base_url}/pet/{resource_id}")
208
+ except requests.RequestException:
209
+ pass
210
+
211
+
212
+ def test_get_pet_by_id_success(base_url, api_session, created_pet):
213
+ """GET /pet/{petId} - positive scenario"""
214
+ response = api_session.get(f"{base_url}/pet/{created_pet}")
215
+ assert response.status_code in [200]
216
+ ```
217
+
218
+ *(Abridged from a file generated for the Swagger Petstore specification.)*
219
+
220
+ Every scenario on such an endpoint uses the created resource - negative
221
+ scenarios included, so a 404 for a missing resource cannot hide the 400 a
222
+ validation test expects. A top-level `id` in the create request is sent as a
223
+ fresh unique value on every run, so tests never share or collide on one
224
+ resource; whether the server keeps that value or assigns its own, the fixture
225
+ reads the actual identifier from the response. The generated files themselves
226
+ stay identical for the same specification - only the runtime data varies.
227
+
228
+ Endpoints without a recognisable create operation are generated exactly as
229
+ before, with example identifiers.
230
+
231
+ ## Request data
232
+
233
+ Request bodies and parameters are built deterministically from the
234
+ specification, without random data. For each value, the first available
235
+ source is used: `const`, then `example` (or the first of `examples`), then
236
+ `default`, then the first `enum` member, and finally a placeholder matching
237
+ the declared type and format (for example `user@example.com` for an `email`).
238
+ Placeholder strings are fitted to `minLength` and `maxLength`. Properties
239
+ marked `readOnly` are left out of requests, as OpenAPI prescribes.
240
+
169
241
  ## How it works
170
242
 
171
243
  SwaggerForge runs a six-stage pipeline: the specification is validated, parsed
172
244
  into an internal model (with `$ref`s resolved), turned into test scenarios
173
- based on test-design techniques, rendered into pytest code via templates, and
174
- written to per-resource files.
245
+ based on test-design techniques - with dependencies between operations
246
+ inferred, so stateful scenarios can share created resources - rendered into
247
+ pytest code via templates, and written to per-resource files.
175
248
 
176
249
  ## Limitations
177
250
 
@@ -181,6 +254,19 @@ written to per-resource files.
181
254
  (authorization code) generate a placeholder for a manually obtained token,
182
255
  as they require a browser-based login that cannot be automated
183
256
  - Boundary tests require the specification to declare numeric/length constraints
257
+ - Stateful tests cover single-level resources (a collection and its items);
258
+ nested chains such as `/owners/{ownerId}/pets/{petId}` keep example
259
+ identifiers, and only `links` passing a top-level response field by
260
+ `operationId` are used
261
+ - APIs that soft-delete (a deleted resource still answers 200) fail the
262
+ nonexistent-resource test - a reflection of the API's semantics
263
+ - Resource fixtures use the file's session authentication; a create operation
264
+ that needs different credentials than the file's default is not overridden
265
+ - Only a top-level field named `id` is treated as a server-side surrogate key;
266
+ a differently named one is sent with its example value
267
+ - An identifier read from a `Location` header arrives as a string
268
+ - The create operation's own positive test does not remove the resource it
269
+ creates
184
270
 
185
271
  ## License
186
272
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swaggerforge"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "Automatic pytest test generation from OpenAPI (Swagger) specifications"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,129 @@
1
+ """Inference of resource dependencies between OpenAPI operations.
2
+
3
+ Identifies create operations whose responses yield identifiers consumed by
4
+ operations on the corresponding item path. Explicit OpenAPI links on the
5
+ create's response take priority; otherwise inference follows the REST convention
6
+ that a POST on a collection path creates a resource addressed by the
7
+ collection path plus one path parameter. Only single-level relationships
8
+ are inferred; a POST on a path that already contains a parameter is not
9
+ treated as a producer.
10
+ """
11
+
12
+ import re
13
+
14
+ from swaggerforge.models import ResourceProducer
15
+
16
+ # matches a path segment consisting of exactly one parameter, e.g. "{petId}"
17
+ _PARAM_SEGMENT = re.compile(r"^\{([^{}/]+)\}$")
18
+
19
+ # finds every path parameter in a path, e.g. "/items/{itemId}" -> ["itemId"]
20
+ _PATH_PARAMS = re.compile(r"\{([^{}/]+)\}")
21
+
22
+
23
+ def find_producers(endpoints):
24
+ """Infer resource producers from a list of endpoints.
25
+
26
+ Args:
27
+ endpoints: A list of Endpoint objects from the parser.
28
+
29
+ Returns:
30
+ A list of ResourceProducer objects, in specification order.
31
+ """
32
+ by_path = {}
33
+ by_operation = {}
34
+ for endpoint in endpoints:
35
+ by_path.setdefault(endpoint.path, {})[endpoint.method] = endpoint
36
+ if endpoint.operation_id:
37
+ by_operation[endpoint.operation_id] = endpoint
38
+
39
+ producers = []
40
+ for path, methods in by_path.items():
41
+ create = methods.get("post")
42
+ if create is None or "{" in path:
43
+ continue
44
+ producer = (
45
+ _producer_from_links(path, create, by_path, by_operation)
46
+ or _producer_for(path, create, by_path)
47
+ )
48
+ if producer is not None:
49
+ producers.append(producer)
50
+ return producers
51
+
52
+
53
+ def _producer_from_links(collection_path, create, by_path, by_operation):
54
+ """Build a producer from the create's explicit OpenAPI links, or None.
55
+
56
+ A link is usable when its target operation exists and its path has
57
+ exactly one path parameter, which the link supplies from the response
58
+ body. Explicit links are the specification's own statement of the
59
+ relationship, so they take priority over path inference.
60
+ """
61
+ for target_id, parameters in create.links.items():
62
+ target = by_operation.get(target_id)
63
+ if target is None:
64
+ continue
65
+ params = _PATH_PARAMS.findall(target.path)
66
+ if len(params) != 1 or params[0] not in parameters:
67
+ continue
68
+ param = params[0]
69
+ return ResourceProducer(
70
+ resource=_resource_name(collection_path),
71
+ create=create,
72
+ item_path=target.path,
73
+ path_param=param,
74
+ id_field=parameters[param],
75
+ delete=by_path.get(target.path, {}).get("delete"),
76
+ )
77
+ return None
78
+
79
+
80
+ def _producer_for(collection_path, create, by_path):
81
+ """Build the producer for a collection POST, or None if none applies."""
82
+ for path, methods in by_path.items():
83
+ param = _single_param_child(collection_path, path)
84
+ if param is None:
85
+ continue
86
+ id_field = _id_field(create.response_schema, param)
87
+ if id_field is None:
88
+ continue
89
+ return ResourceProducer(
90
+ resource=_resource_name(collection_path),
91
+ create=create,
92
+ item_path=path,
93
+ path_param=param,
94
+ id_field=id_field,
95
+ delete=methods.get("delete"),
96
+ )
97
+ return None
98
+
99
+
100
+ def _single_param_child(parent, path):
101
+ """Return the parameter name if path is parent plus one parameter."""
102
+ prefix = parent.rstrip("/") + "/"
103
+ if not path.startswith(prefix):
104
+ return None
105
+ match = _PARAM_SEGMENT.match(path[len(prefix):])
106
+ return match.group(1) if match else None
107
+
108
+
109
+ def _id_field(response_schema, param):
110
+ """Pick the response field holding the identifier.
111
+
112
+ A field named exactly like the path parameter wins; otherwise a field
113
+ named 'id' is used. Returns None when neither exists.
114
+ """
115
+ if not isinstance(response_schema, dict):
116
+ return None
117
+ properties = response_schema.get("properties", {})
118
+ if param in properties:
119
+ return param
120
+ if "id" in properties:
121
+ return "id"
122
+ return None
123
+
124
+
125
+ def _resource_name(collection_path):
126
+ """Derive a fixture-safe resource name from the last path segment."""
127
+ segment = collection_path.rstrip("/").rsplit("/", 1)[-1]
128
+ name = re.sub(r"[^a-zA-Z0-9_]", "_", segment).lower()
129
+ return name or "resource"