swaggerforge 0.3.0__tar.gz → 0.3.1__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 (36) hide show
  1. {swaggerforge-0.3.0/src/swaggerforge.egg-info → swaggerforge-0.3.1}/PKG-INFO +67 -24
  2. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/README.md +64 -23
  3. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/pyproject.toml +2 -1
  4. swaggerforge-0.3.1/src/swaggerforge/auth.py +274 -0
  5. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/config.py +13 -0
  6. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/models.py +1 -1
  7. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/output.py +37 -5
  8. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/parser.py +7 -8
  9. swaggerforge-0.3.1/src/swaggerforge/template.py +215 -0
  10. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/templates/test_file.py.j2 +14 -3
  11. {swaggerforge-0.3.0 → swaggerforge-0.3.1/src/swaggerforge.egg-info}/PKG-INFO +67 -24
  12. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge.egg-info/requires.txt +3 -0
  13. swaggerforge-0.3.1/tests/test_auth.py +523 -0
  14. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/tests/test_config.py +76 -0
  15. swaggerforge-0.3.1/tests/test_e2e_auth.py +253 -0
  16. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/tests/test_output.py +86 -3
  17. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/tests/test_parser.py +43 -3
  18. swaggerforge-0.3.1/tests/test_template.py +290 -0
  19. swaggerforge-0.3.0/src/swaggerforge/auth.py +0 -143
  20. swaggerforge-0.3.0/src/swaggerforge/template.py +0 -132
  21. swaggerforge-0.3.0/tests/test_auth.py +0 -142
  22. swaggerforge-0.3.0/tests/test_e2e_auth.py +0 -109
  23. swaggerforge-0.3.0/tests/test_template.py +0 -138
  24. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/LICENSE +0 -0
  25. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/setup.cfg +0 -0
  26. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/__init__.py +0 -0
  27. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/__main__.py +0 -0
  28. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/cli.py +0 -0
  29. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/generator.py +0 -0
  30. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/validator.py +0 -0
  31. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge.egg-info/SOURCES.txt +0 -0
  32. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge.egg-info/dependency_links.txt +0 -0
  33. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge.egg-info/entry_points.txt +0 -0
  34. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge.egg-info/top_level.txt +0 -0
  35. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/tests/test_generator.py +0 -0
  36. {swaggerforge-0.3.0 → swaggerforge-0.3.1}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swaggerforge
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Automatic pytest test generation from OpenAPI (Swagger) specifications
5
5
  Author: Viktor Pylypenko
6
6
  License-Expression: MIT
@@ -20,6 +20,8 @@ Requires-Dist: pytest>=9.0; extra == "dev"
20
20
  Requires-Dist: flake8>=7.0; extra == "dev"
21
21
  Requires-Dist: pytest-cov>=7.0; extra == "dev"
22
22
  Requires-Dist: tox>=4.0; extra == "dev"
23
+ Provides-Extra: oauth2
24
+ Requires-Dist: requests-oauth2client>=1.6; extra == "oauth2"
23
25
  Dynamic: license-file
24
26
 
25
27
  # SwaggerForge
@@ -48,8 +50,9 @@ established test-design techniques.
48
50
  - Deterministic output: the same specification always produces identical tests
49
51
  - Generated files use session-scoped pytest fixtures and run with no manual edits
50
52
  - Optional `swaggerforge.toml` config file for project-level defaults
51
- - Authentication support: bearer, API key, and basic schemes, with
52
- environment-variable substitution for secrets
53
+ - Comprehensive authentication: bearer, basic, API key (header/query/cookie),
54
+ multiple-scheme AND/OR requirements, and OAuth2 / OpenID Connect (optional
55
+ extra), with environment-variable substitution for secrets
53
56
 
54
57
  ## Requirements
55
58
 
@@ -113,9 +116,10 @@ setting one makes test runs fail fast when the API is unreachable.
113
116
 
