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/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