xtr-http-client-contracts 4.0.2__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.
- xtr_http_client_contracts/__init__.py +47 -0
- xtr_http_client_contracts/exception/__init__.py +40 -0
- xtr_http_client_contracts/exception/client_error.py +16 -0
- xtr_http_client_contracts/exception/decoding_error.py +32 -0
- xtr_http_client_contracts/exception/http_client_error.py +21 -0
- xtr_http_client_contracts/exception/http_error.py +51 -0
- xtr_http_client_contracts/exception/invalid_argument_error.py +27 -0
- xtr_http_client_contracts/exception/json_error.py +19 -0
- xtr_http_client_contracts/exception/redirection_error.py +21 -0
- xtr_http_client_contracts/exception/server_error.py +15 -0
- xtr_http_client_contracts/exception/transport_error.py +31 -0
- xtr_http_client_contracts/exception/transport_timeout_error.py +27 -0
- xtr_http_client_contracts/http_client_interface.py +124 -0
- xtr_http_client_contracts/http_response_interface.py +148 -0
- xtr_http_client_contracts/options_defaults.py +69 -0
- xtr_http_client_contracts/py.typed +0 -0
- xtr_http_client_contracts/request_options.py +106 -0
- xtr_http_client_contracts/testing/__init__.py +12 -0
- xtr_http_client_contracts/testing/_fixture_request.py +184 -0
- xtr_http_client_contracts/testing/_fixture_response.py +80 -0
- xtr_http_client_contracts/testing/_fixture_routes.py +321 -0
- xtr_http_client_contracts/testing/http_client_test_case.py +1035 -0
- xtr_http_client_contracts/testing/test_http_server.py +213 -0
- xtr_http_client_contracts-4.0.2.dist-info/METADATA +271 -0
- xtr_http_client_contracts-4.0.2.dist-info/RECORD +27 -0
- xtr_http_client_contracts-4.0.2.dist-info/WHEEL +4 -0
- xtr_http_client_contracts-4.0.2.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""What a request asks for when nobody asked for anything in particular."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Final
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .request_options import RequestOptions
|
|
9
|
+
|
|
10
|
+
__all__ = ["OPTIONS_DEFAULTS"]
|
|
11
|
+
|
|
12
|
+
OPTIONS_DEFAULTS: Final[RequestOptions] = {
|
|
13
|
+
# No credentials are sent unless the caller asks for them.
|
|
14
|
+
"auth_basic": None,
|
|
15
|
+
"auth_bearer": None,
|
|
16
|
+
# Nothing is added to the URL, nothing is sent alongside it.
|
|
17
|
+
"query": {},
|
|
18
|
+
"headers": {},
|
|
19
|
+
"body": b"",
|
|
20
|
+
"json": None,
|
|
21
|
+
# Carried for the caller, never read.
|
|
22
|
+
"user_data": None,
|
|
23
|
+
# Enough hops for any sane chain, few enough to end a loop.
|
|
24
|
+
"max_redirects": 20,
|
|
25
|
+
# The best version the connection offers.
|
|
26
|
+
"http_version": None,
|
|
27
|
+
# Every URL is absolute until a caller says otherwise.
|
|
28
|
+
"base_uri": None,
|
|
29
|
+
# 60 seconds: long enough for a slow server, short enough to free the
|
|
30
|
+
# connection.
|
|
31
|
+
"timeout": None,
|
|
32
|
+
# Neither the transfer as a whole nor connecting is time limited: the
|
|
33
|
+
# idle timeout already ends a transfer nothing is coming in on.
|
|
34
|
+
"max_duration": 0.0,
|
|
35
|
+
"max_connect_duration": 0.0,
|
|
36
|
+
# Hosts are resolved the usual way, and reached without a proxy.
|
|
37
|
+
"resolve": {},
|
|
38
|
+
"proxy": None,
|
|
39
|
+
# A certificate is checked, and checked against the host asked for.
|
|
40
|
+
# Turning either off is a decision an application makes in the open.
|
|
41
|
+
"verify_peer": True,
|
|
42
|
+
"verify_host": True,
|
|
43
|
+
# The trust store and the client certificate are whatever the platform
|
|
44
|
+
# offers.
|
|
45
|
+
"cafile": None,
|
|
46
|
+
"capath": None,
|
|
47
|
+
"local_cert": None,
|
|
48
|
+
"local_pk": None,
|
|
49
|
+
"passphrase": None,
|
|
50
|
+
# How often a client that retries tries again.
|
|
51
|
+
"max_retries": 3,
|
|
52
|
+
# Additional options a client may ignore when it does not support them,
|
|
53
|
+
# unlike the regular options it must refuse; `use_persistent_connections`
|
|
54
|
+
# says whether to use persistent connections where supported.
|
|
55
|
+
"extra": {"use_persistent_connections": False},
|
|
56
|
+
}
|
|
57
|
+
"""Every option the contract names, mapped to what it means to leave it out.
|
|
58
|
+
|
|
59
|
+
A client merges what a request was given over its own defaults, and those
|
|
60
|
+
over this, so every option always has a value by the time a request is made.
|
|
61
|
+
Nothing here is an instruction — it is the absence of one, which is why the
|
|
62
|
+
defaults name no host, no credentials and no deadline.
|
|
63
|
+
|
|
64
|
+
The entries worth reading twice: ``timeout`` of ``None`` is not "no timeout"
|
|
65
|
+
but 60 seconds; ``max_duration`` and ``max_connect_duration`` of zero *are*
|
|
66
|
+
"no limit".
|
|
67
|
+
|
|
68
|
+
The mapping is shared, so merge into a copy rather than writing to it.
|
|
69
|
+
"""
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Everything a request can be asked to do, as one typed mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, TypedDict
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import AsyncIterable, Mapping, Sequence
|
|
9
|
+
|
|
10
|
+
__all__ = ["RequestOptions"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RequestOptions(TypedDict, total=False):
|
|
14
|
+
"""How a request is to be made, beyond its method and URL.
|
|
15
|
+
|
|
16
|
+
Every option is optional, and an option left out is one nobody asked
|
|
17
|
+
about: the client fills it in from its own defaults, which start at
|
|
18
|
+
:data:`OPTIONS_DEFAULTS
|
|
19
|
+
<xtr_http_client_contracts.options_defaults.OPTIONS_DEFAULTS>`. The same
|
|
20
|
+
keys are what :meth:`HttpClientInterface.with_options
|
|
21
|
+
<xtr_http_client_contracts.http_client_interface.HttpClientInterface.with_options>`
|
|
22
|
+
takes, where they become the defaults of the client it returns.
|
|
23
|
+
|
|
24
|
+
A name not listed here is refused. ``extra`` carries what one client
|
|
25
|
+
understands and another has no use for, so entries in it may be ignored.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
auth_basic: Credentials for basic authentication: ``"user:password"``,
|
|
29
|
+
or the user alone, or the two as a tuple. ``None`` sends none.
|
|
30
|
+
auth_bearer: A token to send as a bearer credential. ``None`` sends
|
|
31
|
+
none.
|
|
32
|
+
query: Parameters to merge into the URL's query string. A scalar is one
|
|
33
|
+
value; a sequence repeats the name once per item. ``None``, booleans
|
|
34
|
+
and nested mappings are not accepted. They win over a parameter of
|
|
35
|
+
the same name already in the URL.
|
|
36
|
+
headers: Headers to send, a mapping of name to one value or several.
|
|
37
|
+
body: What to send as the request body. Bytes or text go as they are;
|
|
38
|
+
a mapping is sent as an HTML form; an async iterable of bytes is
|
|
39
|
+
sent as it is produced, which is how a body too large to hold is
|
|
40
|
+
uploaded.
|
|
41
|
+
json: A value to send as a JSON body. The client encodes it and sets
|
|
42
|
+
a JSON content type unless ``headers`` already names one. Setting
|
|
43
|
+
both this and ``body`` is refused.
|
|
44
|
+
user_data: Anything the caller wants to find again on the response,
|
|
45
|
+
under the ``user_data`` entry of ``get_info()``. Never read by
|
|
46
|
+
the client.
|
|
47
|
+
max_redirects: How many redirects to follow. Zero or less follows
|
|
48
|
+
none, which leaves the 3xx to be answered with.
|
|
49
|
+
http_version: The HTTP version to ask for — ``"1.0"``, ``"1.1"``,
|
|
50
|
+
``"2"`` or ``"2.0"``. ``None`` leaves the client to use the best
|
|
51
|
+
version the connection offers.
|
|
52
|
+
base_uri: The URI a relative URL is resolved against. A sequence of
|
|
53
|
+
them is only for a client that retries, which moves along it from
|
|
54
|
+
one attempt to the next.
|
|
55
|
+
timeout: How long the transfer may go quiet for, in seconds. ``None``
|
|
56
|
+
is 60 seconds.
|
|
57
|
+
max_duration: How long the whole request and response may take, in
|
|
58
|
+
seconds. Zero is unlimited.
|
|
59
|
+
max_connect_duration: How long resolving, connecting and the TLS
|
|
60
|
+
handshake may take, in seconds. Zero is unlimited.
|
|
61
|
+
resolve: Host to IP address, used instead of asking a resolver. This
|
|
62
|
+
is what pins a request to an address that has already been
|
|
63
|
+
checked, so that nothing can change under it between the check
|
|
64
|
+
and the connection.
|
|
65
|
+
proxy: The proxy to go through, as a URL.
|
|
66
|
+
verify_peer: Whether to check that the peer's certificate is trusted.
|
|
67
|
+
verify_host: Whether to check that the certificate is for the host
|
|
68
|
+
being asked for.
|
|
69
|
+
cafile: A file of certificates to trust instead of the defaults.
|
|
70
|
+
capath: A directory of certificates to trust instead of the defaults.
|
|
71
|
+
local_cert: A client certificate to present.
|
|
72
|
+
local_pk: The private key of ``local_cert``, when it is kept apart.
|
|
73
|
+
passphrase: The passphrase of ``local_pk``.
|
|
74
|
+
max_retries: How many times a client that retries may try the request
|
|
75
|
+
again. A client that does not retry ignores it.
|
|
76
|
+
extra: Additional options a client may ignore when it does not
|
|
77
|
+
support them, unlike the regular options it must refuse:
|
|
78
|
+
``use_persistent_connections`` says whether to use persistent
|
|
79
|
+
connections where supported, ``trace_content`` whether a tracing
|
|
80
|
+
client keeps the bodies.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
auth_basic: str | tuple[str] | tuple[str, str] | None
|
|
84
|
+
auth_bearer: str | None
|
|
85
|
+
query: Mapping[str, str | int | float | Sequence[str | int | float]]
|
|
86
|
+
headers: Mapping[str, str | Sequence[str]]
|
|
87
|
+
body: bytes | str | Mapping[str, object] | AsyncIterable[bytes]
|
|
88
|
+
json: object
|
|
89
|
+
user_data: object
|
|
90
|
+
max_redirects: int
|
|
91
|
+
http_version: str | None
|
|
92
|
+
base_uri: str | Sequence[str] | None
|
|
93
|
+
timeout: float | None
|
|
94
|
+
max_duration: float
|
|
95
|
+
max_connect_duration: float
|
|
96
|
+
resolve: Mapping[str, str]
|
|
97
|
+
proxy: str | None
|
|
98
|
+
verify_peer: bool
|
|
99
|
+
verify_host: bool
|
|
100
|
+
cafile: str | None
|
|
101
|
+
capath: str | None
|
|
102
|
+
local_cert: str | None
|
|
103
|
+
local_pk: str | None
|
|
104
|
+
passphrase: str | None
|
|
105
|
+
max_retries: int
|
|
106
|
+
extra: Mapping[str, object]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""What an implementation of the client contract tests itself with.
|
|
2
|
+
|
|
3
|
+
Install it with the ``testing`` extra. Nothing here is imported by the contract itself, so an
|
|
4
|
+
application that only depends on the interfaces never pays for it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .http_client_test_case import HttpClientTestCase
|
|
10
|
+
from .test_http_server import TestHttpServer
|
|
11
|
+
|
|
12
|
+
__all__ = ["HttpClientTestCase", "TestHttpServer"]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""One request the fixture server read, and the variables its echo routes answer with.
|
|
2
|
+
|
|
3
|
+
The echo routes answer a JSON object describing the request they received, so a client test can
|
|
4
|
+
assert on what actually arrived. The names in that object are:
|
|
5
|
+
|
|
6
|
+
====================== ====================================================================
|
|
7
|
+
``SERVER_PROTOCOL`` the protocol token the request came in with, e.g. ``HTTP/1.1``
|
|
8
|
+
``SERVER_NAME`` the host the server is bound to, not the one the request asked for
|
|
9
|
+
``REQUEST_URI`` the raw request target, absolute-form and query string included
|
|
10
|
+
``REQUEST_METHOD`` the method, as sent
|
|
11
|
+
``AUTH_USER`` the user of the request's Basic credentials, when it carried any
|
|
12
|
+
``AUTH_PW`` their password, split off at the first colon
|
|
13
|
+
``REMOTE_ADDR`` the address the connection came from
|
|
14
|
+
``REMOTE_PORT`` its port, as a string
|
|
15
|
+
``HTTP_<NAME>`` every request header, upper-cased, dashes turned into underscores
|
|
16
|
+
====================== ====================================================================
|
|
17
|
+
|
|
18
|
+
The order is the one above: the fixed names first, then the headers as they arrived. A header
|
|
19
|
+
sent more than once is answered once, its values joined with ``", "``. ``AUTH_USER`` and
|
|
20
|
+
``AUTH_PW`` appear only when the request carried credentials this module could decode — the
|
|
21
|
+
fixture these routes answer like exposed the same two values under names belonging to its own
|
|
22
|
+
runtime, which would mean nothing here.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
from base64 import b64decode
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import TYPE_CHECKING, final
|
|
31
|
+
from urllib.parse import urlsplit
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import Sequence
|
|
35
|
+
|
|
36
|
+
__all__ = ["FixtureRequest", "read_request"]
|
|
37
|
+
|
|
38
|
+
_REQUEST_LINE_PARTS = 3
|
|
39
|
+
_BASIC = "basic"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@final
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class FixtureRequest:
|
|
45
|
+
"""What arrived on one connection, parsed no further than the routes need."""
|
|
46
|
+
|
|
47
|
+
method: str
|
|
48
|
+
target: str
|
|
49
|
+
protocol: str
|
|
50
|
+
headers: tuple[tuple[str, str], ...]
|
|
51
|
+
body: bytes
|
|
52
|
+
peer: tuple[str, str]
|
|
53
|
+
server_name: str
|
|
54
|
+
server_port: int
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def path(self) -> str:
|
|
58
|
+
"""The target's path, which is what the routes dispatch on.
|
|
59
|
+
|
|
60
|
+
An absolute target — what a client sends through a proxy — routes on its path too, so
|
|
61
|
+
``http://localhost:<port>/301/proxy`` reaches the same route as ``/301/proxy``.
|
|
62
|
+
"""
|
|
63
|
+
return urlsplit(self.target).path
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def query(self) -> str:
|
|
67
|
+
"""The target's query string, undecoded."""
|
|
68
|
+
return urlsplit(self.target).query
|
|
69
|
+
|
|
70
|
+
def header(self, name: str) -> str | None:
|
|
71
|
+
"""The named header, repeats joined, or ``None`` when it was not sent."""
|
|
72
|
+
return _joined(self.headers, name)
|
|
73
|
+
|
|
74
|
+
def credentials(self) -> tuple[str, str] | None:
|
|
75
|
+
"""The Basic user and password the request carried, or ``None``.
|
|
76
|
+
|
|
77
|
+
Anything that is not a decodable ``Basic`` token holding a colon counts as no
|
|
78
|
+
credentials at all, which is how a client sending a bearer token reaches a route that
|
|
79
|
+
only looks for Basic.
|
|
80
|
+
"""
|
|
81
|
+
authorization = self.header("authorization")
|
|
82
|
+
if authorization is None:
|
|
83
|
+
return None
|
|
84
|
+
scheme, _, token = authorization.partition(" ")
|
|
85
|
+
if scheme.lower() != _BASIC:
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
decoded = b64decode(token, validate=True).decode()
|
|
89
|
+
except ValueError:
|
|
90
|
+
return None
|
|
91
|
+
user, separator, password = decoded.partition(":")
|
|
92
|
+
return (user, password) if separator else None
|
|
93
|
+
|
|
94
|
+
def variables(self) -> dict[str, str]:
|
|
95
|
+
"""The object the echo routes answer with, in the order described for this module."""
|
|
96
|
+
variables = {
|
|
97
|
+
"SERVER_PROTOCOL": self.protocol,
|
|
98
|
+
"SERVER_NAME": self.server_name,
|
|
99
|
+
"REQUEST_URI": self.target,
|
|
100
|
+
"REQUEST_METHOD": self.method,
|
|
101
|
+
}
|
|
102
|
+
credentials = self.credentials()
|
|
103
|
+
if credentials is not None:
|
|
104
|
+
variables["AUTH_USER"], variables["AUTH_PW"] = credentials
|
|
105
|
+
variables["REMOTE_ADDR"] = self.peer[0]
|
|
106
|
+
variables["REMOTE_PORT"] = self.peer[1]
|
|
107
|
+
for name, _ in self.headers:
|
|
108
|
+
key = "HTTP_" + name.upper().replace("-", "_")
|
|
109
|
+
variables[key] = _joined(self.headers, name) or ""
|
|
110
|
+
return variables
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def read_request(
|
|
114
|
+
reader: asyncio.StreamReader,
|
|
115
|
+
writer: asyncio.StreamWriter,
|
|
116
|
+
server_name: str,
|
|
117
|
+
) -> FixtureRequest | None:
|
|
118
|
+
"""Read one request, or answer ``None`` for anything that is not one.
|
|
119
|
+
|
|
120
|
+
A connection that says nothing, or opens with something that is not a request line, gets
|
|
121
|
+
no answer at all: a client probing the port should see it closed, not a protocol error.
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
head = await reader.readuntil(b"\r\n\r\n")
|
|
125
|
+
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError):
|
|
126
|
+
return None
|
|
127
|
+
lines = head.decode("latin-1").split("\r\n")
|
|
128
|
+
parts = lines[0].split(" ")
|
|
129
|
+
if len(parts) != _REQUEST_LINE_PARTS or not parts[2].startswith("HTTP/"):
|
|
130
|
+
return None
|
|
131
|
+
method, target, protocol = parts
|
|
132
|
+
headers = tuple(
|
|
133
|
+
(name.strip(), value.strip())
|
|
134
|
+
for name, separator, value in (line.partition(":") for line in lines[1:] if line)
|
|
135
|
+
if separator
|
|
136
|
+
)
|
|
137
|
+
return FixtureRequest(
|
|
138
|
+
method=method,
|
|
139
|
+
target=target,
|
|
140
|
+
protocol=protocol,
|
|
141
|
+
headers=headers,
|
|
142
|
+
body=await _read_body(reader, headers),
|
|
143
|
+
peer=_peer(writer),
|
|
144
|
+
server_name=server_name,
|
|
145
|
+
server_port=_local_port(writer),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _joined(headers: Sequence[tuple[str, str]], name: str) -> str | None:
|
|
150
|
+
wanted = name.lower()
|
|
151
|
+
values = [value for key, value in headers if key.lower() == wanted]
|
|
152
|
+
return ", ".join(values) if values else None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _peer(writer: asyncio.StreamWriter) -> tuple[str, str]:
|
|
156
|
+
"""The address and port the connection came from, as text, which is how they are echoed."""
|
|
157
|
+
address: tuple[object, ...] = writer.get_extra_info("peername")
|
|
158
|
+
return str(address[0]), str(address[1])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _local_port(writer: asyncio.StreamWriter) -> int:
|
|
162
|
+
"""The port the connection reached, which a redirect back to this server must name."""
|
|
163
|
+
address: tuple[object, ...] = writer.get_extra_info("sockname")
|
|
164
|
+
return int(str(address[1]))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def _read_body(reader: asyncio.StreamReader, headers: Sequence[tuple[str, str]]) -> bytes:
|
|
168
|
+
transfer_encoding = _joined(headers, "transfer-encoding") or ""
|
|
169
|
+
if "chunked" in transfer_encoding.lower():
|
|
170
|
+
return await _read_chunks(reader)
|
|
171
|
+
declared = _joined(headers, "content-length")
|
|
172
|
+
length = int(declared) if declared is not None and declared.isdigit() else 0
|
|
173
|
+
return await reader.readexactly(length) if length else b""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def _read_chunks(reader: asyncio.StreamReader) -> bytes:
|
|
177
|
+
body = bytearray()
|
|
178
|
+
while True:
|
|
179
|
+
size = int((await reader.readline()).strip() or b"0", 16)
|
|
180
|
+
if size == 0:
|
|
181
|
+
_ = await reader.readline()
|
|
182
|
+
return bytes(body)
|
|
183
|
+
body += await reader.readexactly(size)
|
|
184
|
+
_ = await reader.readline()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""How the fixture server puts a response on the wire.
|
|
2
|
+
|
|
3
|
+
Responses are assembled as bytes rather than by a server framework: several routes answer
|
|
4
|
+
something deliberately malformed — a length that does not match the body, a declared encoding
|
|
5
|
+
the body does not use, framing that stops mid-chunk — and no framework would emit those.
|
|
6
|
+
|
|
7
|
+
Every response answers in the protocol its request came in with and closes the connection, so
|
|
8
|
+
a client never has to guess where a body that declares no length ends.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import gzip
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from http import HTTPStatus
|
|
17
|
+
from typing import TYPE_CHECKING, final
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from asyncio import StreamWriter
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
|
|
23
|
+
from ._fixture_request import FixtureRequest
|
|
24
|
+
|
|
25
|
+
__all__ = ["Answer", "compact", "compressed", "head", "length", "pretty", "send"]
|
|
26
|
+
|
|
27
|
+
_PHRASES = {status.value: status.phrase for status in HTTPStatus}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@final
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class Answer:
|
|
33
|
+
"""A status and the headers to answer with; the body follows them."""
|
|
34
|
+
|
|
35
|
+
status: int
|
|
36
|
+
headers: tuple[tuple[str, str], ...] = ()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def head(request: FixtureRequest, answer: Answer) -> bytes:
|
|
40
|
+
"""The status line and headers, ending with the blank line that opens the body."""
|
|
41
|
+
phrase = _PHRASES.get(answer.status, "Unknown")
|
|
42
|
+
lines = [f"{request.protocol} {answer.status} {phrase}"]
|
|
43
|
+
lines += [f"{name}: {value}" for name, value in answer.headers]
|
|
44
|
+
lines.append("Connection: close")
|
|
45
|
+
return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def send(writer: StreamWriter, *parts: bytes) -> None:
|
|
49
|
+
"""Write, and wait for it to leave: a client that hung up ends the route here."""
|
|
50
|
+
writer.write(b"".join(parts))
|
|
51
|
+
await writer.drain()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pretty(payload: Mapping[str, object]) -> bytes:
|
|
55
|
+
"""JSON indented as the echo routes answer it."""
|
|
56
|
+
return json.dumps(payload, indent=4, ensure_ascii=False).encode()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def compact(payload: Mapping[str, object]) -> str:
|
|
60
|
+
"""JSON with no spacing, for the payloads that travel in a header."""
|
|
61
|
+
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def length(body: bytes) -> tuple[str, str]:
|
|
65
|
+
"""The header declaring ``body``'s real length."""
|
|
66
|
+
return ("Content-Length", str(len(body)))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def compressed(
|
|
70
|
+
request: FixtureRequest,
|
|
71
|
+
payload: bytes,
|
|
72
|
+
) -> tuple[bytes, tuple[tuple[str, str], ...]]:
|
|
73
|
+
"""``payload`` gzipped and the headers saying so, when the request asked for gzip.
|
|
74
|
+
|
|
75
|
+
A request that did not ask gets the payload as it is, and no headers: a client must be
|
|
76
|
+
able to drive the same route both ways.
|
|
77
|
+
"""
|
|
78
|
+
if "gzip" not in (request.header("accept-encoding") or ""):
|
|
79
|
+
return payload, ()
|
|
80
|
+
return gzip.compress(payload), (("Content-Encoding", "gzip"), ("Vary", "Accept-Encoding"))
|