114
117
  ## Authentication
115
118
 
116
- When a specification declares security requirements, SwaggerForge embeds the
117
- matching authentication headers into the generated tests. Credentials are
118
- supplied through the config file, never taken from the specification itself.
119
+ When a specification declares security requirements, SwaggerForge configures
120
+ the generated tests to authenticate. Credentials are supplied through the
121
+ config file; the specification decides *which* scheme and *where* each value
122
+ goes, and the config supplies the secret values.
119
123
 
120
124
  ```toml
121
125
  # swaggerforge.toml
@@ -128,28 +132,65 @@ value = "${API_KEY}"
128
132
  [auth.basic]
129
133
  username = "${API_USER}"
130
134
  password = "${API_PASSWORD}"
135
+
136
+ [auth.oauth2]
137
+ client_id = "${OAUTH_CLIENT_ID}"
138
+ client_secret = "${OAUTH_CLIENT_SECRET}"
139
+ scope = "read write"
131
140
  ```
132
141
 
133
- Three scheme kinds are supported, matching the OpenAPI security scheme types:
142
+ Generated tests build a session-scoped `requests.Session` that carries the
143
+ resolved authentication, so every request is authenticated without repeating
144
+ credentials per call.
145
+
146
+ ### Supported schemes
147
+
148
+ | Config section | OpenAPI scheme | How it is applied |
149
+ |-----------------|---------------------------------------------|-----------------------------------------------------|
150
+ | `[auth.bearer]` | `http` bearer | `Authorization: Bearer <token>` header |
151
+ | `[auth.api_key]`| `apiKey` in header, query, or cookie | the name and location declared by the specification |
152
+ | `[auth.basic]` | `http` basic | `Authorization: Basic <base64>` header |
153
+ | `[auth.oauth2]` | `oauth2` client credentials, `openIdConnect`| a token fetched at runtime (see below) |
154
+
155
+ For an API key, both the **name** and the **location** (header, query, or
156
+ cookie) come from the specification; the config supplies only the secret
157
+ `value`.
158
+
159
+ ### Multiple schemes
160
+
161
+ OpenAPI can require several schemes at once (AND) or offer alternatives (OR).
162
+ SwaggerForge honours both: it uses the first alternative whose credentials are
163
+ fully configured, combining every scheme in an AND group. When endpoints in one
164
+ file need different authentication, the differing ones override the session per
165
+ request.
166
+
167
+ ### OAuth2 and OpenID Connect
168
+
169
+ OAuth2 and OIDC support is an optional extra, installed with:
170
+
171
+ ```bash
172
+ pip install swaggerforge[oauth2]
173
+ ```
134
174
 
135
- | Section | Applies to | Generated header |
136
- |-----------------|---------------------------------------------|-------------------------------------------|
137
- | `[auth.bearer]` | `http` bearer, and `oauth2` (pre-obtained token) | `Authorization: Bearer <token>` |
138
- | `[auth.api_key]`| `apiKey` in header | the header named by the specification |
139
- | `[auth.basic]` | `http` basic | `Authorization: Basic <base64>` |
175
+ For the **client credentials** flow, generated tests fetch a fresh access
176
+ token from the specification's `tokenUrl` at runtime (and refresh it on
177
+ expiry), so no token is ever embedded at generation time. **OpenID Connect**
178
+ schemes work the same way, discovering the token endpoint at runtime from the
179
+ specification's `openIdConnectUrl`. Both need `client_id` and `client_secret`
180
+ in `[auth.oauth2]`.
140
181
 
141
- For an API key, the header **name** comes from the specification's scheme
142
- definition; the config supplies only the secret `value`.
182
+ Interactive flows (OAuth2 **authorization code**) require a human to log in
183
+ through a browser and so cannot be fully automated. For these, SwaggerForge
184
+ generates a clearly marked placeholder in the session fixture where you paste a
185
+ token obtained manually.
143
186
 
