userver: en/testsuite/utils/http.py Source File
Loading...
Searching...
No Matches
http.py
1import email
2import json
3import typing
4import urllib.parse
5
6import aiohttp.web
7
8CONTENT_IN_GET_REQUEST_ERROR = (
9 'GET requests cannot have content, but Content-Length header was sent.'
10)
11CHUNKED_CONTENT_IN_GET_REQUEST_ERROR = (
12 "GET requests cannot have content, but 'Transfer-Encoding: chunked' "
13 'header was sent.'
14)
15MULTIPART_MIME_PATTERN = """MIME-Version: 1.0
16Content-Type: %s
17
18%s"""
19
20
21class BaseError(Exception):
22 pass
23
24
26 """Base class for mockserver mocked errors."""
27
28 error_code = 'unknown'
29
30
31class TimeoutError(MockedError): # pylint: disable=redefined-builtin
32 """Exception used to mock HTTP client timeout errors.
33
34 Requires service side support.
35
36 Available as ``mockserver.TimeoutError`` alias
37 or by full name ``testsuite.utils.http.TimeoutError``.
38 """
39
40 error_code = 'timeout'
41
42
44 """Exception used to mock HTTP client network errors.
45
46 Requires service side support.
47
48 Available as ``mockserver.NetworkError`` alias
49 or by full name ``testsuite.utils.http.NetworkError``.
50 """
51
52 error_code = 'network'
53
54
56 def __init__(self, *, url: str, status: int):
57 self.url = url
58 self.status = status
59 super().__init__(f"status={self.status}, url='{self.url}'")
60
61
63 """Invalid request which cannot be wrapped"""
64
65
66class Request:
67 """Adapts aiohttp.web.BaseRequest to mimic a frequently used subset of
68 werkzeug.Request interface. ``data`` property is not supported,
69 use get_data() instead.
70 """
71
72 def __init__(self, request: aiohttp.web.BaseRequest, data: bytes):
73 self._request = request
74 self._data: bytes = data
75 self._json: object = None
76 self._form: dict[str, str] | None = None
77
78 @property
79 def method(self) -> str:
80 return self._request.method
81
82 @property
83 def url(self) -> str:
84 return str(self._request.url)
85
86 @property
87 def path(self) -> str:
88 return self._request.path
89
90 # For backward compatibility with code using aiohttp.web.BaseRequest
91 @property
92 def path_qs(self) -> str:
93 return self._request.raw_path
94
95 @property
96 def query_string(self) -> bytes:
97 path_and_query = self._request.raw_path.split('?')
98 if len(path_and_query) < 2:
99 return b''
100 return path_and_query[1].encode()
101
102 @property
103 def headers(self):
104 return self._request.headers
105
106 @property
107 def content_type(self):
108 return self._request.content_type
109
110 def get_data(self) -> bytes:
111 return self._data
112
113 @property
114 def form(self):
115 if self._form is None:
116 if self._request.content_type in (
117 '',
118 'application/x-www-form-urlencoded',
119 ):
120 charset = self._request.charset or 'utf-8'
121 items = urllib.parse.parse_qsl(
122 self._data.rstrip().decode(charset),
123 keep_blank_values=True,
124 encoding=charset,
125 )
126 self._form = dict(items)
127 elif self._request.content_type.startswith('multipart/form-data'):
128 charset = self._request.charset or 'utf-8'
129 epost_data = MULTIPART_MIME_PATTERN % (
130 self._request.headers['content-type'],
131 self._data.rstrip().decode(charset),
132 )
133 data = email.message_from_string(epost_data)
134 assert data.is_multipart()
135
136 self._form = {}
137 for part in data.get_payload():
138 name = part.get_param('name', header='content-disposition')
139 payload = part.get_payload(decode=True).decode(charset)
140 try:
141 payload = int(payload)
142 except ValueError:
143 pass
144 self._form[name] = payload # type: ignore[index]
145
146 else:
147 self._form = {}
148
149 return self._form
150
151 @property
152 def json(self) -> typing.Any:
153 if self._json is None:
154 bytes_body = self.get_data()
155 encoding = self._request.charset or 'utf-8'
156 str_body = bytes_body.decode(encoding)
157 self._json = json.loads(str_body)
158 return self._json
159
160 @property
161 def cookies(self) -> typing.Mapping[str, str]:
162 return self._request.cookies
163
164 @property
165 def args(self):
166 return self._request.query
167
168 # For backward compatibility with code using aiohttp.web.BaseRequest
169 @property
170 def query(self):
171 return self._request.query
172
173
175 pass
176
177
178async def wrap_request(request: aiohttp.web.BaseRequest) -> Request:
179 if request.method == 'GET':
180 if request.content_length:
181 raise InvalidRequestError(CONTENT_IN_GET_REQUEST_ERROR)
182 if request.headers.get('Transfer-Encoding', '') == 'chunked':
183 raise InvalidRequestError(CHUNKED_CONTENT_IN_GET_REQUEST_ERROR)
184 if request.headers.get('expect') == '100-continue':
185 await request.writer.write(b'HTTP/1.1 100 Continue\r\n\r\n')
186 await request.writer.drain()
187 data = await request.content.read()
188 return Request(request, data)
189
190
192 def __init__(
193 self,
194 body: bytes | bytearray | None = None,
195 text: str | None = None,
196 status: int = 200,
197 headers: typing.Mapping[str, str] | None = None,
198 content_type: str | None = None,
199 charset: str | None = None,
200 ):
201 if body and text:
202 raise RuntimeError(
203 'Response params "body" and "text" can not be used at the same time'
204 )
205
206 self._body = body
207 self._text = text
208 self._status = status
209 self._headers = headers
210 self._content_type = content_type
211 self._charset = charset
212
213 def __repr__(self):
214 return (
215 f'<{self.__class__.__name__} body={self._body!r} '
216 f'text={self._text} status={self._status} content_type={self._content_type} charset={self._charset}>'
217 )
218
219 def to_aiohttp(self) -> aiohttp.web.Response:
220 return aiohttp.web.Response(
221 body=self._body,
222 text=self._text,
223 status=self._status,
224 headers=self._headers,
225 content_type=self._content_type,
226 charset=self._charset,
227 )
228
229
231 def __init__(
232 self,
233 response: aiohttp.ClientResponse,
234 content: bytes,
235 *,
236 json_loads,
237 ):
238 self._response = response
239 self._content: bytes = content
240 self._text: str | None = None
241 self._form: dict[str, str] | None = None
242 self._json_loads = json_loads
243
244 def __repr__(self):
245 return (
246 f'<{self.__class__.__name__} method={self._response.method} '
247 f'url={self._response.url} status={self.status} content={self.content!r}>'
248 )
249
250 @property
251 def status_code(self) -> int:
252 return self._response.status
253
254 # For backward compatibility with code using async ClientResponse
255 @property
256 def status(self) -> int:
257 return self._response.status
258
259 @property
260 def reason(self) -> str | None:
261 return self._response.reason
262
263 @property
264 def content(self) -> bytes:
265 return self._content
266
267 @property
268 def text(self) -> str:
269 if self._text is None:
270 encoding = self._response.get_encoding()
271 self._text = str(self._content, encoding)
272 return self._text
273
274 def json(self) -> typing.Any:
275 return self._json_loads(self.text)
276
277 @property
278 def form(self):
279 if self._form is None:
280 if self.content_type in ('', 'application/x-www-form-urlencoded'):
281 items = urllib.parse.parse_qsl(
282 self.text,
283 keep_blank_values=True,
284 encoding=self.encoding,
285 )
286 self._form = dict(items)
287 else:
288 self._form = {}
289
290 return self._form
291
292 @property
293 def headers(self):
294 return self._response.headers
295
296 @property
297 def content_type(self):
298 return self._response.content_type
299
300 @property
301 def encoding(self):
302 return self._response.get_encoding()
303
304 @property
305 def cookies(self):
306 return self._response.cookies
307
308 def raise_for_status(self) -> None:
309 if self._response.status < 400:
310 return
311 self._response.release()
312 raise HttpResponseError(
313 url=str(self._response.request_info.url),
314 status=self._response.status,
315 )
316
317
318async def wrap_client_response(
319 response: aiohttp.ClientResponse,
320 *,
321 json_loads=json.loads,
322):
323 content = await response.read()
324 wrapped = ClientResponse(response, content, json_loads=json_loads)
325 return wrapped
326
327
328def make_response(
329 response: str | bytes | bytearray | None = None,
330 status: int = 200,
331 headers: typing.Mapping[str, str] | None = None,
332 content_type: str | None = None,
333 charset: str | None = None,
334 *,
335 json=_NoValue,
336 form=_NoValue,
337) -> Response:
338 """
339 Create HTTP response object. Returns ``Response`` instance.
340
341 :param response: response content
342 :param status: HTTP status code
343 :param headers: HTTP headers dictionary
344 :param content_type: HTTP Content-Type header
345 :param charset: Response character set
346 :param json: JSON response shortcut
347 :param form: x-www-form-urlencoded response shortcut
348 """
349 if json is not _NoValue and form is not _NoValue:
350 raise RuntimeError(
351 'Response params "json" and "form" can not be used '
352 'at the same time',
353 )
354 if json is not _NoValue:
355 response = _json_response(json)
356 if content_type is None:
357 content_type = 'application/json'
358 if form is not _NoValue:
359 response = _form_response(form)
360 if content_type is None:
361 content_type = 'application/x-www-form-urlencoded'
362
363 if isinstance(response, (bytes, bytearray)):
364 return Response(
365 body=response,
366 status=status,
367 headers=headers,
368 content_type=content_type,
369 charset=charset,
370 )
371 if isinstance(response, str):
372 return Response(
373 text=response,
374 status=status,
375 headers=headers,
376 content_type=content_type,
377 charset=charset,
378 )
379 if response is None:
380 return Response(
381 headers=headers,
382 status=status,
383 content_type=content_type,
384 charset=charset,
385 )
386 raise RuntimeError(f'Unsupported response {response!r} given')
387
388
389def _json_response(data: typing.Any) -> bytes:
390 text = json.dumps(data, ensure_ascii=False)
391 return text.encode('utf-8')
392
393
394def _form_response(data: typing.Any) -> bytes:
395 text = urllib.parse.urlencode(data)
396 return text.encode('utf-8')