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