144
- **Environment variables.** Any auth value may reference an environment
145
- variable with `${VAR}` syntax, so secrets stay out of the config file. A
146
- referenced variable that is not set is an error.
187
+ ### Environment variables and missing credentials
147
188
 
148
- **Unconfigured or unsupported schemes.** If a specification requires a scheme
149
- whose credentials are not configured, SwaggerForge prints a warning and
150
- generates the tests without that header, rather than failing. Schemes that are
151
- not yet supported - API keys in a query string or cookie, and OpenID Connect -
152
- are likewise skipped with a warning.
189
+ Any auth value may reference an environment variable with `${VAR}` syntax, so
190
+ secrets stay out of the config file; a referenced variable that is not set is
191
+ an error. If a specification requires a scheme whose credentials are not
192
+ configured, SwaggerForge prints a warning naming the scheme and generates the
193
+ tests without that authentication rather than failing.
153
194
 
154
195
  ## How it works
155
196
 
@@ -161,8 +202,10 @@ written to per-resource files.
161
202
  ## Limitations
162
203
 
163
204
  - Targets OpenAPI 3.x with JSON request bodies
164
- - Authentication covers bearer, API key (header), and basic schemes; API keys
165
- in query or cookie, full OAuth2 flows, and OpenID Connect are not yet handled
205
+ - Authentication covers bearer, basic, API key (header/query/cookie), OAuth2
206
+ client credentials, and OpenID Connect; interactive OAuth2 flows
207
+ (authorization code) generate a placeholder for a manually obtained token,
208
+ as they require a browser-based login that cannot be automated
166
209
  - Boundary tests require the specification to declare numeric/length constraints
167
210
 
168
211
  ## License
@@ -24,8 +24,9 @@ established test-design techniques.
24
24
  - Deterministic output: the same specification always produces identical tests
25
25
  - Generated files use session-scoped pytest fixtures and run with no manual edits
26
26
  - Optional `swaggerforge.toml` config file for project-level defaults
27
- - Authentication support: bearer, API key, and basic schemes, with
28
- environment-variable substitution for secrets
27
+ - Comprehensive authentication: bearer, basic, API key (header/query/cookie),
28
+ multiple-scheme AND/OR requirements, and OAuth2 / OpenID Connect (optional
29
+ extra), with environment-variable substitution for secrets
29
30
 
30
31
  ## Requirements
31
32
 
@@ -89,9 +90,10 @@ setting one makes test runs fail fast when the API is unreachable.
89
90
 
90
91
  ## Authentication
91
92
 
92
- When a specification declares security requirements, SwaggerForge embeds the
93
- matching authentication headers into the generated tests. Credentials are
94
- supplied through the config file, never taken from the specification itself.
93
+ When a specification declares security requirements, SwaggerForge configures
94
+ the generated tests to authenticate. Credentials are supplied through the
95
+ config file; the specification decides *which* scheme and *where* each value
96
+ goes, and the config supplies the secret values.
95
97
 
96
98
  ```toml
97
99
  # swaggerforge.toml
@@ -104,28 +106,65 @@ value = "${API_KEY}"
104
106
  [auth.basic]
105
107
  username = "${API_USER}"
106
108
  password = "${API_PASSWORD}"
109
+
110
+ [auth.oauth2]
111
+ client_id = "${OAUTH_CLIENT_ID}"
112
+ client_secret = "${OAUTH_CLIENT_SECRET}"
113
+ scope = "read write"
107
114
  ```
108
115
 
