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,321 @@
|
|
|
1
|
+
"""What the fixture server answers, route by route.
|
|
2
|
+
|
|
3
|
+
Each route exists to put a client in one awkward position: a redirect it must follow or refuse,
|
|
4
|
+
a length that does not match the body, a body compressed only in name, a transfer that never
|
|
5
|
+
ends. ``_fixture_response`` holds how any of it reaches the wire.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
from typing import TYPE_CHECKING, Final
|
|
13
|
+
from urllib.parse import parse_qsl
|
|
14
|
+
|
|
15
|
+
from ._fixture_response import Answer, compact, compressed, head, length, pretty, send
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from asyncio import StreamWriter
|
|
19
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
20
|
+
from typing import TypeAlias
|
|
21
|
+
|
|
22
|
+
from ._fixture_request import FixtureRequest
|
|
23
|
+
|
|
24
|
+
_Route: TypeAlias = Callable[[FixtureRequest, StreamWriter], Awaitable[None]]
|
|
25
|
+
|
|
26
|
+
__all__ = ["respond"]
|
|
27
|
+
|
|
28
|
+
# The standard library's decoder answers anything at all; saying here what this module asks
|
|
29
|
+
# of it keeps that looseness to one line, guarded by the check in `_json_fields`.
|
|
30
|
+
_decode_object: Final[Callable[[bytes], dict[str, object]]] = json.loads
|
|
31
|
+
|
|
32
|
+
_JSON: Final = ("Content-Type", "application/json")
|
|
33
|
+
_HTML: Final = ("Content-Type", "text/html; charset=UTF-8")
|
|
34
|
+
_TEXT: Final = ("Content-Type", "text/plain")
|
|
35
|
+
_ETAG: Final = ("ETag", '"abc123"')
|
|
36
|
+
_EXPECTED_BASIC: Final = "Basic Zm9vOmJhcg=="
|
|
37
|
+
_HEAD_DELAY: Final = 0.3
|
|
38
|
+
_BODY_DELAY: Final = 0.5
|
|
39
|
+
_HINT_DELAY: Final = 0.001
|
|
40
|
+
_SILENCE: Final = 1.0
|
|
41
|
+
_TICK: Final = 0.0005
|
|
42
|
+
|
|
43
|
+
# The hints and the answer after them are written verbatim: an interim response is not
|
|
44
|
+
# something a client library can be asked to produce, so the test server hands over the bytes.
|
|
45
|
+
_EARLY_HINTS: Final = (
|
|
46
|
+
b"HTTP/1.1 103 Early Hints\r\n"
|
|
47
|
+
b"Link: </style.css>; rel=preload; as=style\r\n"
|
|
48
|
+
b"Link: </script.js>; rel=preload; as=script\r\n"
|
|
49
|
+
b"\r\n"
|
|
50
|
+
)
|
|
51
|
+
_AFTER_HINTS: Final = (
|
|
52
|
+
b"HTTP/1.1 200 OK\r\n"
|
|
53
|
+
b"Date: Fri, 26 May 2017 10:02:11 GMT\r\n"
|
|
54
|
+
b"Content-Length: 13\r\n"
|
|
55
|
+
b"\r\n"
|
|
56
|
+
b"Here the body"
|
|
57
|
+
)
|
|
58
|
+
_CHUNKS: Final = b"8\r\nchunked \r\n5\r\ndata \r\n6\r\nworks!\r\n0\r\n\r\n"
|
|
59
|
+
_TRUNCATED_CHUNKS: Final = b"8\r\nchunked \r\n5\r\ndata \r\n6\r\nw"
|
|
60
|
+
_NOT_GZIP: Final = b"-" * 1000
|
|
61
|
+
_DOCUMENTS: Final = {"documents": [{"id": "/json/1"}, {"id": "/json/2"}, {"id": "/json/3"}]}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _echoing(status: int, *headers: tuple[str, str]) -> _Route:
|
|
65
|
+
"""A route answering ``status``, ``headers`` and the request's own variables as JSON."""
|
|
66
|
+
|
|
67
|
+
async def route(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
68
|
+
body = pretty(request.variables())
|
|
69
|
+
answer = Answer(status, (*headers, _JSON, length(body)))
|
|
70
|
+
await send(writer, head(request, answer), body)
|
|
71
|
+
|
|
72
|
+
return route
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _redirecting(status: int, host: str, path: str) -> _Route:
|
|
76
|
+
"""An echoing route that sends the client to ``path`` on this same server, under ``host``.
|
|
77
|
+
|
|
78
|
+
The port comes from the connection, because the server listens wherever it was given a
|
|
79
|
+
free port.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
async def route(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
83
|
+
location = f"http://{host}:{request.server_port}{path}"
|
|
84
|
+
await _echoing(status, ("Location", location))(request, writer)
|
|
85
|
+
|
|
86
|
+
return route
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_PLAIN: Final[_Route] = _echoing(200)
|
|
90
|
+
_MOVED: Final[_Route] = _redirecting(301, "127.0.0.1", "/302")
|
|
91
|
+
_TAGGED: Final[_Route] = _echoing(200, _ETAG)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _nothing(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
95
|
+
"""An unrouted path answers an empty 200, so a client sees a valid, useless response."""
|
|
96
|
+
await send(writer, head(request, Answer(200, (_HTML, ("Content-Length", "0")))))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _root(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
100
|
+
"""The variables, gzipped when the request asked for it."""
|
|
101
|
+
body, encoding = compressed(request, pretty(request.variables()))
|
|
102
|
+
answer = Answer(200, (*encoding, _JSON, length(body)))
|
|
103
|
+
await send(writer, head(request, answer), body)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _withheld_body(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
107
|
+
"""Declares the length of a body a HEAD request will not receive."""
|
|
108
|
+
variables = request.variables()
|
|
109
|
+
body = pretty(variables)
|
|
110
|
+
headers = (("X-Request-Vars", compact(variables)), _JSON, length(body))
|
|
111
|
+
await send(
|
|
112
|
+
writer,
|
|
113
|
+
head(request, Answer(200, headers)),
|
|
114
|
+
b"" if request.method == "HEAD" else body,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _early_hints(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
119
|
+
"""Hints first, the answer after, so a client must keep reading past a 1xx."""
|
|
120
|
+
del request
|
|
121
|
+
await send(writer, _EARLY_HINTS)
|
|
122
|
+
await asyncio.sleep(_HINT_DELAY)
|
|
123
|
+
await send(writer, _AFTER_HINTS)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def _gzipped_not_found(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
127
|
+
"""A 404 whose head arrives long before its compressed body."""
|
|
128
|
+
body, encoding = compressed(request, b"some text")
|
|
129
|
+
await send(writer, head(request, Answer(404, (_TEXT, *encoding))))
|
|
130
|
+
await asyncio.sleep(_HEAD_DELAY)
|
|
131
|
+
await send(writer, body)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _credentialed_redirect(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
135
|
+
"""Redirects only to a request carrying the credentials the fixture expects."""
|
|
136
|
+
route = _MOVED if request.header("authorization") == _EXPECTED_BASIC else _PLAIN
|
|
137
|
+
await route(request, writer)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def _found(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
141
|
+
"""Redirects until a request arrives with credentials, which ends the chain."""
|
|
142
|
+
if request.header("authorization") is not None:
|
|
143
|
+
await _PLAIN(request, writer)
|
|
144
|
+
return
|
|
145
|
+
targets = [value for name, value in _parameters(request) if name == "location"]
|
|
146
|
+
location = targets[-1] if targets else f"http://localhost:{request.server_port}/"
|
|
147
|
+
await _echoing(302, ("Location", location))(request, writer)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _not_modified(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
151
|
+
"""Declares ten bytes and sends five, under a status allowed no body at all."""
|
|
152
|
+
await send(writer, head(request, Answer(304, (("Content-Length", "10"),))), b"12345")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def _etag_route(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
156
|
+
"""Answers the variables, or nothing at all to a request that already has them."""
|
|
157
|
+
if request.header("if-none-match") is None:
|
|
158
|
+
await _TAGGED(request, writer)
|
|
159
|
+
return
|
|
160
|
+
await send(writer, head(request, Answer(304, (_ETAG,))))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def _broken_length(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
164
|
+
"""Declares a length the body never reaches, so a client waits for bytes that never come."""
|
|
165
|
+
body = pretty(request.variables())
|
|
166
|
+
answer = Answer(200, (("Content-Length", "1000"), _JSON))
|
|
167
|
+
await send(writer, head(request, answer), body)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def _echo_posted(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
171
|
+
"""Answers what was sent: a form's fields, or a JSON body's keys and its content type."""
|
|
172
|
+
body = pretty(_posted(request))
|
|
173
|
+
answer = Answer(200, (_JSON, length(body)))
|
|
174
|
+
await send(writer, head(request, answer), body)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _slow_head(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
178
|
+
"""Says nothing at all for long enough to trip a timeout before the status arrives."""
|
|
179
|
+
await asyncio.sleep(_HEAD_DELAY)
|
|
180
|
+
await _PLAIN(request, writer)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def _slow_body(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
184
|
+
"""Answers, then pauses mid-body long enough to trip a timeout between chunks."""
|
|
185
|
+
await send(writer, head(request, Answer(200, (_HTML,))), b"<1>")
|
|
186
|
+
await asyncio.sleep(_BODY_DELAY)
|
|
187
|
+
await send(writer, b"<2>")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _endless(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
191
|
+
"""Writes until the client goes away, so only a deadline ends the transfer."""
|
|
192
|
+
await send(writer, head(request, Answer(200, (_HTML,))), b"<1>")
|
|
193
|
+
while not writer.is_closing():
|
|
194
|
+
await asyncio.sleep(_TICK)
|
|
195
|
+
await send(writer, b"<1>")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def _silent_then_endless(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
199
|
+
"""Silent for a second, then endless: a deadline may fall on either side of the status."""
|
|
200
|
+
await asyncio.sleep(_SILENCE)
|
|
201
|
+
await _endless(request, writer)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def _chunked(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
205
|
+
"""A body framed in three chunks, as a client reading chunk by chunk should see it."""
|
|
206
|
+
answer = Answer(200, (_HTML, ("Transfer-Encoding", "chunked")))
|
|
207
|
+
await send(writer, head(request, answer), _CHUNKS)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def _chunked_broken(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
211
|
+
"""Stops inside the third chunk, leaving the framing unfinished."""
|
|
212
|
+
answer = Answer(200, (_HTML, ("Transfer-Encoding", "chunked")))
|
|
213
|
+
await send(writer, head(request, answer), _TRUNCATED_CHUNKS)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def _gzip_broken(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
217
|
+
"""Announces gzip and sends something else, so decoding must fail, not the transfer."""
|
|
218
|
+
answer = Answer(200, (_HTML, ("Content-Encoding", "gzip"), length(_NOT_GZIP)))
|
|
219
|
+
await send(writer, head(request, answer), _NOT_GZIP)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def _json_index(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
223
|
+
"""Three document ids, for a client fetching them one after another."""
|
|
224
|
+
body = compact(_DOCUMENTS).encode()
|
|
225
|
+
await send(writer, head(request, Answer(200, (_JSON, length(body)))), body)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def _json_document(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
229
|
+
"""A document titled after the target it was asked for."""
|
|
230
|
+
body = compact({"title": request.target}).encode()
|
|
231
|
+
await send(writer, head(request, Answer(200, (_JSON, length(body)))), body)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def _custom(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
235
|
+
"""Answers the status and headers the query string asks for."""
|
|
236
|
+
status = 200
|
|
237
|
+
headers: list[tuple[str, str]] = []
|
|
238
|
+
for name, value in _parameters(request):
|
|
239
|
+
if name == "status" and value.isdigit():
|
|
240
|
+
status = int(value)
|
|
241
|
+
elif name == "headers[]":
|
|
242
|
+
field, _, content = value.partition(":")
|
|
243
|
+
# The echo sets its own content type last, so asking for another is pointless.
|
|
244
|
+
if field.strip().lower() != "content-type":
|
|
245
|
+
headers.append((field.strip(), content.strip()))
|
|
246
|
+
await _echoing(status, *headers)(request, writer)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _parameters(request: FixtureRequest) -> list[tuple[str, str]]:
|
|
250
|
+
return parse_qsl(request.query, keep_blank_values=True)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _posted(request: FixtureRequest) -> dict[str, object]:
|
|
254
|
+
"""The fields a request carried, named as the echo answers them.
|
|
255
|
+
|
|
256
|
+
A form is answered as its fields. Anything else is read as JSON, and the content type is
|
|
257
|
+
answered alongside its keys — ``?`` when the request named none, so a client can tell a
|
|
258
|
+
missing header from an empty one. The method is answered last, unless a field claimed
|
|
259
|
+
the name first.
|
|
260
|
+
"""
|
|
261
|
+
fields = _form_fields(request)
|
|
262
|
+
if not fields:
|
|
263
|
+
fields = _json_fields(request.body)
|
|
264
|
+
fields["content-type"] = request.header("content-type") or "?"
|
|
265
|
+
if "REQUEST_METHOD" not in fields:
|
|
266
|
+
fields["REQUEST_METHOD"] = request.method
|
|
267
|
+
return fields
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _form_fields(request: FixtureRequest) -> dict[str, object]:
|
|
271
|
+
content_type = request.header("content-type") or ""
|
|
272
|
+
if request.method != "POST" or not content_type.startswith("application/x-www-form-urlencoded"):
|
|
273
|
+
return {}
|
|
274
|
+
return dict(parse_qsl(request.body.decode("latin-1")))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _json_fields(body: bytes) -> dict[str, object]:
|
|
278
|
+
"""The keys of a JSON object body; anything else carries no fields to echo."""
|
|
279
|
+
if not body.lstrip().startswith(b"{"):
|
|
280
|
+
return {}
|
|
281
|
+
try:
|
|
282
|
+
return _decode_object(body)
|
|
283
|
+
except ValueError:
|
|
284
|
+
return {}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
_ROUTES: Final[Mapping[str, _Route]] = {
|
|
288
|
+
"/": _root,
|
|
289
|
+
"/103": _early_hints,
|
|
290
|
+
"/301": _credentialed_redirect,
|
|
291
|
+
"/301/bad-tld": _echoing(301, ("Location", "http://foo.example.")),
|
|
292
|
+
"/301/invalid": _echoing(301, ("Location", "//?foo=bar")),
|
|
293
|
+
"/301/proxy": _redirecting(301, "localhost", "/"),
|
|
294
|
+
"/302": _found,
|
|
295
|
+
"/302/relative": _echoing(302, ("Location", "..")),
|
|
296
|
+
"/304": _not_modified,
|
|
297
|
+
"/304/etag": _etag_route,
|
|
298
|
+
"/307": _redirecting(307, "localhost", "/post"),
|
|
299
|
+
"/404": _echoing(404),
|
|
300
|
+
"/404-gzipped": _gzipped_not_found,
|
|
301
|
+
"/chunked": _chunked,
|
|
302
|
+
"/chunked-broken": _chunked_broken,
|
|
303
|
+
"/custom": _custom,
|
|
304
|
+
"/gzip-broken": _gzip_broken,
|
|
305
|
+
"/head": _withheld_body,
|
|
306
|
+
"/json": _json_index,
|
|
307
|
+
"/json/1": _json_document,
|
|
308
|
+
"/json/2": _json_document,
|
|
309
|
+
"/json/3": _json_document,
|
|
310
|
+
"/length-broken": _broken_length,
|
|
311
|
+
"/max-duration": _endless,
|
|
312
|
+
"/post": _echo_posted,
|
|
313
|
+
"/timeout-body": _slow_body,
|
|
314
|
+
"/timeout-header": _slow_head,
|
|
315
|
+
"/timeout-long": _silent_then_endless,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def respond(request: FixtureRequest, writer: StreamWriter) -> None:
|
|
320
|
+
"""Answer ``request`` on ``writer``, dispatching on the target's path."""
|
|
321
|
+
await _ROUTES.get(request.path, _nothing)(request, writer)
|