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/loading.py ADDED
@@ -0,0 +1,121 @@
1
+ """Data-only loading and bounded, document-local JSON pointer resolution."""
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any, override
7
+
8
+ import yaml
9
+ from yaml.tokens import AliasToken, AnchorToken
10
+
11
+ from codapact.errors import CodaError
12
+ from codapact.net import MAX_BYTES, HttpTransport, Transport
13
+
14
+
15
+ class UniqueLoader(yaml.SafeLoader):
16
+ """Reject ambiguous duplicate keys instead of accepting the last one."""
17
+
18
+ @override
19
+ def construct_mapping(self, node, deep=False):
20
+ keys = [self.construct_object(key, deep=deep) for key, _ in node.value]
21
+ if any(not isinstance(key, str) for key in keys) or len(set(keys)) != len(keys):
22
+ raise CodaError("mapping keys must be unique strings")
23
+ return super().construct_mapping(node, deep=deep)
24
+
25
+
26
+ def parse(data: bytes) -> dict[str, Any]:
27
+ if len(data) > MAX_BYTES:
28
+ raise CodaError("document exceeds 2 MB limit")
29
+ try:
30
+
31
+ def unique_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
32
+ result = dict(pairs)
33
+ if len(result) != len(pairs):
34
+ raise CodaError("mapping keys must be unique strings")
35
+ return result
36
+
37
+ try:
38
+ value = json.loads(data, object_pairs_hook=unique_pairs)
39
+ except json.JSONDecodeError:
40
+ if any(isinstance(token, (AliasToken, AnchorToken)) for token in yaml.scan(data)):
41
+ raise CodaError("YAML aliases and anchors are unsupported") from None
42
+ value = yaml.load(data, Loader=UniqueLoader) # noqa: S506 -- SafeLoader subclass
43
+ count = 0
44
+
45
+ def visit(node: Any, depth: int) -> None:
46
+ nonlocal count
47
+ count += 1
48
+ if depth > 48 or count > 50_000:
49
+ raise CodaError("document complexity limit exceeded")
50
+ if isinstance(node, dict):
51
+ if any(not isinstance(k, str) for k in node):
52
+ raise CodaError("all mapping keys must be strings")
53
+ for child in node.values():
54
+ visit(child, depth + 1)
55
+ elif isinstance(node, list):
56
+ for child in node:
57
+ visit(child, depth + 1)
58
+ elif node is not None and not isinstance(node, (str, int, float, bool)):
59
+ raise CodaError("only JSON-compatible values are supported")
60
+
61
+ visit(value, 0)
62
+ json.dumps(value, allow_nan=False)
63
+ if not isinstance(value, dict):
64
+ raise CodaError("document root must be an object")
65
+ return value
66
+ except (yaml.YAMLError, UnicodeError, ValueError, RecursionError):
67
+ raise CodaError("invalid JSON/YAML document") from None
68
+
69
+
70
+ def load(source: str, *, transport: Transport | None = None) -> dict[str, Any]:
71
+ if source.startswith(("https://", "http://")):
72
+ response = (transport or HttpTransport()).send(
73
+ "GET", source, {"Accept": "application/json"}, None
74
+ )
75
+ if response.status != 200:
76
+ raise CodaError("contract download did not return HTTP 200")
77
+ return parse(response.body)
78
+ try:
79
+ with Path(source).open("rb") as handle:
80
+ return parse(handle.read(MAX_BYTES + 1))
81
+ except OSError:
82
+ raise CodaError("cannot read local document") from None
83
+
84
+
85
+ def resolve(document: dict[str, Any]) -> dict[str, Any]:
86
+ """Resolve internal refs only; refuse recursion, siblings, and external fetches."""
87
+ budget = 0
88
+
89
+ def walk(node: Any, stack: tuple[str, ...] = (), depth: int = 0) -> Any:
90
+ nonlocal budget
91
+ budget += 1
92
+ if budget > 100_000 or depth > 48:
93
+ raise CodaError("reference expansion limit exceeded")
94
+ if isinstance(node, list):
95
+ return [walk(child, stack, depth + 1) for child in node]
96
+ if not isinstance(node, dict):
97
+ return node
98
+ if "$ref" in node:
99
+ ref = node["$ref"]
100
+ if len(node) != 1 or not isinstance(ref, str) or not ref.startswith("#/"):
101
+ raise CodaError("only document-local references without siblings are supported")
102
+ if "%" in ref or re.search(r"~(?:[^01]|$)", ref):
103
+ raise CodaError("unsupported URI-encoded reference or invalid JSON Pointer escape")
104
+ if ref in stack:
105
+ raise CodaError("recursive references are unsupported")
106
+ target: Any = document
107
+ try:
108
+ for token in ref[2:].split("/"):
109
+ token = token.replace("~1", "/").replace("~0", "~")
110
+ if isinstance(target, list):
111
+ if not re.fullmatch(r"0|[1-9][0-9]*", token):
112
+ raise ValueError
113
+ target = target[int(token)]
114
+ else:
115
+ target = target[token]
116
+ except (KeyError, IndexError, TypeError, ValueError):
117
+ raise CodaError("unresolved internal reference") from None
118
+ return walk(target, (*stack, ref), depth + 1)
119
+ return {key: walk(child, stack, depth + 1) for key, child in node.items()}
120
+
121
+ return walk(document)
codapact/model.py ADDED
@@ -0,0 +1,83 @@
1
+ """Versioned source-neutral intermediate representation and plugin contracts."""
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from typing import Any, Protocol
5
+
6
+ from codapact.errors import CodaError
7
+
8
+ API_VERSION = 1
9
+ IR_VERSION = 1
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Operation:
14
+ id: str
15
+ method: str
16
+ path: str
17
+ parameters: tuple[dict[str, Any], ...]
18
+ body: dict[str, Any] | None
19
+ body_required: bool
20
+ responses: dict[str, dict[str, Any] | None]
21
+ security: tuple[dict[str, list[str]], ...]
22
+ summary: str = ""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Contract:
27
+ title: str
28
+ base_url: str
29
+ operations: tuple[Operation, ...]
30
+ security_schemes: dict[str, dict[str, Any]]
31
+ types: dict[str, dict[str, Any]] = field(default_factory=dict)
32
+ metadata: dict[str, Any] = field(default_factory=dict)
33
+ version: int = IR_VERSION
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ return asdict(self)
37
+
38
+
39
+ class Translator(Protocol):
40
+ api_version: int
41
+ capabilities: frozenset[str]
42
+
43
+ def translate(self, document: dict[str, Any]) -> Contract: ...
44
+
45
+
46
+ class Generator(Protocol):
47
+ api_version: int
48
+ requires: frozenset[str]
49
+
50
+ def generate(self, contract: Contract, package: str) -> dict[str, str]: ...
51
+
52
+
53
+ class Registry:
54
+ """Explicit registration only. Config never imports or executes Python."""
55
+
56
+ def __init__(self) -> None:
57
+ self.translators: dict[str, Translator] = {}
58
+ self.generators: dict[str, Generator] = {}
59
+
60
+ def translator(self, name: str, plugin: Translator) -> None:
61
+ self._check(name, plugin.api_version, self.translators)
62
+ self.translators[name] = plugin
63
+
64
+ def generator(self, name: str, plugin: Generator) -> None:
65
+ self._check(name, plugin.api_version, self.generators)
66
+ self.generators[name] = plugin
67
+
68
+ @staticmethod
69
+ def _check(name: str, version: int, existing: dict[str, Any]) -> None:
70
+ if type(version) is not int or version != API_VERSION or name in existing:
71
+ raise CodaError("incompatible plugin API version or duplicate registration")
72
+
73
+ def negotiate(
74
+ self, source: str, targets: tuple[str, ...]
75
+ ) -> tuple[Translator, list[Generator]]:
76
+ try:
77
+ translator = self.translators[source]
78
+ generators = [self.generators[name] for name in targets]
79
+ except KeyError:
80
+ raise CodaError("unknown source or target plugin") from None
81
+ if any(not generator.requires <= translator.capabilities for generator in generators):
82
+ raise CodaError("plugin capabilities are incompatible")
83
+ return translator, generators
codapact/net.py ADDED
@@ -0,0 +1,100 @@
1
+ """Bounded HTTP transport with DNS pinning and no redirect following."""
2
+
3
+ import http.client
4
+ import ipaddress
5
+ import socket
6
+ import ssl
7
+ from dataclasses import dataclass
8
+ from typing import Protocol, cast
9
+ from urllib.parse import SplitResult, urlsplit
10
+
11
+ from codapact.errors import CodaError, TransportError
12
+
13
+ MAX_BYTES = 2_000_000
14
+
15
+
16
+ def validate_url(url: str, *, allow_query: bool = True) -> SplitResult:
17
+ """Reject ambiguous URL normalization before credentials can be attached."""
18
+ try:
19
+ if any(
20
+ character.isspace() or ord(character) < 32 or ord(character) == 127 for character in url
21
+ ):
22
+ raise ValueError
23
+ parts = urlsplit(url)
24
+ if (
25
+ parts.scheme not in {"http", "https"}
26
+ or parts.hostname is None
27
+ or parts.username is not None
28
+ or parts.password is not None
29
+ or parts.fragment
30
+ or not parts.path.isascii()
31
+ or not parts.query.isascii()
32
+ or (not allow_query and parts.query)
33
+ or parts.port == 0
34
+ ):
35
+ raise ValueError
36
+ return parts
37
+ except (ValueError, TypeError):
38
+ raise CodaError(
39
+ "URL must be unambiguous HTTP(S), without credentials or fragments"
40
+ ) from None
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Response:
45
+ status: int
46
+ headers: dict[str, str]
47
+ body: bytes
48
+
49
+
50
+ class Transport(Protocol):
51
+ """Implementations must not follow redirects or log credentials."""
52
+
53
+ def send(
54
+ self, method: str, url: str, headers: dict[str, str], body: bytes | None
55
+ ) -> Response: ...
56
+
57
+
58
+ class HttpTransport:
59
+ """Single-request transport; private destinations require explicit opt-in."""
60
+
61
+ def __init__(self, *, allow_private: bool = False, timeout: float = 15) -> None:
62
+ if not 0 < timeout <= 120:
63
+ raise CodaError("timeout must be between 0 and 120 seconds")
64
+ self.allow_private = allow_private
65
+ self.timeout = timeout
66
+
67
+ def send(self, method: str, url: str, headers: dict[str, str], body: bytes | None) -> Response:
68
+ try:
69
+ parts = validate_url(url)
70
+ hostname = cast(str, parts.hostname)
71
+ port = parts.port or (443 if parts.scheme == "https" else 80)
72
+ addresses = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
73
+ ips = [str(item[4][0]) for item in addresses]
74
+ if not ips or (
75
+ not self.allow_private and any(not ipaddress.ip_address(ip).is_global for ip in ips)
76
+ ):
77
+ raise CodaError("private network access requires explicit allow_private")
78
+ if parts.scheme == "http" and not self.allow_private:
79
+ raise CodaError("plaintext HTTP requires explicit allow_private")
80
+ connection = http.client.HTTPConnection(hostname, port, timeout=self.timeout)
81
+ # Resolve once, then connect to that exact address. TLS still checks the hostname.
82
+ sock = socket.create_connection((ips[0], port), timeout=self.timeout)
83
+ try:
84
+ if parts.scheme == "https":
85
+ sock = ssl.create_default_context().wrap_socket(sock, server_hostname=hostname)
86
+ connection.sock = sock
87
+ target = (parts.path or "/") + ("?" + parts.query if parts.query else "")
88
+ connection.request(method, target, body, headers)
89
+ response = connection.getresponse()
90
+ data = response.read(MAX_BYTES + 1)
91
+ if len(data) > MAX_BYTES:
92
+ raise TransportError("HTTP response exceeds 2 MB limit")
93
+ if 300 <= response.status < 400:
94
+ raise TransportError("HTTP redirects are disabled")
95
+ return Response(response.status, dict(response.getheaders()), data)
96
+ finally:
97
+ connection.close()
98
+ sock.close()
99
+ except (OSError, ValueError, http.client.HTTPException):
100
+ raise TransportError("HTTP request failed; check URL, network, and TLS") from None
codapact/openapi.py ADDED
@@ -0,0 +1,294 @@
1
+ """Strict OpenAPI 3.0 translator for a deliberately bounded JSON API slice."""
2
+
3
+ import keyword
4
+ import re
5
+ from typing import Any
6
+
7
+ from jsonschema import Draft7Validator
8
+ from jsonschema.exceptions import SchemaError
9
+
10
+ from codapact.errors import CodaError
11
+ from codapact.loading import resolve
12
+ from codapact.model import Contract, Operation
13
+ from codapact.net import validate_url
14
+
15
+ IDENTIFIER = re.compile(r"[a-z][a-z0-9_]{0,79}\Z")
16
+ SCHEMA_KEYS = {
17
+ "type",
18
+ "properties",
19
+ "required",
20
+ "items",
21
+ "additionalProperties",
22
+ "enum",
23
+ "nullable",
24
+ "description",
25
+ "title",
26
+ "default",
27
+ "example",
28
+ "minimum",
29
+ "maximum",
30
+ "minLength",
31
+ "maxLength",
32
+ "minItems",
33
+ "maxItems",
34
+ "uniqueItems",
35
+ "minProperties",
36
+ "maxProperties",
37
+ }
38
+
39
+
40
+ def normalized_schema(schema: Any) -> dict[str, Any]:
41
+ if not isinstance(schema, dict) or set(schema) - SCHEMA_KEYS:
42
+ raise CodaError("unsupported schema keyword; see supported OpenAPI subset")
43
+ result = dict(schema)
44
+ kind = result.get("type")
45
+ if not isinstance(kind, str) or kind not in {
46
+ "object",
47
+ "array",
48
+ "string",
49
+ "integer",
50
+ "number",
51
+ "boolean",
52
+ }:
53
+ raise CodaError("schemas require an explicit supported type")
54
+ if kind == "object":
55
+ properties = result.get("properties", {})
56
+ if not isinstance(properties, dict):
57
+ raise CodaError("properties must be an object")
58
+ result["properties"] = {key: normalized_schema(value) for key, value in properties.items()}
59
+ additional = result.get("additionalProperties", True)
60
+ if isinstance(additional, dict):
61
+ result["additionalProperties"] = normalized_schema(additional)
62
+ elif not isinstance(additional, bool):
63
+ raise CodaError("invalid additionalProperties")
64
+ if not set(result.get("required", [])) <= properties.keys():
65
+ raise CodaError("required fields must be declared properties")
66
+ if kind == "array":
67
+ result["items"] = normalized_schema(result.get("items"))
68
+ nullable = result.pop("nullable", False)
69
+ if not isinstance(nullable, bool):
70
+ raise CodaError("nullable must be boolean")
71
+ if nullable:
72
+ result["type"] = [kind, "null"]
73
+ try:
74
+ Draft7Validator.check_schema(result)
75
+ except SchemaError:
76
+ raise CodaError("invalid schema constraints") from None
77
+ return result
78
+
79
+
80
+ def content_schema(item: dict[str, Any]) -> dict[str, Any] | None:
81
+ content = item.get("content", {})
82
+ if not content:
83
+ return None
84
+ if set(content) != {"application/json"}:
85
+ raise CodaError("only application/json bodies are supported")
86
+ if set(content["application/json"]) - {"schema", "example", "examples"}:
87
+ raise CodaError("unsupported media type feature")
88
+ return normalized_schema(content["application/json"].get("schema"))
89
+
90
+
91
+ class OpenAPITranslator:
92
+ api_version = 1
93
+ capabilities = frozenset({"json-http-v1", "ir-v1"})
94
+
95
+ def translate(self, document: dict[str, Any]) -> Contract:
96
+ try:
97
+ return self._translate(resolve(document))
98
+ except (KeyError, TypeError, AttributeError, ValueError):
99
+ raise CodaError("malformed OpenAPI document") from None
100
+
101
+ def _translate(self, doc: dict[str, Any]) -> Contract:
102
+ if doc.get("openapi") not in {"3.0.0", "3.0.1", "3.0.2", "3.0.3"}:
103
+ raise CodaError("supported OpenAPI versions: 3.0.0 through 3.0.3")
104
+ if (
105
+ not isinstance(doc.get("info"), dict)
106
+ or not all(
107
+ isinstance(doc["info"].get(key), str) and doc["info"][key]
108
+ for key in ("title", "version")
109
+ )
110
+ or not isinstance(doc.get("paths"), dict)
111
+ ):
112
+ raise CodaError("OpenAPI requires info.title, info.version, and a paths object")
113
+ if doc.get("callbacks") or doc.get("webhooks"):
114
+ raise CodaError("callbacks and webhooks are unsupported")
115
+ servers = doc.get("servers", [])
116
+ if len(servers) != 1 or servers[0].get("variables"):
117
+ raise CodaError("exactly one server without variables is required")
118
+ base_url = servers[0]["url"].rstrip("/")
119
+ validate_url(base_url, allow_query=False)
120
+ schemes = doc.get("components", {}).get("securitySchemes", {})
121
+ for scheme in schemes.values():
122
+ if scheme.get("type") == "http" and scheme.get("scheme") in {"bearer", "basic"}:
123
+ continue
124
+ if scheme.get("type") == "apiKey" and scheme.get("in") == "header":
125
+ name = scheme.get("name", "")
126
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]{0,79}", name):
127
+ raise CodaError("invalid API key header name")
128
+ if name.lower() in {
129
+ "host",
130
+ "content-length",
131
+ "transfer-encoding",
132
+ "connection",
133
+ "content-type",
134
+ "accept",
135
+ "expect",
136
+ "te",
137
+ "trailer",
138
+ "upgrade",
139
+ "proxy-authorization",
140
+ "proxy-connection",
141
+ }:
142
+ raise CodaError("unsafe API key header")
143
+ continue
144
+ raise CodaError("supported security: HTTP bearer/basic and header API key")
145
+ operations = []
146
+ names: set[str] = set()
147
+ for path, item in doc["paths"].items():
148
+ if (
149
+ not path.startswith("/")
150
+ or path.startswith("//")
151
+ or "?" in path
152
+ or "#" in path
153
+ or "\\" in path
154
+ or "%" in path
155
+ or any(ord(c) < 32 for c in path)
156
+ or not path.isascii()
157
+ or "{" in re.sub(r"\{[A-Za-z][A-Za-z0-9_-]*\}", "", path)
158
+ or "}" in re.sub(r"\{[A-Za-z][A-Za-z0-9_-]*\}", "", path)
159
+ ):
160
+ raise CodaError("invalid operation path")
161
+ if any(part in {".", ".."} for part in path.split("/")):
162
+ raise CodaError("path traversal is unsupported")
163
+ if item.get("servers") or item.get("$ref"):
164
+ raise CodaError("path-specific servers are unsupported")
165
+ for method, operation in item.items():
166
+ if method in {"parameters", "summary", "description"} or method.startswith("x-"):
167
+ continue
168
+ if method not in {"get", "post", "put", "patch", "delete", "head", "options"}:
169
+ raise CodaError("unsupported path item field")
170
+ if operation.get("callbacks") or operation.get("servers"):
171
+ raise CodaError("callbacks and operation-specific servers are unsupported")
172
+ name = operation.get("operationId", "")
173
+ if not IDENTIFIER.fullmatch(name) or keyword.iskeyword(name) or name in names:
174
+ raise CodaError(
175
+ "operationId must be unique lowercase snake_case (max 80 characters)"
176
+ )
177
+ names.add(name)
178
+ parameters: dict[tuple[str, str], dict[str, Any]] = {}
179
+ for parameter_list in (item.get("parameters", []), operation.get("parameters", [])):
180
+ pairs = [
181
+ (p["in"], p["name"].lower() if p["in"] == "header" else p["name"])
182
+ for p in parameter_list
183
+ ]
184
+ if len(set(pairs)) != len(pairs):
185
+ raise CodaError("duplicate parameter declarations")
186
+ for parameter in [*item.get("parameters", []), *operation.get("parameters", [])]:
187
+ if not isinstance(parameter.get("required", False), bool):
188
+ raise CodaError("parameter required must be boolean")
189
+ location = parameter["in"]
190
+ if location not in {"path", "query", "header"}:
191
+ raise CodaError("only scalar path/query/header parameters are supported")
192
+ if set(parameter) - {
193
+ "name",
194
+ "in",
195
+ "required",
196
+ "schema",
197
+ "description",
198
+ "deprecated",
199
+ "style",
200
+ "explode",
201
+ }:
202
+ raise CodaError("unsupported parameter feature")
203
+ expected = "form" if location == "query" else "simple"
204
+ if parameter.get("style", expected) != expected or parameter.get(
205
+ "explode", False
206
+ ):
207
+ raise CodaError("unsupported parameter serialization")
208
+ schema = normalized_schema(parameter["schema"])
209
+ if schema["type"] not in {"string", "integer", "number", "boolean"}:
210
+ raise CodaError("parameters must be non-null scalar types")
211
+ pname = parameter["name"]
212
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,79}", pname):
213
+ raise CodaError("unsupported parameter name")
214
+ if location == "header" and pname.lower() in {
215
+ "authorization",
216
+ "host",
217
+ "content-length",
218
+ "transfer-encoding",
219
+ "connection",
220
+ "accept",
221
+ "content-type",
222
+ *[s.get("name", "").lower() for s in schemes.values()],
223
+ }:
224
+ raise CodaError("reserved/auth headers must use runtime configuration")
225
+ if location == "path" and parameter.get("required") is not True:
226
+ raise CodaError("path parameters must be required")
227
+ parameters[location, pname.lower() if location == "header" else pname] = {
228
+ "name": pname,
229
+ "in": location,
230
+ "required": parameter.get("required", False),
231
+ "schema": schema,
232
+ }
233
+ if {p["name"] for p in parameters.values() if p["in"] == "path"} != set(
234
+ re.findall(r"\{([^{}]+)\}", path)
235
+ ):
236
+ raise CodaError("path placeholders and parameters must match")
237
+ if len({p["name"] for p in parameters.values()}) != len(parameters):
238
+ raise CodaError("parameter names must be unique across locations")
239
+ responses = {}
240
+ for status, response in operation["responses"].items():
241
+ if status != "default" and not re.fullmatch(r"[1-5][0-9]{2}", status):
242
+ raise CodaError("response keys must be exact HTTP status codes or default")
243
+ if response.get("links") or response.get("headers"):
244
+ raise CodaError(
245
+ "response links and declared response headers are unsupported"
246
+ )
247
+ schema = content_schema(response)
248
+ if schema is not None and (method == "head" or status in {"204", "205"}):
249
+ raise CodaError("HEAD, 204, and 205 responses cannot declare a JSON body")
250
+ responses[status] = schema
251
+ if not any(key.startswith("2") for key in responses):
252
+ raise CodaError("an explicit successful response is required")
253
+ security = operation.get("security", doc.get("security", []))
254
+ if not isinstance(security, list):
255
+ raise CodaError("security must be an OR list of AND requirement objects")
256
+ for alternative in security:
257
+ if not isinstance(alternative, dict) or any(
258
+ key not in schemes or scopes != [] for key, scopes in alternative.items()
259
+ ):
260
+ raise CodaError("invalid security requirement or unsupported OAuth scopes")
261
+ body = operation.get("requestBody", {})
262
+ if not isinstance(body.get("required", False), bool):
263
+ raise CodaError("request body required must be boolean")
264
+ if "requestBody" in operation and content_schema(body) is None:
265
+ raise CodaError("request body must declare JSON content")
266
+ if "requestBody" in operation and method in {"get", "head"}:
267
+ raise CodaError("GET and HEAD request bodies are unsupported")
268
+ operations.append(
269
+ Operation(
270
+ name,
271
+ method.upper(),
272
+ path,
273
+ tuple(parameters.values()),
274
+ content_schema(body),
275
+ body.get("required", False),
276
+ responses,
277
+ tuple(security),
278
+ str(operation.get("summary", "")),
279
+ )
280
+ )
281
+ if not operations:
282
+ raise CodaError("contract must contain operations")
283
+ types = {
284
+ name: normalized_schema(schema)
285
+ for name, schema in doc.get("components", {}).get("schemas", {}).items()
286
+ }
287
+ return Contract(
288
+ str(doc["info"]["title"]),
289
+ base_url,
290
+ tuple(sorted(operations, key=lambda op: op.id)),
291
+ schemes,
292
+ types,
293
+ {"source_format": "openapi-3.0", "pagination": "manual", "streaming": False},
294
+ )