109
- Three scheme kinds are supported, matching the OpenAPI security scheme types:
116
+ Generated tests build a session-scoped `requests.Session` that carries the
117
+ resolved authentication, so every request is authenticated without repeating
118
+ credentials per call.
119
+
120
+ ### Supported schemes
121
+
122
+ | Config section | OpenAPI scheme | How it is applied |
123
+ |-----------------|---------------------------------------------|-----------------------------------------------------|
124
+ | `[auth.bearer]` | `http` bearer | `Authorization: Bearer <token>` header |
125
+ | `[auth.api_key]`| `apiKey` in header, query, or cookie | the name and location declared by the specification |
126
+ | `[auth.basic]` | `http` basic | `Authorization: Basic <base64>` header |
127
+ | `[auth.oauth2]` | `oauth2` client credentials, `openIdConnect`| a token fetched at runtime (see below) |
128
+
129
+ For an API key, both the **name** and the **location** (header, query, or
130
+ cookie) come from the specification; the config supplies only the secret
131
+ `value`.
132
+
133
+ ### Multiple schemes
134
+
135
+ OpenAPI can require several schemes at once (AND) or offer alternatives (OR).
136
+ SwaggerForge honours both: it uses the first alternative whose credentials are
137
+ fully configured, combining every scheme in an AND group. When endpoints in one
138
+ file need different authentication, the differing ones override the session per
139
+ request.
140
+
141
+ ### OAuth2 and OpenID Connect
142
+
143
+ OAuth2 and OIDC support is an optional extra, installed with:
144
+
145
+ ```bash
146
+ pip install swaggerforge[oauth2]
147
+ ```
110
148
 
111
- | Section | Applies to | Generated header |
112
- |-----------------|---------------------------------------------|-------------------------------------------|
113
- | `[auth.bearer]` | `http` bearer, and `oauth2` (pre-obtained token) | `Authorization: Bearer <token>` |
114
- | `[auth.api_key]`| `apiKey` in header | the header named by the specification |
115
- | `[auth.basic]` | `http` basic | `Authorization: Basic <base64>` |
149
+ For the **client credentials** flow, generated tests fetch a fresh access
150
+ token from the specification's `tokenUrl` at runtime (and refresh it on
151
+ expiry), so no token is ever embedded at generation time. **OpenID Connect**
152
+ schemes work the same way, discovering the token endpoint at runtime from the
153
+ specification's `openIdConnectUrl`. Both need `client_id` and `client_secret`
154
+ in `[auth.oauth2]`.
116
155
 
117
- For an API key, the header **name** comes from the specification's scheme
118
- definition; the config supplies only the secret `value`.
156
+ Interactive flows (OAuth2 **authorization code**) require a human to log in
157
+ through a browser and so cannot be fully automated. For these, SwaggerForge
158
+ generates a clearly marked placeholder in the session fixture where you paste a
159
+ token obtained manually.
119
160
 
120
- **Environment variables.** Any auth value may reference an environment
121
- variable with `${VAR}` syntax, so secrets stay out of the config file. A
122
- referenced variable that is not set is an error.
161
+ ### Environment variables and missing credentials
123
162
 
124
- **Unconfigured or unsupported schemes.** If a specification requires a scheme
125
- whose credentials are not configured, SwaggerForge prints a warning and
126
- generates the tests without that header, rather than failing. Schemes that are
127
- not yet supported - API keys in a query string or cookie, and OpenID Connect -
128
- are likewise skipped with a warning.
163
+ Any auth value may reference an environment variable with `${VAR}` syntax, so
164
+ secrets stay out of the config file; a referenced variable that is not set is
165
+ an error. If a specification requires a scheme whose credentials are not
166
+ configured, SwaggerForge prints a warning naming the scheme and generates the
167
+ tests without that authentication rather than failing.
129
168
 
130
169
  ## How it works
131
170
 
@@ -137,8 +176,10 @@ written to per-resource files.
137
176
  ## Limitations
138
177
 
139
178
  - Targets OpenAPI 3.x with JSON request bodies
140
- - Authentication covers bearer, API key (header), and basic schemes; API keys
141
- in query or cookie, full OAuth2 flows, and OpenID Connect are not yet handled
179
+ - Authentication covers bearer, basic, API key (header/query/cookie), OAuth2
180
+ client credentials, and OpenID Connect; interactive OAuth2 flows
181
+ (authorization code) generate a placeholder for a manually obtained token,
182
+ as they require a browser-based login that cannot be automated
142
183
  - Boundary tests require the specification to declare numeric/length constraints
