swaggerforge 0.2.0__tar.gz → 0.3.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.
- {swaggerforge-0.2.0/src/swaggerforge.egg-info → swaggerforge-0.3.0}/PKG-INFO +45 -2
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/README.md +44 -1
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/pyproject.toml +1 -1
- swaggerforge-0.3.0/src/swaggerforge/auth.py +143 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/cli.py +15 -4
- swaggerforge-0.3.0/src/swaggerforge/config.py +206 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/models.py +1 -0
- swaggerforge-0.3.0/src/swaggerforge/output.py +91 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/parser.py +36 -3
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/template.py +12 -1
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/templates/test_file.py.j2 +1 -1
- {swaggerforge-0.2.0 → swaggerforge-0.3.0/src/swaggerforge.egg-info}/PKG-INFO +45 -2
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge.egg-info/SOURCES.txt +3 -0
- swaggerforge-0.3.0/tests/test_auth.py +142 -0
- swaggerforge-0.3.0/tests/test_config.py +332 -0
- swaggerforge-0.3.0/tests/test_e2e_auth.py +109 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/tests/test_generator.py +4 -4
- swaggerforge-0.3.0/tests/test_output.py +112 -0
- swaggerforge-0.3.0/tests/test_parser.py +200 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/tests/test_template.py +42 -1
- swaggerforge-0.2.0/src/swaggerforge/config.py +0 -102
- swaggerforge-0.2.0/src/swaggerforge/output.py +0 -53
- swaggerforge-0.2.0/tests/test_config.py +0 -120
- swaggerforge-0.2.0/tests/test_output.py +0 -46
- swaggerforge-0.2.0/tests/test_parser.py +0 -79
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/LICENSE +0 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/setup.cfg +0 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/__init__.py +0 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/__main__.py +0 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/generator.py +0 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/validator.py +0 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge.egg-info/dependency_links.txt +0 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge.egg-info/entry_points.txt +0 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge.egg-info/requires.txt +0 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge.egg-info/top_level.txt +0 -0
- {swaggerforge-0.2.0 → swaggerforge-0.3.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
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Automatic pytest test generation from OpenAPI (Swagger) specifications
|
|
5
5
|
Author: Viktor Pylypenko
|
|
6
6
|
License-Expression: MIT
|
|
@@ -48,6 +48,8 @@ established test-design techniques.
|
|
|
48
48
|
- Deterministic output: the same specification always produces identical tests
|
|
49
49
|
- Generated files use session-scoped pytest fixtures and run with no manual edits
|
|
50
50
|
- 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
|
|
51
53
|
|
|
52
54
|
## Requirements
|
|
53
55
|
|
|
@@ -109,6 +111,46 @@ Values given on the command line always take precedence over the config file.
|
|
|
109
111
|
Without a `timeout`, generated tests place no time limit on requests -
|
|
110
112
|
setting one makes test runs fail fast when the API is unreachable.
|
|
111
113
|
|
|
114
|
+
## Authentication
|
|
115
|
+
|
|
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
|
+
|
|
120
|
+
```toml
|
|
121
|
+
# swaggerforge.toml
|
|
122
|
+
[auth.bearer]
|
|
123
|
+
token = "${API_TOKEN}"
|
|
124
|
+
|
|
125
|
+
[auth.api_key]
|
|
126
|
+
value = "${API_KEY}"
|
|
127
|
+
|
|
128
|
+
[auth.basic]
|
|
129
|
+
username = "${API_USER}"
|
|
130
|
+
password = "${API_PASSWORD}"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Three scheme kinds are supported, matching the OpenAPI security scheme types:
|
|
134
|
+
|
|
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>` |
|
|
140
|
+
|
|
141
|
+
For an API key, the header **name** comes from the specification's scheme
|
|
142
|
+
definition; the config supplies only the secret `value`.
|
|
143
|
+
|
|
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.
|
|
147
|
+
|
|
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.
|
|
153
|
+
|
|
112
154
|
## How it works
|
|
113
155
|
|
|
114
156
|
SwaggerForge runs a six-stage pipeline: the specification is validated, parsed
|
|
@@ -119,7 +161,8 @@ written to per-resource files.
|
|
|
119
161
|
## Limitations
|
|
120
162
|
|
|
121
163
|
- Targets OpenAPI 3.x with JSON request bodies
|
|
122
|
-
- Authentication
|
|
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
|
|
123
166
|
- Boundary tests require the specification to declare numeric/length constraints
|
|
124
167
|
|
|
125
168
|
## License
|
|
@@ -24,6 +24,8 @@ 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
29
|
|
|
28
30
|
## Requirements
|
|
29
31
|
|
|
@@ -85,6 +87,46 @@ Values given on the command line always take precedence over the config file.
|
|
|
85
87
|
Without a `timeout`, generated tests place no time limit on requests -
|
|
86
88
|
setting one makes test runs fail fast when the API is unreachable.
|
|
87
89
|
|
|
90
|
+
## Authentication
|
|
91
|
+
|
|
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.
|
|
95
|
+
|
|
96
|
+
```toml
|
|
97
|
+
# swaggerforge.toml
|
|
98
|
+
[auth.bearer]
|
|
99
|
+
token = "${API_TOKEN}"
|
|
100
|
+
|
|
101
|
+
[auth.api_key]
|
|
102
|
+
value = "${API_KEY}"
|
|
103
|
+
|
|
104
|
+
[auth.basic]
|
|
105
|
+
username = "${API_USER}"
|
|
106
|
+
password = "${API_PASSWORD}"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Three scheme kinds are supported, matching the OpenAPI security scheme types:
|
|
110
|
+
|
|
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>` |
|
|
116
|
+
|
|
117
|
+
For an API key, the header **name** comes from the specification's scheme
|
|
118
|
+
definition; the config supplies only the secret `value`.
|
|
119
|
+
|
|
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.
|
|
123
|
+
|
|
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.
|
|
129
|
+
|
|
88
130
|
## How it works
|
|
89
131
|
|
|
90
132
|
SwaggerForge runs a six-stage pipeline: the specification is validated, parsed
|
|
@@ -95,7 +137,8 @@ written to per-resource files.
|
|
|
95
137
|
## Limitations
|
|
96
138
|
|
|
97
139
|
- Targets OpenAPI 3.x with JSON request bodies
|
|
98
|
-
- Authentication
|
|
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
|
|
99
142
|
- Boundary tests require the specification to declare numeric/length constraints
|
|
100
143
|
|
|
101
144
|
## License
|
|
@@ -0,0 +1,143 @@
|
|
|
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 ResolvedAuth(NamedTuple):
|
|
18
|
+
"""The outcome of resolving auth for one endpoint.
|
|
19
|
+
|
|
20
|
+
``headers`` holds the authentication headers to inject (empty when no
|
|
21
|
+
auth applies). ``warning`` is a human-readable message when a declared
|
|
22
|
+
scheme was skipped, or None otherwise.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
headers: dict
|
|
26
|
+
warning: str | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_NO_AUTH = ResolvedAuth(headers={}, warning=None)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_auth(endpoint, security_schemes, auth_config):
|
|
33
|
+
"""Resolve the authentication headers for a single endpoint.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
endpoint: The Endpoint whose security requirement is resolved.
|
|
37
|
+
security_schemes: The specification's securitySchemes definitions.
|
|
38
|
+
auth_config: The AuthConfig carrying user-supplied credentials.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A ResolvedAuth with the headers to inject and an optional warning.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
AuthError: If a supported scheme is required but its credentials
|
|
45
|
+
are not configured.
|
|
46
|
+
"""
|
|
47
|
+
if not endpoint.security:
|
|
48
|
+
return _NO_AUTH
|
|
49
|
+
|
|
50
|
+
scheme_name = endpoint.security[0]
|
|
51
|
+
scheme = security_schemes.get(scheme_name)
|
|
52
|
+
if scheme is None:
|
|
53
|
+
return ResolvedAuth(
|
|
54
|
+
headers={},
|
|
55
|
+
warning=(
|
|
56
|
+
f"Security scheme '{scheme_name}' required by "
|
|
57
|
+
f"{endpoint.method.upper()} {endpoint.path} is not defined in "
|
|
58
|
+
f"the specification; skipping authentication for it."
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
scheme_type = scheme.get("type")
|
|
63
|
+
|
|
64
|
+
if scheme_type == "oauth2":
|
|
65
|
+
return _resolve_bearer(scheme_name, auth_config)
|
|
66
|
+
if scheme_type == "http":
|
|
67
|
+
http_scheme = scheme.get("scheme", "").lower()
|
|
68
|
+
if http_scheme == "bearer":
|
|
69
|
+
return _resolve_bearer(scheme_name, auth_config)
|
|
70
|
+
if http_scheme == "basic":
|
|
71
|
+
return _resolve_basic(scheme_name, auth_config)
|
|
72
|
+
if scheme_type == "apiKey" and scheme.get("in") == "header":
|
|
73
|
+
return _resolve_api_key(scheme_name, scheme, auth_config)
|
|
74
|
+
|
|
75
|
+
return ResolvedAuth(
|
|
76
|
+
headers={},
|
|
77
|
+
warning=(
|
|
78
|
+
f"Security scheme '{scheme_name}' (type '{scheme_type}') is not "
|
|
79
|
+
f"supported in this version; skipping authentication for "
|
|
80
|
+
f"{endpoint.method.upper()} {endpoint.path}."
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_bearer(scheme_name, auth_config):
|
|
86
|
+
"""Build a bearer Authorization header from configured credentials."""
|
|
87
|
+
bearer = auth_config.bearer
|
|
88
|
+
if bearer is None or not bearer.token:
|
|
89
|
+
return ResolvedAuth(
|
|
90
|
+
headers={},
|
|
91
|
+
warning=(
|
|
92
|
+
f"Scheme '{scheme_name}' requires a bearer token, but none is "
|
|
93
|
+
f"configured; generating without authentication. Add a "
|
|
94
|
+
f"[auth.bearer] section with a 'token' to enable it."
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
return ResolvedAuth(
|
|
98
|
+
headers={"Authorization": f"Bearer {bearer.token}"}, warning=None
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_basic(scheme_name, auth_config):
|
|
103
|
+
"""Build a basic Authorization header from configured credentials."""
|
|
104
|
+
basic = auth_config.basic
|
|
105
|
+
if basic is None or not basic.username or not basic.password:
|
|
106
|
+
return ResolvedAuth(
|
|
107
|
+
headers={},
|
|
108
|
+
warning=(
|
|
109
|
+
f"Scheme '{scheme_name}' requires basic credentials, but the "
|
|
110
|
+
f"username and password are not both configured; generating "
|
|
111
|
+
f"without authentication. Add a [auth.basic] section with "
|
|
112
|
+
f"'username' and 'password' to enable it."
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
raw = f"{basic.username}:{basic.password}".encode("utf-8")
|
|
116
|
+
encoded = base64.b64encode(raw).decode("ascii")
|
|
117
|
+
return ResolvedAuth(
|
|
118
|
+
headers={"Authorization": f"Basic {encoded}"}, warning=None
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _resolve_api_key(scheme_name, scheme, auth_config):
|
|
123
|
+
"""Build an API-key header, taking the header name from the spec scheme."""
|
|
124
|
+
api_key = auth_config.api_key
|
|
125
|
+
if api_key is None or not api_key.value:
|
|
126
|
+
return ResolvedAuth(
|
|
127
|
+
headers={},
|
|
128
|
+
warning=(
|
|
129
|
+
f"Scheme '{scheme_name}' requires an API key, but none is "
|
|
130
|
+
f"configured; generating without authentication. Add a "
|
|
131
|
+
f"[auth.api_key] section with a 'value' to enable it."
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
header_name = scheme.get("name")
|
|
135
|
+
if not header_name:
|
|
136
|
+
return ResolvedAuth(
|
|
137
|
+
headers={},
|
|
138
|
+
warning=(
|
|
139
|
+
f"API-key scheme '{scheme_name}' does not declare a header "
|
|
140
|
+
f"name; skipping authentication for it."
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
return ResolvedAuth(headers={header_name: api_key.value}, warning=None)
|
|
@@ -68,7 +68,8 @@ def generate(spec, url, output, config_path):
|
|
|
68
68
|
click.echo(f"API title: {spec_dict['info']['title']}")
|
|
69
69
|
|
|
70
70
|
try:
|
|
71
|
-
|
|
71
|
+
parsed = parse_spec(spec)
|
|
72
|
+
endpoints = parsed.endpoints
|
|
72
73
|
except SpecParseError as error:
|
|
73
74
|
raise click.ClickException(str(error))
|
|
74
75
|
|
|
@@ -79,12 +80,22 @@ def generate(spec, url, output, config_path):
|
|
|
79
80
|
click.echo(f"Generated {len(scenarios)} test scenarios.")
|
|
80
81
|
|
|
81
82
|
try:
|
|
82
|
-
|
|
83
|
+
result = write_test_files(
|
|
84
|
+
scenarios,
|
|
85
|
+
url,
|
|
86
|
+
output,
|
|
87
|
+
config.timeout,
|
|
88
|
+
security_schemes=parsed.security_schemes,
|
|
89
|
+
auth_config=config.auth,
|
|
90
|
+
)
|
|
83
91
|
except OSError as error:
|
|
84
92
|
raise click.ClickException(
|
|
85
93
|
f"Could not write test files to '{output}': {error}"
|
|
86
94
|
)
|
|
87
95
|
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
for warning in result.warnings:
|
|
97
|
+
click.echo(f"Warning: {warning}")
|
|
98
|
+
|
|
99
|
+
click.echo(f"Wrote {len(result.written)} test file(s) to '{output}':")
|
|
100
|
+
for path in result.written:
|
|
90
101
|
click.echo(f" - {path}")
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Loading of user configuration from a swaggerforge.toml file.
|
|
2
|
+
|
|
3
|
+
This module reads optional user configuration for the generator. An
|
|
4
|
+
explicitly given path must exist and parse; an auto-discovered file
|
|
5
|
+
(./swaggerforge.toml in the working directory) may be absent, in which
|
|
6
|
+
case an empty configuration is returned.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field, fields
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import tomllib # Python 3.11+
|
|
16
|
+
except ModuleNotFoundError:
|
|
17
|
+
import tomli as tomllib # Python 3.10 backport
|
|
18
|
+
|
|
19
|
+
DEFAULT_CONFIG_FILENAME = "swaggerforge.toml"
|
|
20
|
+
|
|
21
|
+
_KNOWN_KEYS = {
|
|
22
|
+
"timeout": int,
|
|
23
|
+
"output_dir": str,
|
|
24
|
+
"base_url": str,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfigError(Exception):
|
|
29
|
+
"""Raised when a config file is missing (if explicitly requested) or invalid."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class BearerAuth:
|
|
34
|
+
"""Bearer-token credentials for the API under test."""
|
|
35
|
+
token: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ApiKeyAuth:
|
|
40
|
+
"""API-key credentials: the secret value (header name comes from the spec)."""
|
|
41
|
+
value: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class BasicAuth:
|
|
46
|
+
"""HTTP basic-auth credentials."""
|
|
47
|
+
username: str | None = None
|
|
48
|
+
password: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AuthConfig:
|
|
53
|
+
"""Authentication credentials, keyed by scheme kind. All optional."""
|
|
54
|
+
bearer: BearerAuth | None = None
|
|
55
|
+
api_key: ApiKeyAuth | None = None
|
|
56
|
+
basic: BasicAuth | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Config:
|
|
61
|
+
"""User configuration loaded from swaggerforge.toml.
|
|
62
|
+
|
|
63
|
+
A field value of None means the setting was not present in the file.
|
|
64
|
+
"""
|
|
65
|
+
timeout: int | None = None
|
|
66
|
+
output_dir: str | None = None
|
|
67
|
+
base_url: str | None = None
|
|
68
|
+
auth: AuthConfig = field(default_factory=AuthConfig)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_config(config_path=None):
|
|
72
|
+
"""Load configuration from a swaggerforge.toml file.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
config_path: Explicit path to a config file. If given, the file
|
|
76
|
+
must exist and parse; otherwise ./swaggerforge.toml is
|
|
77
|
+
auto-discovered, and its absence yields an empty Config.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A Config object with values from the file, or an empty Config
|
|
81
|
+
when no file was found during auto-discovery.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
ConfigError: If an explicitly given file is missing, if any file
|
|
85
|
+
contains invalid TOML, or if it contains unknown keys or
|
|
86
|
+
values of the wrong type.
|
|
87
|
+
"""
|
|
88
|
+
if config_path is not None:
|
|
89
|
+
path = Path(config_path)
|
|
90
|
+
if not path.is_file():
|
|
91
|
+
raise ConfigError(f"Config file not found: '{config_path}'")
|
|
92
|
+
else:
|
|
93
|
+
path = Path(DEFAULT_CONFIG_FILENAME)
|
|
94
|
+
if not path.is_file():
|
|
95
|
+
return Config()
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
raw = path.read_bytes()
|
|
99
|
+
# utf-8-sig strips a leading UTF-8 BOM if present (common on
|
|
100
|
+
# Windows) and is identical to utf-8 otherwise.
|
|
101
|
+
data = tomllib.loads(raw.decode("utf-8-sig"))
|
|
102
|
+
except tomllib.TOMLDecodeError as error:
|
|
103
|
+
raise ConfigError(f"Invalid config file '{path}': {error}")
|
|
104
|
+
except UnicodeDecodeError:
|
|
105
|
+
raise ConfigError(
|
|
106
|
+
f"Invalid config file '{path}': not valid UTF-8 "
|
|
107
|
+
f"(TOML files must be UTF-8 encoded; on Windows, beware that "
|
|
108
|
+
f"PowerShell redirection may create UTF-16 files)"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return _validate(data, path)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_AUTH_SCHEMES = {
|
|
115
|
+
"bearer": BearerAuth,
|
|
116
|
+
"api_key": ApiKeyAuth,
|
|
117
|
+
"basic": BasicAuth,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _validate(data, path):
|
|
122
|
+
"""Check keys and value types, returning a populated Config."""
|
|
123
|
+
top_level = {key: value for key, value in data.items() if key != "auth"}
|
|
124
|
+
|
|
125
|
+
unknown = set(top_level) - set(_KNOWN_KEYS)
|
|
126
|
+
if unknown:
|
|
127
|
+
names = ", ".join(sorted(unknown))
|
|
128
|
+
raise ConfigError(f"Unknown key(s) in config file '{path}': {names}")
|
|
129
|
+
|
|
130
|
+
for key, expected_type in _KNOWN_KEYS.items():
|
|
131
|
+
if key not in top_level:
|
|
132
|
+
continue
|
|
133
|
+
value = top_level[key]
|
|
134
|
+
# bool is a subclass of int in Python; reject it for timeout.
|
|
135
|
+
if isinstance(value, bool) or not isinstance(value, expected_type):
|
|
136
|
+
raise ConfigError(
|
|
137
|
+
f"Invalid value for '{key}' in config file '{path}': "
|
|
138
|
+
f"expected {expected_type.__name__}, got {type(value).__name__}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
auth = _validate_auth(data.get("auth", {}), path)
|
|
142
|
+
return Config(auth=auth, **top_level)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Matches ${VAR} where VAR is a valid environment-variable name. The
|
|
146
|
+
# ${VAR|default} and bare $VAR forms are deliberately not supported.
|
|
147
|
+
_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _substitute_env_vars(value, scheme, key, path):
|
|
151
|
+
"""Replace every ${VAR} in a string with the environment value.
|
|
152
|
+
|
|
153
|
+
Raises ConfigError if a referenced variable is not set.
|
|
154
|
+
"""
|
|
155
|
+
def replace(match):
|
|
156
|
+
var_name = match.group(1)
|
|
157
|
+
if var_name not in os.environ:
|
|
158
|
+
raise ConfigError(
|
|
159
|
+
f"Environment variable '{var_name}' referenced in "
|
|
160
|
+
f"[auth.{scheme}] ('{key}') of config file '{path}' is not set."
|
|
161
|
+
)
|
|
162
|
+
return os.environ[var_name]
|
|
163
|
+
|
|
164
|
+
return _ENV_VAR_PATTERN.sub(replace, value)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _validate_auth(auth_data, path):
|
|
168
|
+
"""Build and validate an AuthConfig from the [auth] section."""
|
|
169
|
+
unknown = set(auth_data) - set(_AUTH_SCHEMES)
|
|
170
|
+
if unknown:
|
|
171
|
+
names = ", ".join(sorted(unknown))
|
|
172
|
+
raise ConfigError(
|
|
173
|
+
f"Unknown auth scheme(s) in config file '{path}': {names}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
kwargs = {}
|
|
177
|
+
for scheme, scheme_class in _AUTH_SCHEMES.items():
|
|
178
|
+
if scheme not in auth_data:
|
|
179
|
+
continue
|
|
180
|
+
kwargs[scheme] = _build_auth_scheme(
|
|
181
|
+
scheme, scheme_class, auth_data[scheme], path
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return AuthConfig(**kwargs)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _build_auth_scheme(scheme, scheme_class, section, path):
|
|
188
|
+
"""Validate one auth sub-table and build its dataclass."""
|
|
189
|
+
valid_fields = {f.name for f in fields(scheme_class)}
|
|
190
|
+
unknown = set(section) - valid_fields
|
|
191
|
+
if unknown:
|
|
192
|
+
names = ", ".join(sorted(unknown))
|
|
193
|
+
raise ConfigError(
|
|
194
|
+
f"Unknown key(s) in [auth.{scheme}] in config file '{path}': {names}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
resolved = {}
|
|
198
|
+
for key, value in section.items():
|
|
199
|
+
if not isinstance(value, str):
|
|
200
|
+
raise ConfigError(
|
|
201
|
+
f"Invalid value for '{key}' in [auth.{scheme}] in config file "
|
|
202
|
+
f"'{path}': expected str, got {type(value).__name__}"
|
|
203
|
+
)
|
|
204
|
+
resolved[key] = _substitute_env_vars(value, scheme, key, path)
|
|
205
|
+
|
|
206
|
+
return scheme_class(**resolved)
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
from typing import NamedTuple
|
|
12
|
+
|
|
13
|
+
from swaggerforge.auth import resolve_auth
|
|
14
|
+
from swaggerforge.config import AuthConfig
|
|
15
|
+
from swaggerforge.template import render_test_file
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WriteResult(NamedTuple):
|
|
19
|
+
"""The result of writing test files: the paths written and any warnings."""
|
|
20
|
+
|
|
21
|
+
written: list
|
|
22
|
+
warnings: list
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_test_files(
|
|
26
|
+
scenarios,
|
|
27
|
+
base_url,
|
|
28
|
+
output_dir,
|
|
29
|
+
timeout=None,
|
|
30
|
+
security_schemes=None,
|
|
31
|
+
auth_config=None,
|
|
32
|
+
):
|
|
33
|
+
"""Group scenarios by tag, render them, and write one file per tag.
|
|
34
|
+
|
|
35
|
+
Authentication is resolved per file from the tag group's first
|
|
36
|
+
endpoint (file-level auth); a scheme that cannot be applied is
|
|
37
|
+
reported as a warning rather than stopping generation.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
scenarios: A list of TestScenario objects.
|
|
41
|
+
base_url: The base URL of the API under test.
|
|
42
|
+
output_dir: Directory where the test files will be written.
|
|
43
|
+
timeout: Optional request timeout embedded in generated calls.
|
|
44
|
+
security_schemes: The specification's securitySchemes definitions.
|
|
45
|
+
auth_config: The AuthConfig carrying user-supplied credentials.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A WriteResult with the sorted list of written paths and a list of
|
|
49
|
+
de-duplicated warnings raised while resolving authentication.
|
|
50
|
+
"""
|
|
51
|
+
if security_schemes is None:
|
|
52
|
+
security_schemes = {}
|
|
53
|
+
if auth_config is None:
|
|
54
|
+
auth_config = AuthConfig()
|
|
55
|
+
|
|
56
|
+
grouped = _group_by_tag(scenarios)
|
|
57
|
+
output_path = Path(output_dir)
|
|
58
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
written = []
|
|
61
|
+
warnings = []
|
|
62
|
+
for tag in sorted(grouped):
|
|
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)
|
|
68
|
+
|
|
69
|
+
filename = f"test_{_sanitize(tag)}.py"
|
|
70
|
+
file_path = output_path / filename
|
|
71
|
+
content = render_test_file(
|
|
72
|
+
filename, base_url, group, timeout, resolved.headers
|
|
73
|
+
)
|
|
74
|
+
file_path.write_text(content, encoding="utf-8")
|
|
75
|
+
written.append(str(file_path))
|
|
76
|
+
|
|
77
|
+
return WriteResult(written=written, warnings=warnings)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _group_by_tag(scenarios):
|
|
81
|
+
"""Group scenarios into a dict keyed by their endpoint's tag."""
|
|
82
|
+
grouped = {}
|
|
83
|
+
for scenario in scenarios:
|
|
84
|
+
tag = scenario.endpoint.tag
|
|
85
|
+
grouped.setdefault(tag, []).append(scenario)
|
|
86
|
+
return grouped
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _sanitize(tag):
|
|
90
|
+
"""Replace characters not valid in a filename with underscores."""
|
|
91
|
+
return re.sub(r"[^a-zA-Z0-9_]", "_", tag)
|