codapact 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.
- codapact/__init__.py +3 -0
- codapact/__main__.py +83 -0
- codapact/cli.py +117 -0
- codapact/configuration.py +135 -0
- codapact/errors.py +30 -0
- codapact/generation.py +243 -0
- codapact/loading.py +121 -0
- codapact/model.py +83 -0
- codapact/net.py +100 -0
- codapact/openapi.py +294 -0
- codapact/profiles.py +171 -0
- codapact/project.py +173 -0
- codapact/py.typed +0 -0
- codapact/runtime.py +299 -0
- codapact/schemas/generation.v1.json +57 -0
- codapact/values.py +33 -0
- codapact-0.1.0.dist-info/METADATA +62 -0
- codapact-0.1.0.dist-info/RECORD +21 -0
- codapact-0.1.0.dist-info/WHEEL +4 -0
- codapact-0.1.0.dist-info/entry_points.txt +3 -0
- codapact-0.1.0.dist-info/licenses/LICENSE +21 -0
codapact/profiles.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Portable runtime discovery and explicit, replaceable credential storage."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
from codapact.errors import AuthError, CodaError
|
|
11
|
+
from codapact.loading import parse
|
|
12
|
+
from codapact.runtime import EnvironmentAuth, config, validate
|
|
13
|
+
from codapact.values import merge, slug_name
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_profile(profile: dict[str, Any]) -> dict[str, Any]:
|
|
17
|
+
validate(
|
|
18
|
+
profile,
|
|
19
|
+
{
|
|
20
|
+
"type": "object",
|
|
21
|
+
"additionalProperties": False,
|
|
22
|
+
"required": ["version", "slug", "config_directory", "defaults"],
|
|
23
|
+
"properties": {
|
|
24
|
+
"version": {"type": "integer", "const": 1},
|
|
25
|
+
"slug": {"type": "string"},
|
|
26
|
+
"config_directory": {"type": ["string", "null"]},
|
|
27
|
+
"defaults": {"type": "object"},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
"invalid runtime profile",
|
|
31
|
+
)
|
|
32
|
+
slug_name(profile["slug"])
|
|
33
|
+
directory = profile["config_directory"]
|
|
34
|
+
if directory is not None and (
|
|
35
|
+
not directory
|
|
36
|
+
or any(ord(c) < 32 for c in directory)
|
|
37
|
+
or not (Path(directory).is_absolute() or directory == "~" or directory.startswith("~/"))
|
|
38
|
+
):
|
|
39
|
+
raise CodaError("runtime config directory must be absolute or start with ~/")
|
|
40
|
+
config(profile["defaults"])
|
|
41
|
+
if "auth" in profile["defaults"]:
|
|
42
|
+
raise CodaError("generated defaults cannot contain auth configuration")
|
|
43
|
+
return profile
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def config_directory(profile: dict[str, Any], explicit: str | None = None) -> Path:
|
|
47
|
+
validate_profile(profile)
|
|
48
|
+
if explicit is not None:
|
|
49
|
+
return Path(explicit).expanduser().absolute()
|
|
50
|
+
if profile["config_directory"] is not None:
|
|
51
|
+
return Path(profile["config_directory"]).expanduser()
|
|
52
|
+
root = os.environ.get("XDG_CONFIG_HOME", "")
|
|
53
|
+
base = Path(root) if root and Path(root).is_absolute() else Path.home() / ".config"
|
|
54
|
+
return base / str(profile["slug"])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_file(path: Path, *, optional: bool = False, json_only: bool = False) -> dict[str, Any]:
|
|
58
|
+
"""Bounded regular local data; never follow links or modify permissions."""
|
|
59
|
+
try:
|
|
60
|
+
if any(p.is_symlink() or p.is_junction() for p in (path, *path.parents)):
|
|
61
|
+
raise CodaError("configuration links and junctions are unsupported")
|
|
62
|
+
if not stat.S_ISREG(path.stat().st_mode):
|
|
63
|
+
raise CodaError("configuration must be a regular file")
|
|
64
|
+
with path.open("rb") as handle:
|
|
65
|
+
if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
|
|
66
|
+
raise CodaError("configuration must be a regular file")
|
|
67
|
+
data = handle.read(65_537)
|
|
68
|
+
if len(data) > 65_536:
|
|
69
|
+
raise CodaError("configuration file exceeds size limit")
|
|
70
|
+
if json_only:
|
|
71
|
+
try:
|
|
72
|
+
json.loads(data)
|
|
73
|
+
except (ValueError, UnicodeError):
|
|
74
|
+
raise CodaError("configuration requires JSON data") from None
|
|
75
|
+
return parse(data)
|
|
76
|
+
except FileNotFoundError:
|
|
77
|
+
if optional:
|
|
78
|
+
return {}
|
|
79
|
+
raise CodaError("configuration file does not exist") from None
|
|
80
|
+
except OSError:
|
|
81
|
+
raise CodaError("cannot read configuration file") from None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CredentialStore(Protocol):
|
|
85
|
+
"""Explicit dependency injection boundary for future keyring providers."""
|
|
86
|
+
|
|
87
|
+
def get(self, name: str) -> dict[str, str] | None: ...
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class JsonFileStore:
|
|
91
|
+
def __init__(self, path: Path, *, optional: bool = False) -> None:
|
|
92
|
+
document = read_file(path, optional=optional, json_only=True)
|
|
93
|
+
if document == {} and optional and not path.exists():
|
|
94
|
+
self.entries: dict[str, dict[str, str]] = {}
|
|
95
|
+
return
|
|
96
|
+
validate(
|
|
97
|
+
document,
|
|
98
|
+
{
|
|
99
|
+
"type": "object",
|
|
100
|
+
"additionalProperties": False,
|
|
101
|
+
"required": ["version", "credentials"],
|
|
102
|
+
"properties": {
|
|
103
|
+
"version": {"type": "integer", "const": 1},
|
|
104
|
+
"credentials": {
|
|
105
|
+
"type": "object",
|
|
106
|
+
"additionalProperties": {
|
|
107
|
+
"type": "object",
|
|
108
|
+
"additionalProperties": {"type": "string"},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
"invalid auth file",
|
|
114
|
+
)
|
|
115
|
+
self.entries = document["credentials"]
|
|
116
|
+
for entry in self.entries.values():
|
|
117
|
+
if set(entry) not in (
|
|
118
|
+
{"value"},
|
|
119
|
+
{"username", "password"},
|
|
120
|
+
{"env"},
|
|
121
|
+
{"username_env", "password_env"},
|
|
122
|
+
):
|
|
123
|
+
raise AuthError("invalid auth credential fields")
|
|
124
|
+
|
|
125
|
+
def get(self, name: str) -> dict[str, str] | None:
|
|
126
|
+
return self.entries.get(name)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class StoredAuth:
|
|
130
|
+
def __init__(self, store: CredentialStore, fallback: EnvironmentAuth | None = None) -> None:
|
|
131
|
+
self.store = store
|
|
132
|
+
self.fallback = fallback or EnvironmentAuth()
|
|
133
|
+
|
|
134
|
+
def headers(self, name: str, scheme: dict[str, Any]) -> dict[str, str]:
|
|
135
|
+
entry = self.store.get(name)
|
|
136
|
+
if entry is None:
|
|
137
|
+
return self.fallback.headers(name, scheme)
|
|
138
|
+
if set(entry) in ({"env"}, {"username_env", "password_env"}):
|
|
139
|
+
return EnvironmentAuth({name: entry}).headers(name, scheme)
|
|
140
|
+
if any(
|
|
141
|
+
not value or any(ord(c) < 32 or ord(c) == 127 for c in value)
|
|
142
|
+
for value in entry.values()
|
|
143
|
+
):
|
|
144
|
+
raise AuthError("required credential is missing or invalid")
|
|
145
|
+
if scheme["type"] == "apiKey" or scheme.get("scheme") == "bearer":
|
|
146
|
+
if set(entry) != {"value"}:
|
|
147
|
+
raise AuthError("credential type does not match security scheme")
|
|
148
|
+
return (
|
|
149
|
+
{scheme["name"]: entry["value"]}
|
|
150
|
+
if scheme["type"] == "apiKey"
|
|
151
|
+
else {"Authorization": "Bearer " + entry["value"]}
|
|
152
|
+
)
|
|
153
|
+
if set(entry) != {"username", "password"} or ":" in entry["username"]:
|
|
154
|
+
raise AuthError("invalid basic auth credential")
|
|
155
|
+
token = base64.b64encode((entry["username"] + ":" + entry["password"]).encode()).decode()
|
|
156
|
+
return {"Authorization": "Basic " + token}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def runtime_settings(
|
|
160
|
+
profile: dict[str, Any], directory: str | None, path: str | None, overrides: dict[str, Any]
|
|
161
|
+
) -> tuple[dict[str, Any], Path]:
|
|
162
|
+
root = config_directory(profile, directory)
|
|
163
|
+
if path is not None and "://" in path:
|
|
164
|
+
raise CodaError("runtime configuration must be local")
|
|
165
|
+
document = read_file(
|
|
166
|
+
Path(path) if path is not None else root / "config.json", optional=path is None
|
|
167
|
+
)
|
|
168
|
+
if path is None and "auth" in document:
|
|
169
|
+
raise CodaError("put credentials in auth.json; config.json is ordinary configuration")
|
|
170
|
+
settings = config(merge(merge(profile["defaults"], document), overrides))
|
|
171
|
+
return settings, root
|
codapact/project.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Orchestration and ownership-aware regeneration."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from codapact import __version__
|
|
13
|
+
from codapact.errors import CodaError
|
|
14
|
+
from codapact.generation import ArgparseGenerator, PythonGenerator, canonical, package_name
|
|
15
|
+
from codapact.loading import load
|
|
16
|
+
from codapact.model import Registry
|
|
17
|
+
from codapact.net import Transport
|
|
18
|
+
from codapact.openapi import OpenAPITranslator
|
|
19
|
+
|
|
20
|
+
MANIFEST = ".codapact-manifest.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_registry() -> Registry:
|
|
24
|
+
registry = Registry()
|
|
25
|
+
registry.translator("openapi", OpenAPITranslator())
|
|
26
|
+
registry.generator("python", PythonGenerator())
|
|
27
|
+
registry.generator("argparse", ArgparseGenerator())
|
|
28
|
+
return registry
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def digest(data: bytes) -> str:
|
|
32
|
+
return hashlib.sha256(data).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _safe_path(root: Path, relative: str) -> Path:
|
|
36
|
+
parts = PurePosixPath(relative).parts
|
|
37
|
+
if (
|
|
38
|
+
not parts
|
|
39
|
+
or relative != PurePosixPath(relative).as_posix()
|
|
40
|
+
or not re.fullmatch(r"[A-Za-z0-9_./-]+", relative)
|
|
41
|
+
or relative.startswith("/")
|
|
42
|
+
or "\\" in relative
|
|
43
|
+
or ":" in relative
|
|
44
|
+
or any(part in {".", ".."} or part.endswith((".", " ")) for part in parts)
|
|
45
|
+
or any(
|
|
46
|
+
re.fullmatch(r"(?i:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\..*)?", part) for part in parts
|
|
47
|
+
)
|
|
48
|
+
or relative == MANIFEST
|
|
49
|
+
):
|
|
50
|
+
raise CodaError("unsafe generated path")
|
|
51
|
+
path = root.joinpath(*parts)
|
|
52
|
+
for parent in (path, *path.parents):
|
|
53
|
+
if parent.is_symlink() or parent.is_junction():
|
|
54
|
+
raise CodaError("symlinks and junctions are unsupported in generation paths")
|
|
55
|
+
if parent != path and parent.exists() and not parent.is_dir():
|
|
56
|
+
raise CodaError("generated path parent is not a directory")
|
|
57
|
+
return path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def write_project(root: Path, files: dict[str, str]) -> None:
|
|
61
|
+
root = root.absolute()
|
|
62
|
+
_safe_path(root, "probe")
|
|
63
|
+
if root.exists() and not root.is_dir():
|
|
64
|
+
raise CodaError("output must be a directory")
|
|
65
|
+
manifest = root / MANIFEST
|
|
66
|
+
if manifest.is_symlink():
|
|
67
|
+
raise CodaError("manifest cannot be a symlink")
|
|
68
|
+
previous = {}
|
|
69
|
+
if manifest.exists():
|
|
70
|
+
try:
|
|
71
|
+
state = json.loads(manifest.read_text("utf-8"))
|
|
72
|
+
if (
|
|
73
|
+
state["version"] != 1
|
|
74
|
+
or not isinstance(state["files"], dict)
|
|
75
|
+
or any(
|
|
76
|
+
not isinstance(k, str)
|
|
77
|
+
or not isinstance(v, str)
|
|
78
|
+
or not re.fullmatch(r"[0-9a-f]{64}", v)
|
|
79
|
+
for k, v in state["files"].items()
|
|
80
|
+
)
|
|
81
|
+
):
|
|
82
|
+
raise ValueError
|
|
83
|
+
previous = state["files"]
|
|
84
|
+
except (ValueError, KeyError, OSError):
|
|
85
|
+
raise CodaError("invalid ownership manifest") from None
|
|
86
|
+
encoded = {name: value.encode("utf-8") for name, value in files.items()}
|
|
87
|
+
names = {name.casefold() for name in encoded}
|
|
88
|
+
if len(names) != len(encoded) or any(
|
|
89
|
+
parent.as_posix().casefold() in names
|
|
90
|
+
for name in encoded
|
|
91
|
+
for parent in PurePosixPath(name).parents
|
|
92
|
+
):
|
|
93
|
+
raise CodaError("generated files have conflicting paths")
|
|
94
|
+
for name in previous.keys() | encoded.keys():
|
|
95
|
+
path = _safe_path(root, name)
|
|
96
|
+
if path.exists():
|
|
97
|
+
if (
|
|
98
|
+
not path.is_file()
|
|
99
|
+
or name not in previous
|
|
100
|
+
or digest(path.read_bytes()) != previous[name]
|
|
101
|
+
):
|
|
102
|
+
raise CodaError("regeneration refused: existing file is unowned or modified")
|
|
103
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
# Each file is atomically replaced. Manifest commits last; interrupted multi-file
|
|
105
|
+
# updates fail closed on the next attempt instead of claiming transactional safety.
|
|
106
|
+
for name, data in sorted(encoded.items()):
|
|
107
|
+
path = _safe_path(root, name)
|
|
108
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
_atomic(path, data)
|
|
110
|
+
for name in previous.keys() - encoded.keys():
|
|
111
|
+
_safe_path(root, name).unlink(missing_ok=True)
|
|
112
|
+
_atomic(
|
|
113
|
+
manifest,
|
|
114
|
+
canonical(
|
|
115
|
+
{
|
|
116
|
+
"version": 1,
|
|
117
|
+
"generator": f"codapact/{__version__}",
|
|
118
|
+
"files": {name: digest(data) for name, data in encoded.items()},
|
|
119
|
+
}
|
|
120
|
+
).encode(),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _atomic(path: Path, data: bytes) -> None:
|
|
125
|
+
fd, temporary = tempfile.mkstemp(prefix=".coda-", dir=path.parent)
|
|
126
|
+
try:
|
|
127
|
+
with os.fdopen(fd, "wb") as handle:
|
|
128
|
+
handle.write(data)
|
|
129
|
+
os.replace(temporary, path)
|
|
130
|
+
finally:
|
|
131
|
+
Path(temporary).unlink(missing_ok=True)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def generate(
|
|
135
|
+
source: str,
|
|
136
|
+
output: Path,
|
|
137
|
+
*,
|
|
138
|
+
package: str,
|
|
139
|
+
registry: Registry | None = None,
|
|
140
|
+
source_plugin: str = "openapi",
|
|
141
|
+
targets: tuple[str, ...] = ("python", "argparse"),
|
|
142
|
+
cli_definition: dict[str, Any] | None = None,
|
|
143
|
+
transport: Transport | None = None,
|
|
144
|
+
profile: dict[str, Any] | None = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
package_name(package)
|
|
147
|
+
translator, generators = (registry or default_registry()).negotiate(source_plugin, targets)
|
|
148
|
+
contract = translator.translate(load(source, transport=transport))
|
|
149
|
+
if contract.version != 1:
|
|
150
|
+
raise CodaError("unsupported IR version")
|
|
151
|
+
if profile is not None:
|
|
152
|
+
from codapact.profiles import validate_profile
|
|
153
|
+
|
|
154
|
+
profile = validate_profile(profile)
|
|
155
|
+
contract = replace(
|
|
156
|
+
contract,
|
|
157
|
+
metadata={
|
|
158
|
+
**contract.metadata,
|
|
159
|
+
"codapact.runtime": profile,
|
|
160
|
+
"codapact.cli": profile["slug"],
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
files: dict[str, str] = {}
|
|
164
|
+
for generator in generators:
|
|
165
|
+
generated = (
|
|
166
|
+
generator.generate_with_definition(contract, package, cli_definition)
|
|
167
|
+
if isinstance(generator, ArgparseGenerator)
|
|
168
|
+
else generator.generate(contract, package)
|
|
169
|
+
)
|
|
170
|
+
if files.keys() & generated.keys():
|
|
171
|
+
raise CodaError("generators have conflicting file ownership")
|
|
172
|
+
files.update(generated)
|
|
173
|
+
write_project(output, files)
|
codapact/py.typed
ADDED
|
File without changes
|
codapact/runtime.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Reusable execution, authentication, policy, and response validation layers."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
from urllib.parse import quote, urlencode
|
|
11
|
+
|
|
12
|
+
from jsonschema import Draft7Validator
|
|
13
|
+
|
|
14
|
+
from codapact.errors import ApiError, AuthError, CodaError, PolicyError, ResponseError
|
|
15
|
+
from codapact.net import HttpTransport, Transport, validate_url
|
|
16
|
+
from codapact.values import merge
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Unset:
|
|
20
|
+
"""An omitted body, distinct from an explicitly supplied JSON null."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
UNSET = Unset()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate(value: Any, schema: dict[str, Any], message: str) -> None:
|
|
27
|
+
visited = 0
|
|
28
|
+
|
|
29
|
+
def json_value(node: Any, depth: int = 0) -> None:
|
|
30
|
+
nonlocal visited
|
|
31
|
+
visited += 1
|
|
32
|
+
if depth > 48 or visited > 50_000:
|
|
33
|
+
raise CodaError(message)
|
|
34
|
+
if isinstance(node, dict):
|
|
35
|
+
if any(not isinstance(key, str) for key in node):
|
|
36
|
+
raise CodaError(message)
|
|
37
|
+
for child in node.values():
|
|
38
|
+
json_value(child, depth + 1)
|
|
39
|
+
elif isinstance(node, list):
|
|
40
|
+
for child in node:
|
|
41
|
+
json_value(child, depth + 1)
|
|
42
|
+
elif isinstance(node, float) and not math.isfinite(node):
|
|
43
|
+
raise CodaError(message)
|
|
44
|
+
elif node is not None and not isinstance(node, (str, int, float, bool)):
|
|
45
|
+
raise CodaError(message)
|
|
46
|
+
|
|
47
|
+
json_value(value)
|
|
48
|
+
if not Draft7Validator(schema).is_valid(value):
|
|
49
|
+
raise CodaError(message)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def map_types(value: Any, schema: dict[str, Any]) -> Any:
|
|
53
|
+
"""Map validated JSON integer numbers to actual Python integers, recursively."""
|
|
54
|
+
kind = schema["type"]
|
|
55
|
+
if value is None:
|
|
56
|
+
return None
|
|
57
|
+
if kind == "integer" or kind == ["integer", "null"]:
|
|
58
|
+
return int(value)
|
|
59
|
+
if isinstance(value, list):
|
|
60
|
+
return [map_types(item, schema["items"]) for item in value]
|
|
61
|
+
if isinstance(value, dict):
|
|
62
|
+
properties = schema.get("properties", {})
|
|
63
|
+
additional = schema.get("additionalProperties", True)
|
|
64
|
+
return {
|
|
65
|
+
key: map_types(item, properties[key])
|
|
66
|
+
if key in properties
|
|
67
|
+
else map_types(item, additional)
|
|
68
|
+
if isinstance(additional, dict)
|
|
69
|
+
else item
|
|
70
|
+
for key, item in value.items()
|
|
71
|
+
}
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AuthProvider(Protocol):
|
|
76
|
+
"""Return scheme-specific headers or raise AuthError if unavailable."""
|
|
77
|
+
|
|
78
|
+
def headers(self, name: str, scheme: dict[str, Any]) -> dict[str, str]: ...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AuthorizationPolicy(Protocol):
|
|
82
|
+
"""Client-side guard only. The server remains the authorization authority."""
|
|
83
|
+
|
|
84
|
+
def authorize(self, operation: Mapping[str, Any]) -> None: ...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class OperationPolicy:
|
|
88
|
+
def __init__(self, *, allow: list[str] | None = None, deny: list[str] | None = None) -> None:
|
|
89
|
+
self.allow = None if allow is None else frozenset(allow)
|
|
90
|
+
self.deny = frozenset(deny or [])
|
|
91
|
+
|
|
92
|
+
def authorize(self, operation: Mapping[str, Any]) -> None:
|
|
93
|
+
name = operation["id"]
|
|
94
|
+
if name in self.deny or (self.allow is not None and name not in self.allow):
|
|
95
|
+
raise PolicyError(f"client policy denies operation {name}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class EnvironmentAuth:
|
|
99
|
+
"""Credentials are resolved at execution time, never written into generated files."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, settings: dict[str, dict[str, str]] | None = None) -> None:
|
|
102
|
+
self.settings = settings or {}
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _secret(entry: dict[str, str], key: str) -> str:
|
|
106
|
+
reference = entry.get(key, "")
|
|
107
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", reference):
|
|
108
|
+
raise AuthError("auth requires an environment variable reference")
|
|
109
|
+
value = os.environ.get(reference)
|
|
110
|
+
if value is None or value == "" or any(ord(c) < 32 or ord(c) == 127 for c in value):
|
|
111
|
+
raise AuthError("required credential is missing or invalid")
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
def headers(self, name: str, scheme: dict[str, Any]) -> dict[str, str]:
|
|
115
|
+
entry = self.settings.get(name, {})
|
|
116
|
+
if scheme["type"] == "apiKey":
|
|
117
|
+
return {scheme["name"]: self._secret(entry, "env")}
|
|
118
|
+
if scheme["scheme"] == "bearer":
|
|
119
|
+
return {"Authorization": "Bearer " + self._secret(entry, "env")}
|
|
120
|
+
username = self._secret(entry, "username_env")
|
|
121
|
+
if ":" in username:
|
|
122
|
+
raise AuthError("basic auth username cannot contain a colon")
|
|
123
|
+
token = base64.b64encode(
|
|
124
|
+
(username + ":" + self._secret(entry, "password_env")).encode()
|
|
125
|
+
).decode()
|
|
126
|
+
return {"Authorization": "Basic " + token}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def config(settings: dict[str, Any] | None) -> dict[str, Any]:
|
|
130
|
+
settings = {} if settings is None else settings
|
|
131
|
+
schema = {
|
|
132
|
+
"type": "object",
|
|
133
|
+
"additionalProperties": False,
|
|
134
|
+
"properties": {
|
|
135
|
+
"version": {"const": 1},
|
|
136
|
+
"base_url": {"type": "string"},
|
|
137
|
+
"allow_private": {"type": "boolean"},
|
|
138
|
+
"timeout": {"type": "number", "exclusiveMinimum": 0, "maximum": 120},
|
|
139
|
+
"auth": {
|
|
140
|
+
"type": "object",
|
|
141
|
+
"additionalProperties": {
|
|
142
|
+
"type": "object",
|
|
143
|
+
"additionalProperties": False,
|
|
144
|
+
"properties": {
|
|
145
|
+
key: {"type": "string"} for key in ("env", "username_env", "password_env")
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
"policy": {
|
|
150
|
+
"type": "object",
|
|
151
|
+
"additionalProperties": False,
|
|
152
|
+
"properties": {
|
|
153
|
+
key: {"type": "array", "items": {"type": "string"}} for key in ("allow", "deny")
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
validate(settings, schema, "invalid runtime configuration")
|
|
159
|
+
return settings
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class ClientBase:
|
|
163
|
+
"""Base for generated clients; use explicit objects to install custom hooks."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
document: dict[str, Any],
|
|
168
|
+
*,
|
|
169
|
+
settings: dict[str, Any] | None = None,
|
|
170
|
+
transport: Transport | None = None,
|
|
171
|
+
auth: AuthProvider | None = None,
|
|
172
|
+
policy: AuthorizationPolicy | None = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
profile = document.get("metadata", {}).get("codapact.runtime")
|
|
175
|
+
if profile is not None:
|
|
176
|
+
from codapact.profiles import validate_profile
|
|
177
|
+
|
|
178
|
+
validate_profile(profile)
|
|
179
|
+
settings = merge(profile["defaults"], settings or {})
|
|
180
|
+
settings = config(settings)
|
|
181
|
+
if document.get("version") != 1:
|
|
182
|
+
raise CodaError("unsupported IR version")
|
|
183
|
+
self._operations = {operation["id"]: operation for operation in document["operations"]}
|
|
184
|
+
self._schemes = document["security_schemes"]
|
|
185
|
+
self._base_url = settings.get("base_url", document["base_url"]).rstrip("/")
|
|
186
|
+
validate_url(self._base_url, allow_query=False)
|
|
187
|
+
self._transport = transport or HttpTransport(
|
|
188
|
+
allow_private=settings.get("allow_private", False), timeout=settings.get("timeout", 15)
|
|
189
|
+
)
|
|
190
|
+
self._auth = auth or EnvironmentAuth(settings.get("auth"))
|
|
191
|
+
self._policy = policy or OperationPolicy(**settings.get("policy", {}))
|
|
192
|
+
|
|
193
|
+
def _security(self, operation: dict[str, Any]) -> dict[str, str]:
|
|
194
|
+
# OpenAPI: alternatives are OR; schemes within an alternative are AND.
|
|
195
|
+
if not operation["security"]:
|
|
196
|
+
return {}
|
|
197
|
+
for alternative in operation["security"]:
|
|
198
|
+
result: dict[str, str] = {}
|
|
199
|
+
try:
|
|
200
|
+
for name in alternative:
|
|
201
|
+
scheme = self._schemes[name]
|
|
202
|
+
headers = self._auth.headers(name, scheme)
|
|
203
|
+
expected = scheme["name"] if scheme["type"] == "apiKey" else "Authorization"
|
|
204
|
+
if (
|
|
205
|
+
not headers
|
|
206
|
+
or expected.lower() not in {key.lower() for key in headers}
|
|
207
|
+
or any(not value for value in headers.values())
|
|
208
|
+
):
|
|
209
|
+
raise AuthError(
|
|
210
|
+
"auth provider did not satisfy the required security scheme"
|
|
211
|
+
)
|
|
212
|
+
for key, value in headers.items():
|
|
213
|
+
if key.lower() in {k.lower() for k in result}:
|
|
214
|
+
raise AuthError("AND security schemes conflict on a header")
|
|
215
|
+
result[key] = value
|
|
216
|
+
return result
|
|
217
|
+
except AuthError:
|
|
218
|
+
continue
|
|
219
|
+
raise AuthError("no complete security alternative has usable credentials")
|
|
220
|
+
|
|
221
|
+
def _execute(
|
|
222
|
+
self, name: str, parameters: Mapping[str, Any] | None = None, body: Any = UNSET
|
|
223
|
+
) -> Any:
|
|
224
|
+
operation = self._operations[name]
|
|
225
|
+
self._policy.authorize(operation)
|
|
226
|
+
values = dict(parameters or {})
|
|
227
|
+
definitions = operation["parameters"]
|
|
228
|
+
if values.keys() - {parameter["name"] for parameter in definitions}:
|
|
229
|
+
raise CodaError("unknown request parameter")
|
|
230
|
+
path = operation["path"]
|
|
231
|
+
query = []
|
|
232
|
+
headers = {"Accept": "application/json"}
|
|
233
|
+
for parameter in definitions:
|
|
234
|
+
key = parameter["name"]
|
|
235
|
+
if key not in values:
|
|
236
|
+
if parameter["required"]:
|
|
237
|
+
raise CodaError(f"missing required parameter {key}")
|
|
238
|
+
continue
|
|
239
|
+
value = values[key]
|
|
240
|
+
validate(value, parameter["schema"], f"invalid parameter {key}")
|
|
241
|
+
value = map_types(value, parameter["schema"])
|
|
242
|
+
rendered = str(value).lower() if isinstance(value, bool) else str(value)
|
|
243
|
+
if parameter["in"] == "path":
|
|
244
|
+
# Reject dot-only segments, which intermediaries can normalize even when escaped.
|
|
245
|
+
if rendered in {".", ".."}:
|
|
246
|
+
raise CodaError("dot path parameters are unsupported")
|
|
247
|
+
path = path.replace("{" + key + "}", quote(rendered, safe=""))
|
|
248
|
+
elif parameter["in"] == "query":
|
|
249
|
+
query.append((key, rendered))
|
|
250
|
+
else:
|
|
251
|
+
headers[key] = rendered
|
|
252
|
+
payload = None
|
|
253
|
+
if body is not UNSET:
|
|
254
|
+
if operation["body"] is None:
|
|
255
|
+
raise CodaError("operation does not accept a request body")
|
|
256
|
+
validate(body, operation["body"], "invalid request body")
|
|
257
|
+
body = map_types(body, operation["body"])
|
|
258
|
+
try:
|
|
259
|
+
payload = json.dumps(body, allow_nan=False).encode()
|
|
260
|
+
except (ValueError, TypeError):
|
|
261
|
+
raise CodaError("request body must be JSON compatible") from None
|
|
262
|
+
headers["Content-Type"] = "application/json"
|
|
263
|
+
elif operation["body_required"]:
|
|
264
|
+
raise CodaError("request body is required")
|
|
265
|
+
headers.update(self._security(operation))
|
|
266
|
+
for key, value in headers.items():
|
|
267
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", key) or any(
|
|
268
|
+
character in value for character in ("\r", "\n", "\x00")
|
|
269
|
+
):
|
|
270
|
+
raise CodaError("invalid request header")
|
|
271
|
+
url = self._base_url + path + ("?" + urlencode(query) if query else "")
|
|
272
|
+
response = self._transport.send(operation["method"], url, headers, payload)
|
|
273
|
+
if not 200 <= response.status < 300:
|
|
274
|
+
raise ApiError(response.status, name)
|
|
275
|
+
responses = operation["responses"]
|
|
276
|
+
status = str(response.status)
|
|
277
|
+
if status not in responses:
|
|
278
|
+
raise ResponseError("undeclared successful response status")
|
|
279
|
+
schema = responses[status]
|
|
280
|
+
if schema is None:
|
|
281
|
+
if response.body:
|
|
282
|
+
raise ResponseError("expected an empty response body")
|
|
283
|
+
return None
|
|
284
|
+
content_type = next(
|
|
285
|
+
(v for k, v in response.headers.items() if k.lower() == "content-type"), ""
|
|
286
|
+
)
|
|
287
|
+
if content_type.split(";")[0].strip().lower() != "application/json":
|
|
288
|
+
raise ResponseError("expected application/json response")
|
|
289
|
+
try:
|
|
290
|
+
|
|
291
|
+
def reject_constant(value: str) -> None:
|
|
292
|
+
raise ValueError("non-JSON numeric constant")
|
|
293
|
+
|
|
294
|
+
result = json.loads(response.body, parse_constant=reject_constant)
|
|
295
|
+
validate(result, schema, "response does not match the declared schema")
|
|
296
|
+
result = map_types(result, schema)
|
|
297
|
+
except (ValueError, UnicodeError, CodaError):
|
|
298
|
+
raise ResponseError("response does not match the declared JSON schema") from None
|
|
299
|
+
return result
|