143
184
 
144
185
  ## License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swaggerforge"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Automatic pytest test generation from OpenAPI (Swagger) specifications"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -39,3 +39,4 @@ testpaths = ["tests"]
39
39
 
40
40
  [project.optional-dependencies]
41
41
  dev = ["pytest>=9.0", "flake8>=7.0", "pytest-cov>=7.0", "tox>=4.0"]
42
+ oauth2 = ["requests-oauth2client>=1.6"]
@@ -0,0 +1,274 @@
1
+ """Resolution of authentication for generated tests.
2
+
3
+ Given an endpoint's declared security requirement, the specification's
4
+ security schemes, and the user's configured credentials, this module
5
+ decides which authentication headers to inject into the generated tests.
6
+
7
+ Supported schemes (v0.3.0): HTTP bearer, HTTP basic, and API key in a
8
+ header. OAuth2 is treated as bearer (the user supplies a pre-obtained
9
+ token). Other schemes - API key in query or cookie, OpenID Connect,
10
+ and any undefined scheme name - are skipped with a warning.
11
+ """
12
+
13
+ import base64
14
+ from typing import NamedTuple
15
+
16
+
17
+ class OAuth2Fixture(NamedTuple):
18
+ """The parameters needed to generate an OAuth2 client-credentials fixture.
19
+
20
+ Exactly one of ``token_url`` (a direct clientCredentials token endpoint
21
+ from the spec) or ``discovery_url`` (an OpenID Connect discovery
22
+ endpoint, resolved at runtime) is set. The client credentials and
23
+ optional scope come from the configuration. The generated test fetches
24
+ a token at runtime rather than embedding one.
25
+ """
26
+
27
+ token_url: str | None = None
28
+ client_id: str = ""
29
+ client_secret: str = ""
30
+ discovery_url: str | None = None
31
+ scope: str | None = None
32
+
33
+
34
+ class ResolvedAuth(NamedTuple):
35
+ """The outcome of resolving auth for one endpoint.
36
+
37
+ ``headers``, ``query_params`` and ``cookies`` hold the static
38
+ authentication values to inject, by location (each empty when it does
39
+ not apply). ``oauth2`` carries the parameters for a runtime
40
+ token-fetching fixture, or None. ``warning`` is a human-readable
41
+ message when a declared scheme was skipped, or None otherwise.
42
+ """
43
+
44
+ headers: dict = {}
45
+ query_params: dict = {}
46
+ cookies: dict = {}
47
+ oauth2: OAuth2Fixture | None = None
48
+ manual_auth: str | None = None
49
+ warning: str | None = None
50
+
51
+
52
+ _NO_AUTH = ResolvedAuth(headers={}, warning=None)
53
+
54
+
55
+ def resolve_auth(endpoint, security_schemes, auth_config):
56
+ """Resolve authentication for a single endpoint.
57
+
58
+ The endpoint's security is a list of alternatives (OR); each
59
+ alternative is a list of scheme names that must all be satisfied
60
+ together (AND). The first alternative whose schemes are all
61
+ resolvable is used, merging every scheme's contribution. If no
62
+ alternative is fully satisfiable, generation proceeds without
63
+ authentication and a warning is returned.
64
+
65
+ Args:
66
+ endpoint: The Endpoint whose security requirement is resolved.
67
+ security_schemes: The specification's securitySchemes definitions.
68
+ auth_config: The AuthConfig carrying user-supplied credentials.
69
+
70
+ Returns:
71
+ A ResolvedAuth with the values to inject and an optional warning.
72
+ """
73
+ if not endpoint.security:
74
+ return _NO_AUTH
75
+
76
+ for alternative in endpoint.security:
77
+ merged = _resolve_alternative(alternative, security_schemes, auth_config)
78
+ if merged is not None:
79
+ if merged.manual_auth is not None and merged.warning is None:
80
+ merged = merged._replace(
81
+ warning=(
82
+ f"Scheme '{merged.manual_auth}' uses an interactive "
83
+ f"OAuth2 flow that cannot be automated; a placeholder "
84
+ f"token is generated for you to complete manually."
85
+ )
86
+ )
87
+ return merged
88
+
89
+ scheme_names = sorted(
90
+ {name for alternative in endpoint.security for name in alternative}
91
+ )
92
+ listed = ", ".join(scheme_names) if scheme_names else "the declared schemes"
93
+ return ResolvedAuth(
94
+ warning=(
95
+ f"Could not satisfy security requirement(s) [{listed}] with the "
96
+ f"configured credentials; affected endpoints are generated "
97
+ f"without authentication."
98
+ ),
99
+ )
100
+
101
+
102
+ def _resolve_alternative(alternative, security_schemes, auth_config):
103
+ """Resolve one AND group, merging all schemes, or None if unsatisfiable.
104
+
105
+ An empty alternative (an empty security requirement in the spec) means
106
+ authentication is optional and resolves to no auth.
107
+ """
108
+ headers = {}
109
+ query_params = {}
110
+ cookies = {}
111
+ oauth2 = None
112
+ manual_auth = None
113
+ for scheme_name in alternative:
114
+ contribution = _resolve_scheme(
115
+ scheme_name, security_schemes, auth_config
116
+ )
117
+ if contribution is None:
118
+ return None
119
+ (
120
+ scheme_headers,
121
+ scheme_query,
122
+ scheme_cookies,
123
+ scheme_oauth2,
124
+ scheme_manual,
125
+ ) = contribution
126
+ headers.update(scheme_headers)
127
+ query_params.update(scheme_query)
128
+ cookies.update(scheme_cookies)
129
+ if scheme_oauth2 is not None:
130
+ oauth2 = scheme_oauth2
131
+ if scheme_manual is not None:
132
+ manual_auth = scheme_manual
133
+
134
+ return ResolvedAuth(
135
+ headers=headers,
136
+ query_params=query_params,
137
+ cookies=cookies,
138
+ oauth2=oauth2,
139
+ manual_auth=manual_auth,
140
+ )
141
+
142
+
143
+ def _resolve_scheme(scheme_name, security_schemes, auth_config):
144
+ """Resolve one scheme to a (headers, query_params, cookies, oauth2) tuple.
145
+
146
+ Returns None when the scheme is undefined, unsupported, or its
147
+ credentials are not configured - signalling that the enclosing
148
+ alternative cannot be satisfied.
149
+ """
150
+ scheme = security_schemes.get(scheme_name)
151
+ if scheme is None:
152
+ return None
153
+
154
+ scheme_type = scheme.get("type")
155
+
156
+ if scheme_type == "oauth2":
157
+ return _resolve_oauth2(scheme_name, scheme, auth_config)
158
+ if scheme_type == "openIdConnect":
159
+ return _resolve_oidc(scheme, auth_config)
160
+ if scheme_type == "http":
161
+ http_scheme = scheme.get("scheme", "").lower()
162
+ if http_scheme == "bearer":
163
+ return _resolve_bearer(auth_config)
164
+ if http_scheme == "basic":
165
+ return _resolve_basic(auth_config)
166
+ if scheme_type == "apiKey" and scheme.get("in") in (
167
+ "header",
168
+ "query",
169
+ "cookie",
170
+ ):
171
+ return _resolve_api_key(scheme, auth_config)
172
+
173
+ return None
174
+
175
+
176
+ def _resolve_bearer(auth_config):
177
+ """Return a bearer Authorization header, or None if unconfigured."""
178
+ bearer = auth_config.bearer
179
+ if bearer is None or not bearer.token:
180
+ return None
181
+ return ({"Authorization": f"Bearer {bearer.token}"}, {}, {}, None, None)
182
+
183
+
184
+ def _resolve_basic(auth_config):
185
+ """Return a basic Authorization header, or None if unconfigured."""
186
+ basic = auth_config.basic
187
+ if basic is None or not basic.username or not basic.password:
188
+ return None
189
+ raw = f"{basic.username}:{basic.password}".encode("utf-8")
190
+ encoded = base64.b64encode(raw).decode("ascii")
191
+ return ({"Authorization": f"Basic {encoded}"}, {}, {}, None, None)
192
+
193
+
194
+ def _resolve_api_key(scheme, auth_config):
195
+ """Return an API-key contribution by location, or None if unconfigured.
196
+
197
+ The key's name and location come from the spec scheme; the config
198
+ supplies only the value.
199
+ """
200
+ api_key = auth_config.api_key
201
+ if api_key is None or not api_key.value:
202
+ return None
203
+ key_name = scheme.get("name")
204
+ if not key_name:
205
+ return None
206
+
207
+ location = scheme.get("in")
208
+ if location == "query":
209
+ return ({}, {key_name: api_key.value}, {}, None, None)
210
+ if location == "cookie":
211
+ return ({}, {}, {key_name: api_key.value}, None, None)
212
+ return ({key_name: api_key.value}, {}, {}, None, None)
213
+
214
+
215
+ def _resolve_oauth2(scheme_name, scheme, auth_config):
216
+ """Resolve an oauth2 scheme.
217
+
218
+ The clientCredentials flow is fully automated (it needs the spec's
219
+ tokenUrl and the configured client id and secret). When only an
220
+ interactive flow such as authorizationCode is offered, it cannot be
221
+ automated, so a manual stub naming the scheme is produced for the user
222
+ to complete. Purely interactive-only flows with no authorizationCode
223
+ (implicit, password) are not supported and make the alternative
224
+ unsatisfiable.
225
+ """
226
+ flows = scheme.get("flows", {})
227
+
228
+ client_credentials = flows.get("clientCredentials")
229
+ if client_credentials is not None:
230
+ token_url = client_credentials.get("tokenUrl")
231
+ oauth2 = auth_config.oauth2
232
+ if (
233
+ token_url
234
+ and oauth2 is not None
235
+ and oauth2.client_id
236
+ and oauth2.client_secret
237
+ ):
238
+ fixture = OAuth2Fixture(
239
+ token_url=token_url,
240
+ client_id=oauth2.client_id,
241
+ client_secret=oauth2.client_secret,
242
+ scope=oauth2.scope,
243
+ )
244
+ return ({}, {}, {}, fixture, None)
245
+
246
+ if "authorizationCode" in flows:
247
+ return ({}, {}, {}, None, scheme_name)
248
+
249
+ return None
250
+
251
+
252
+ def _resolve_oidc(scheme, auth_config):
253
+ """Resolve an openIdConnect scheme to a discovery-based OAuth2 fixture.
254
+
255
+ The generated test performs OpenID Connect discovery at runtime using
256
+ the scheme's openIdConnectUrl, then obtains a token via the client
257
+ credentials grant. It therefore needs configured client credentials;
258
+ without them the scheme is not satisfiable.
259
+ """
260
+ discovery_url = scheme.get("openIdConnectUrl")
261
+ if not discovery_url:
262
+ return None
263
+
264
+ oauth2 = auth_config.oauth2
265
+ if oauth2 is None or not oauth2.client_id or not oauth2.client_secret:
266
+ return None
267
+
268
+ fixture = OAuth2Fixture(
269
+ discovery_url=discovery_url,
270
+ client_id=oauth2.client_id,
271
+ client_secret=oauth2.client_secret,
272
+ scope=oauth2.scope,
273
+ )
274
+ return ({}, {}, {}, fixture, None)
@@ -48,12 +48,24 @@ class BasicAuth:
48
48
  password: str | None = None
