Coverage for pyTooling/REST/__init__.py: 98%
237 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 13:06 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 13:06 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _____ ____ _____ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \| ____/ ___|_ _| #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |_) | _| \___ \ | | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| _ <| |___ ___) || | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_| \_\_____|____/ |_| #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A small client for JSON REST APIs, built on the standard library.
34Every REST API is read the same way: a request carrying a bearer token, an answer that has to be a JSON object, a
35transient failure that is worth another attempt, and a collection that arrives one page at a time. None of that is
36knowledge about a particular service, so it lives here rather than in each reader.
38.. hint::
40 See :ref:`high-level help <REST>` for explanations and usage examples.
42.. seealso::
44 `pyTooling.GitHub <https://pyTooling.github.io/pyTooling.GitHub/>`__
45 |rarr| A data model read from a REST API through this client.
46"""
47from http import HTTPMethod
48from json import dumps as json_dumps, loads as json_loads
49from re import compile as re_compile
50from time import sleep
51from typing import Any, Optional as Nullable, Union
52from urllib.error import HTTPError, URLError
53from urllib.request import Request, urlopen
55from pyTooling.Common import getFullyQualifiedName, StringEnum
56from pyTooling.Decorators import export, readonly
57from pyTooling.Exceptions import ToolingException
58from pyTooling.GenericPath.URL import URL, URLError as URLParseError
59from pyTooling.MetaClasses import ExtendedType
62__all__ = ["JSONObject"]
64JSONObject = dict[str, Any]
65"""A JSON object, as :func:`json.loads` returns it."""
67TRANSIENT_HTTP_STATUS = (429, 500, 502, 503, 504)
68"""HTTP status codes of a transient failure, after which a request is tried again."""
70MAXIMUM_RETRY_AFTER = 60.0
71"""The longest pause in seconds a ``Retry-After`` header can demand before a request is tried again."""
73_NEXT_LINK = re_compile(r'<([^>]+)>;\s*rel="next"')
74"""Pattern extracting the URL of the next page from a :rfc:`8288` ``Link`` header."""
77@export
78class MediaType(StringEnum):
79 """
80 Media types a REST API sends and receives, as :rfc:`9110` calls them.
82 A member is its own media type, so it is written where a header's value is expected.
83 """
85 JSON = "application/json" #: A JSON document.
86 PlainText = "text/plain" #: Plain text.
87 HTML = "text/html" #: An HTML document.
88 Binary = "application/octet-stream" #: Bytes of an unnamed type.
90 def Matches(self, contentType: str) -> bool:
91 """
92 Check whether a ``Content-Type`` header names this media type.
94 The header's parameters are ignored, the name is compared case-insensitively - :rfc:`9110` writes a media type
95 that way - and the structured syntax suffix of :rfc:`6839` counts, so ``application/vnd.github+json`` is
96 ``application/json``.
98 :param contentType: The value of a ``Content-Type`` header.
99 :returns: ``True``, if the header names this media type.
100 :raises ValueError: If parameter 'contentType' is ``None``.
101 :raises TypeError: If parameter 'contentType' is not of type :class:`str`.
102 """
103 if contentType is None:
104 raise ValueError("Parameter 'contentType' is None.")
105 elif not isinstance(contentType, str):
106 ex = TypeError("Parameter 'contentType' is not of type 'str'.")
107 ex.add_note(f"Got type '{getFullyQualifiedName(contentType)}'.")
108 raise ex
110 mediaType = contentType.split(";", 1)[0].strip().lower()
112 return mediaType == self or mediaType.endswith(f"+{self.partition('/')[2]}")
115@export
116class RESTError(ToolingException):
117 """A request to a REST API failed, or its answer wasn't the JSON the caller asked for."""
120@export
121class RESTClient(metaclass=ExtendedType, slots=True):
122 """
123 Reads and writes JSON resources of a REST API.
125 The requests use only the standard library, so a package building on this client doesn't drag an HTTP stack into
126 every consumer. A resource is addressed by its path below :attr:`APIURL`, not by a URL, so a client says
127 ``repos/owner/name`` and the API it belongs to is stated once.
129 A token, when given, is sent as a bearer token - and only to the API it was given for, which is why a paginated
130 answer pointing outside that API is rejected. An API authorizing differently overrides :meth:`_RequestHeaders`,
131 which is where the header is built.
133 A request failing transiently - a status in :data:`TRANSIENT_HTTP_STATUS`, a timeout, or an unreachable API - is
134 tried again after an exponentially growing pause, or one as long as a ``Retry-After`` header demands. A request
135 failing with any other HTTP status, like 401, 403 or 404, isn't tried again, because another attempt can't
136 succeed. **A request that isn't idempotent is never tried again**: repeating a ``POST`` that the API did carry
137 out, but whose answer was lost, creates the resource twice.
139 An instance holds no state beyond what it was constructed with, and every request builds its own headers, so one
140 client can serve several threads. The pause before a retry blocks only the thread waiting for that answer.
141 """
142 _apiURL: URL #: Base URL of the REST API, without a trailing slash.
143 _token: Nullable[str] #: Token authorizing the requests, or ``None`` for anonymous requests.
144 _headers: dict[str, str] #: Headers sent with every request, beside the authorization.
145 _timeout: float #: Timeout of a single request in seconds.
146 _retries: int #: How often a transiently failing idempotent request is tried again.
147 _retryDelay: float #: Pause in seconds before a request is tried again the first time.
149 def __init__(
150 self,
151 apiURL: Union[str, URL],
152 token: Nullable[str] = None,
153 *,
154 headers: Nullable[dict[str, str]] = None,
155 timeout: float = 30.0,
156 retries: int = 3,
157 retryDelay: float = 2.0
158 ) -> None:
159 """
160 Initializes a client for one REST API.
162 :param apiURL: Base URL of the REST API, as a string to parse or as a :class:`~pyTooling.GenericPath.URL.URL`.
163 :param token: Optional, token authorizing the requests. Default: anonymous requests.
164 :param headers: Optional, headers sent with every request. Default: no headers beside the authorization.
165 :param timeout: Optional, timeout of a single request in seconds. Default: ``30.0``.
166 :param retries: Optional, how often a transiently failing idempotent request is tried again. ``0`` tries
167 once. Default: ``3``.
168 :param retryDelay: Optional, pause in seconds before a request is tried again the first time. The pause doubles
169 with every further attempt. Default: ``2.0``.
170 :raises ValueError: If parameter 'apiURL' is ``None``.
171 :raises TypeError: If parameter 'apiURL' is neither of type :class:`str` nor of type
172 :class:`~pyTooling.GenericPath.URL.URL`.
173 :raises ValueError: If parameter 'apiURL' is a string :meth:`URL.Parse <pyTooling.GenericPath.URL.URL.Parse>`
174 rejects.
175 :raises ValueError: If parameter 'apiURL' names no scheme or no host.
176 :raises ValueError: If parameter 'apiURL' ends in an empty path element, e.g. ``'https://example.org/api//'``.
177 :raises TypeError: If parameter 'token' is not of type :class:`str`.
178 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
179 :raises ValueError: If parameter 'timeout' is ``None``.
180 :raises TypeError: If parameter 'timeout' is not a number.
181 :raises ValueError: If parameter 'timeout' isn't positive.
182 :raises ValueError: If parameter 'retries' is ``None``.
183 :raises TypeError: If parameter 'retries' is not of type :class:`int`.
184 :raises ValueError: If parameter 'retries' is negative.
185 :raises ValueError: If parameter 'retryDelay' is ``None``.
186 :raises TypeError: If parameter 'retryDelay' is not a number.
187 :raises ValueError: If parameter 'retryDelay' is negative.
188 """
189 if apiURL is None:
190 raise ValueError("Parameter 'apiURL' is None.")
191 elif isinstance(apiURL, str):
192 try:
193 parsedURL = URL.Parse(apiURL).WithoutTrailingSlash()
194 except URLParseError as cause:
195 ex = ValueError("Parameter 'apiURL' is not a URL.")
196 ex.add_note(f"Got value '{apiURL}'.")
197 raise ex from cause
198 elif isinstance(apiURL, URL):
199 parsedURL = apiURL.WithoutTrailingSlash()
200 else:
201 ex = TypeError("Parameter 'apiURL' is neither of type 'str' nor of type 'URL'.")
202 ex.add_note(f"Got type '{getFullyQualifiedName(apiURL)}'.")
203 raise ex
205 if parsedURL.Scheme is None or parsedURL.Host is None:
206 ex = ValueError("Parameter 'apiURL' names no scheme or no host.")
207 ex.add_note(f"Got value '{apiURL}'.")
208 raise ex
210 # One trailing slash is what a base URL is usually written with, and it was just removed. A path still ending
211 # in one named an empty element, which every request would carry as a double slash.
212 if str(parsedURL.Path).endswith("/"):
213 ex = ValueError("Parameter 'apiURL' ends in an empty path element.")
214 ex.add_note(f"Got value '{apiURL}'.")
215 raise ex
217 if token is not None and not isinstance(token, str):
218 ex = TypeError("Parameter 'token' is not of type 'str'.")
219 ex.add_note(f"Got type '{getFullyQualifiedName(token)}'.")
220 raise ex
222 if headers is not None and not isinstance(headers, dict):
223 ex = TypeError("Parameter 'headers' is not of type 'dict'.")
224 ex.add_note(f"Got type '{getFullyQualifiedName(headers)}'.")
225 raise ex
227 if timeout is None:
228 raise ValueError("Parameter 'timeout' is None.")
229 elif isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
230 ex = TypeError("Parameter 'timeout' is not a number.")
231 ex.add_note(f"Got type '{getFullyQualifiedName(timeout)}'.")
232 raise ex
233 elif timeout <= 0:
234 ex = ValueError("Parameter 'timeout' isn't positive.")
235 ex.add_note(f"Got value '{timeout}'.")
236 raise ex
238 if retries is None:
239 raise ValueError("Parameter 'retries' is None.")
240 elif isinstance(retries, bool) or not isinstance(retries, int):
241 ex = TypeError("Parameter 'retries' is not of type 'int'.")
242 ex.add_note(f"Got type '{getFullyQualifiedName(retries)}'.")
243 raise ex
244 elif retries < 0:
245 ex = ValueError("Parameter 'retries' is negative.")
246 ex.add_note(f"Got value '{retries}'.")
247 raise ex
249 if retryDelay is None:
250 raise ValueError("Parameter 'retryDelay' is None.")
251 elif isinstance(retryDelay, bool) or not isinstance(retryDelay, (int, float)):
252 ex = TypeError("Parameter 'retryDelay' is not a number.")
253 ex.add_note(f"Got type '{getFullyQualifiedName(retryDelay)}'.")
254 raise ex
255 elif retryDelay < 0:
256 ex = ValueError("Parameter 'retryDelay' is negative.")
257 ex.add_note(f"Got value '{retryDelay}'.")
258 raise ex
260 self._apiURL = parsedURL
261 self._token = token
262 self._headers = {} if headers is None else dict(headers)
263 self._timeout = float(timeout)
264 self._retries = retries
265 self._retryDelay = float(retryDelay)
267 @readonly
268 def APIURL(self) -> URL:
269 """
270 Read-only property to access the base URL of the REST API (:attr:`_apiURL`).
272 :returns: The base URL, without a trailing slash.
273 """
274 return self._apiURL
276 @readonly
277 def Headers(self) -> dict[str, str]:
278 """
279 Read-only property to return the headers sent with every request (:attr:`_headers`).
281 :returns: A copy of the headers, so changing it doesn't change what the client sends.
282 """
283 return dict(self._headers)
285 @readonly
286 def Timeout(self) -> float:
287 """
288 Read-only property to access the timeout of a single request (:attr:`_timeout`).
290 :returns: The timeout in seconds.
291 """
292 return self._timeout
294 @readonly
295 def Retries(self) -> int:
296 """
297 Read-only property to access how often a transiently failing request is tried again (:attr:`_retries`).
299 :returns: The number of further attempts.
300 """
301 return self._retries
303 @readonly
304 def RetryDelay(self) -> float:
305 """
306 Read-only property to access the pause before a request is tried again the first time (:attr:`_retryDelay`).
308 :returns: The pause in seconds.
309 """
310 return self._retryDelay
312 def GetJSONObject(
313 self,
314 resourcePath: str,
315 headers: Nullable[dict[str, str]] = None
316 ) -> tuple[JSONObject, Nullable[str]]:
317 """
318 Read a resource as a JSON object.
320 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name'``.
321 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`.
322 :returns: The JSON object, and the path of the next page, or ``None`` if the answer names none.
323 :raises ValueError: If parameter 'resourcePath' is ``None``.
324 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`.
325 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
326 :raises RESTError: If the request fails, or the answer isn't a JSON object.
327 """
328 document, nextResourcePath = self._Request(HTTPMethod.GET, resourcePath, headers=headers)
329 if document is None:
330 raise RESTError(f"API answered with an empty body: {self._apiURL / resourcePath.lstrip('/')}")
332 return document, nextResourcePath
334 def PostJSONObject(
335 self,
336 resourcePath: str,
337 document: JSONObject,
338 headers: Nullable[dict[str, str]] = None
339 ) -> Nullable[JSONObject]:
340 """
341 Create a resource from a JSON object.
343 A ``POST`` isn't idempotent, so a transient failure isn't tried again - the API may have created the resource
344 and lost only the answer.
346 :param resourcePath: Path of the collection below :attr:`APIURL`, e.g. ``'repos/owner/name/issues'``.
347 :param document: The JSON object to send.
348 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`.
349 :returns: The answer's JSON object, or ``None`` if the API answered with no body.
350 :raises ValueError: If parameter 'resourcePath' is ``None``.
351 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`.
352 :raises ValueError: If parameter 'document' is ``None``.
353 :raises TypeError: If parameter 'document' is not of type :class:`dict`.
354 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
355 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object.
356 """
357 if document is None:
358 raise ValueError("Parameter 'document' is None.")
360 return self._Request(HTTPMethod.POST, resourcePath, document, headers, idempotent=False)[0]
362 def PutJSONObject(
363 self,
364 resourcePath: str,
365 document: JSONObject,
366 headers: Nullable[dict[str, str]] = None
367 ) -> Nullable[JSONObject]:
368 """
369 Replace a resource by a JSON object.
371 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name/issues/1'``.
372 :param document: The JSON object to send.
373 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`.
374 :returns: The answer's JSON object, or ``None`` if the API answered with no body.
375 :raises ValueError: If parameter 'resourcePath' is ``None``.
376 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`.
377 :raises ValueError: If parameter 'document' is ``None``.
378 :raises TypeError: If parameter 'document' is not of type :class:`dict`.
379 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
380 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object.
381 """
382 if document is None:
383 raise ValueError("Parameter 'document' is None.")
385 return self._Request(HTTPMethod.PUT, resourcePath, document, headers)[0]
387 def PatchJSONObject(
388 self,
389 resourcePath: str,
390 document: JSONObject,
391 headers: Nullable[dict[str, str]] = None
392 ) -> Nullable[JSONObject]:
393 """
394 Alter a resource by a JSON object holding the fields to change.
396 :rfc:`9110` doesn't call ``PATCH`` idempotent - whether applying the same change twice is the same as applying
397 it once depends on the change - so a transient failure isn't tried again.
399 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name/issues/1'``.
400 :param document: The JSON object holding the fields to change.
401 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`.
402 :returns: The answer's JSON object, or ``None`` if the API answered with no body.
403 :raises ValueError: If parameter 'resourcePath' is ``None``.
404 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`.
405 :raises ValueError: If parameter 'document' is ``None``.
406 :raises TypeError: If parameter 'document' is not of type :class:`dict`.
407 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
408 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object.
409 """
410 if document is None:
411 raise ValueError("Parameter 'document' is None.")
413 return self._Request(HTTPMethod.PATCH, resourcePath, document, headers, idempotent=False)[0]
415 def DeleteResource(self, resourcePath: str, headers: Nullable[dict[str, str]] = None) -> Nullable[JSONObject]:
416 """
417 Delete a resource.
419 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name/issues/1'``.
420 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`.
421 :returns: The answer's JSON object, or ``None`` if the API answered with no body, which is what a
422 deletion usually answers with.
423 :raises ValueError: If parameter 'resourcePath' is ``None``.
424 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`.
425 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
426 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object.
427 """
428 return self._Request(HTTPMethod.DELETE, resourcePath, headers=headers)[0]
430 def _Request(
431 self,
432 method: HTTPMethod,
433 resourcePath: str,
434 document: Nullable[JSONObject] = None,
435 headers: Nullable[dict[str, str]] = None,
436 idempotent: bool = True
437 ) -> tuple[Nullable[JSONObject], Nullable[str]]:
438 """
439 Send one request to the REST API and read its answer.
441 :param method: The HTTP method.
442 :param resourcePath: Path of the resource below :attr:`APIURL`.
443 :param document: Optional, the JSON object to send as the request's body. Default: no body.
444 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`.
445 :param idempotent: Optional, ``True``, if repeating the request has the same effect as sending it once, which
446 is what makes trying it again safe. Default: ``True``.
447 :returns: The answer's JSON object or ``None`` if it had no body, and the path of the next page or
448 ``None`` if the answer names none.
449 :raises ValueError: If parameter 'resourcePath' is ``None``.
450 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`.
451 :raises TypeError: If parameter 'document' is not of type :class:`dict`.
452 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
453 :raises RESTError: If the request fails with an HTTP error, or the API can't be reached. |br|
454 The notes report what the API said and how often the request was tried.
455 :raises RESTError: If the answer isn't JSON, isn't valid JSON, or isn't a JSON object.
456 :raises RESTError: If the next page's URL doesn't belong to this API. |br|
457 The note says that the token is only sent to the API itself.
458 """
459 if resourcePath is None:
460 raise ValueError("Parameter 'resourcePath' is None.")
461 elif not isinstance(resourcePath, str):
462 ex = TypeError("Parameter 'resourcePath' is not of type 'str'.")
463 ex.add_note(f"Got type '{getFullyQualifiedName(resourcePath)}'.")
464 raise ex
466 if document is not None and not isinstance(document, dict):
467 ex = TypeError("Parameter 'document' is not of type 'dict'.")
468 ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.")
469 raise ex
471 if headers is not None and not isinstance(headers, dict):
472 ex = TypeError("Parameter 'headers' is not of type 'dict'.")
473 ex.add_note(f"Got type '{getFullyQualifiedName(headers)}'.")
474 raise ex
476 if document is None:
477 data = None
478 mediaType = None
479 else:
480 data = json_dumps(document).encode("utf-8")
481 mediaType = MediaType.JSON
483 # A resource path is always **below** the API, so a leading delimiter is removed - :meth:`URL.__truediv__
484 # <pyTooling.GenericPath.URL.URL.__truediv__>` would otherwise read it as naming its own root.
485 url = self._apiURL / resourcePath.lstrip("/")
486 request = Request(str(url), data=data, method=method, headers=self._RequestHeaders(mediaType, headers))
487 retries = self._retries if idempotent else 0
489 for attempt in range(1, retries + 2): 489 ↛ 535line 489 didn't jump to line 535 because the loop on line 489 didn't complete
490 try:
491 with urlopen(request, timeout=self._timeout) as response:
492 body = response.read()
493 contentType = response.headers.get("Content-Type", None)
494 link = response.headers.get("Link", None)
495 break
496 except HTTPError as ex:
497 if ex.code in TRANSIENT_HTTP_STATUS and attempt <= retries:
498 delay = self._RetryDelay(attempt)
499 # :rfc:`9110` writes 'Retry-After' as a non-negative number of seconds or as an HTTP-date. Only the
500 # first is honored, and only while it asks for longer than the backoff already waits.
501 if ex.headers is not None and (pause := ex.headers.get("Retry-After", "")).strip().isdigit():
502 delay = max(delay, min(float(pause), MAXIMUM_RETRY_AFTER))
504 sleep(delay)
505 continue
507 error = RESTError(f"Request failed with HTTP {ex.code}: {url}")
508 try:
509 answer = json_loads(ex.read())
510 except (OSError, ValueError):
511 answer = None
513 # A REST API reports the reason in a 'message' field. An answer that has none, or isn't JSON at all,
514 # is not a second failure - it just says nothing.
515 if isinstance(answer, dict) and isinstance(message := answer.get("message", None), str): 515 ↛ 518line 515 didn't jump to line 518 because the condition on line 515 was always true
516 error.add_note(f"Answer: {message}")
518 self._AddErrorNotes(error, ex.code)
519 if attempt > 1:
520 error.add_note(f"Tried {attempt} times.")
522 raise error from ex
523 except OSError as ex:
524 if attempt <= retries:
525 sleep(self._RetryDelay(attempt))
526 continue
528 error = RESTError(f"API couldn't be reached: {url}")
529 error.add_note(f"Reason: {ex.reason if isinstance(ex, URLError) else ex}")
530 if attempt > 1: 530 ↛ 533line 530 didn't jump to line 533 because the condition on line 530 was always true
531 error.add_note(f"Tried {attempt} times.")
533 raise error from ex
535 return self._ProcessAnswer(body, contentType, url), self._NextResourcePath(link)
537 def _RequestHeaders(self, mediaType: Nullable[MediaType], headers: Nullable[dict[str, str]]) -> dict[str, str]:
538 """
539 Return the headers one request is sent with.
541 The client's headers are the base, the authorization is added, and this request's own headers win, so a caller
542 can state an ``Accept`` or a conditional header for a single request.
544 :param mediaType: The media type of the request's body, or ``None`` for a request without one.
545 :param headers: This request's headers, or ``None``.
546 :returns: The headers of this request.
547 """
548 requestHeaders = dict(self._headers)
549 # The bearer scheme of :rfc:`6750` is what a token-based REST API expects, and what an OAuth 2.0 flow's access
550 # token is used with once the flow handed one out. An API authorizing differently overrides this method.
551 if self._token is not None:
552 requestHeaders["Authorization"] = f"Bearer {self._token}"
554 if mediaType is not None:
555 requestHeaders["Content-Type"] = mediaType
557 if headers is not None:
558 requestHeaders.update(headers)
560 return requestHeaders
562 def _AddErrorNotes(self, error: RESTError, status: int) -> None:
563 """
564 Add notes explaining what an HTTP status means for this API.
566 A derived class overrides this to say what a caller can do about a status its API answers with. The default
567 adds nothing, because a status alone says the same for every API.
569 :param error: The error the notes are added to.
570 :param status: The HTTP status the request failed with.
571 """
573 def _RetryDelay(self, attempt: int) -> float:
574 """
575 Return the pause before a request is tried again.
577 The pause grows exponentially: :attr:`RetryDelay` doubled for every earlier attempt.
579 :param attempt: The attempt that failed, starting at 1.
580 :returns: The pause in seconds.
581 """
582 return self._retryDelay * 2 ** (attempt - 1)
584 def _ProcessAnswer(self, body: bytes, contentType: Nullable[str], url: URL) -> Nullable[JSONObject]:
585 """
586 Read an answer's body as a JSON object.
588 :param body: The answer's body.
589 :param contentType: The answer's ``Content-Type`` header, or ``None``.
590 :param url: The URL that was requested.
591 :returns: The JSON object, or ``None`` if the answer had no body.
592 :raises RESTError: If the answer's media type isn't JSON. |br|
593 The note reports the media type that was announced.
594 :raises RESTError: If the answer isn't valid JSON.
595 :raises RESTError: If the answer is JSON, but not a JSON object.
596 """
597 if len(body) == 0:
598 return None
600 if contentType is not None and not MediaType.JSON.Matches(contentType):
601 error = RESTError(f"API didn't answer with JSON: {url}")
602 error.add_note(f"Got 'Content-Type: {contentType}'.")
603 raise error
605 try:
606 document = json_loads(body)
607 except ValueError as ex:
608 raise RESTError(f"API answered with invalid JSON: {url}") from ex
610 if not isinstance(document, dict):
611 raise RESTError(f"API didn't answer with a JSON object: {url}")
613 return document
615 def _NextResourcePath(self, link: Nullable[str]) -> Nullable[str]:
616 """
617 Return where a paginated collection continues.
619 :param link: The answer's :rfc:`8288` ``Link`` header, or ``None``.
620 :returns: Path of the next page below :attr:`APIURL`, or ``None`` if the answer names none.
621 :raises RESTError: If the next page's URL doesn't belong to this API. |br|
622 The note says that the token is only sent to the API itself.
623 """
624 if link is None:
625 return None
626 elif (match := _NEXT_LINK.search(link)) is None:
627 return None
629 nextURL = match.group(1)
630 prefix = f"{self._apiURL}/"
631 if not nextURL.startswith(prefix):
632 error = RESTError(f"The next page is outside the API: {nextURL}")
633 error.add_note("The request's token is only sent to the API itself.")
634 raise error
636 return nextURL[len(prefix):]