49
49
 
50
50
 
51
+ @dataclass
52
+ class OAuth2Auth:
53
+ """OAuth2 client-credentials: client id, secret, and optional scope.
54
+
55
+ The token endpoint URL comes from the specification, not the config.
56
+ """
57
+ client_id: str | None = None
58
+ client_secret: str | None = None
59
+ scope: str | None = None
60
+
61
+
51
62
  @dataclass
52
63
  class AuthConfig:
53
64
  """Authentication credentials, keyed by scheme kind. All optional."""
54
65
  bearer: BearerAuth | None = None
55
66
  api_key: ApiKeyAuth | None = None
56
67
  basic: BasicAuth | None = None
68
+ oauth2: OAuth2Auth | None = None
57
69
 
58
70
 
59
71
  @dataclass
@@ -115,6 +127,7 @@ _AUTH_SCHEMES = {
115
127
  "bearer": BearerAuth,
116
128
  "api_key": ApiKeyAuth,
117
129
  "basic": BasicAuth,
130
+ "oauth2": OAuth2Auth,
118
131
  }
119
132
 
120
133
 
@@ -30,7 +30,7 @@ class Endpoint:
30
30
  request_body: dict | None = None
31
31
  responses: list[str] = field(default_factory=list)
32
32
  response_schema: dict | None = None
33
- security: list[str] = field(default_factory=list)
33
+ security: list[list[str]] = field(default_factory=list)
34
34
 
35
35
 
36
36
  @dataclass
@@ -61,15 +61,36 @@ def write_test_files(
61
61
  warnings = []
62
62
  for tag in sorted(grouped):
63
63
  group = grouped[tag]
64
- representative = group[0].endpoint
65
- resolved = resolve_auth(representative, security_schemes, auth_config)
66
- if resolved.warning and resolved.warning not in warnings:
67
- warnings.append(resolved.warning)
64
+
65
+ # The session carries the first endpoint's auth (the base); any
66
+ # scenario whose endpoint resolves to different auth overrides it
67
+ # per call.
68
+ base = resolve_auth(group[0].endpoint, security_schemes, auth_config)
69
+ overrides = []
70
+ for scenario in group:
71
+ resolved = resolve_auth(
72
+ scenario.endpoint, security_schemes, auth_config
73
+ )
74
+ if resolved.warning and resolved.warning not in warnings:
75
+ warnings.append(resolved.warning)
76
+ if _same_auth(resolved, base):
77
+ overrides.append(None)
78
+ else:
79
+ overrides.append(resolved)
68
80
 
69
81
  filename = f"test_{_sanitize(tag)}.py"
70
82
  file_path = output_path / filename
71
83
  content = render_test_file(
72
- filename, base_url, group, timeout, resolved.headers
84
+ filename,
85
+ base_url,
86
+ group,
87
+ timeout,
88
+ base.headers,
89
+ base.query_params,
90
+ base.cookies,
91
+ overrides,
92
+ base.oauth2,
93
+ base.manual_auth,
73
94
  )
74
95
  file_path.write_text(content, encoding="utf-8")
75
96
  written.append(str(file_path))
@@ -77,6 +98,17 @@ def write_test_files(
77
98
  return WriteResult(written=written, warnings=warnings)
78
99
 
79
100
 
101
+ def _same_auth(a, b):
102
+ """True when two ResolvedAuth carry identical injected values."""
103
+ return (
104
+ a.headers == b.headers
105
+ and a.query_params == b.query_params
106
+ and a.cookies == b.cookies
107
+ and a.oauth2 == b.oauth2
108
+ and a.manual_auth == b.manual_auth
109
+ )
110
+
111
+
80
112
  def _group_by_tag(scenarios):
81
113
  """Group scenarios into a dict keyed by their endpoint's tag."""
82
114
  grouped = {}