Coverage for pyTooling/REST/__init__.py: 98%
232 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +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 :mod:`pyTooling.CI.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
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' names no scheme or no host.
174 :raises ValueError: If parameter 'apiURL' ends in an empty path element, e.g. ``'https://example.org/api//'``.
175 :raises TypeError: If parameter 'token' is not of type :class:`str`.
176 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
177 :raises ValueError: If parameter 'timeout' is ``None``.
178 :raises TypeError: If parameter 'timeout' is not a number.
179 :raises ValueError: If parameter 'timeout' isn't positive.
180 :raises ValueError: If parameter 'retries' is ``None``.
181 :raises TypeError: If parameter 'retries' is not of type :class:`int`.
182 :raises ValueError: If parameter 'retries' is negative.
183 :raises ValueError: If parameter 'retryDelay' is ``None``.
184 :raises TypeError: If parameter 'retryDelay' is not a number.
185 :raises ValueError: If parameter 'retryDelay' is negative.
186 """
187 if apiURL is None:
188 raise ValueError("Parameter 'apiURL' is None.")
189 elif isinstance(apiURL, str):
190 parsedURL = URL.Parse(apiURL).WithoutTrailingSlash()
191 elif isinstance(apiURL, URL):
192 parsedURL = apiURL.WithoutTrailingSlash()
193 else:
194 ex = TypeError("Parameter 'apiURL' is neither of type 'str' nor of type 'URL'.")
195 ex.add_note(f"Got type '{getFullyQualifiedName(apiURL)}'.")
196 raise ex
198 if parsedURL.Scheme is None or parsedURL.Host is None:
199 ex = ValueError("Parameter 'apiURL' names no scheme or no host.")
200 ex.add_note(f"Got value '{apiURL}'.")
201 raise ex
203 # One trailing slash is what a base URL is usually written with, and it was just removed. A path still ending
204 # in one named an empty element, which every request would carry as a double slash.
205 if str(parsedURL.Path).endswith("/"):
206 ex = ValueError("Parameter 'apiURL' ends in an empty path element.")
207 ex.add_note(f"Got value '{apiURL}'.")
208 raise ex
210 if token is not None and not isinstance(token, str):
211 ex = TypeError("Parameter 'token' is not of type 'str'.")
212 ex.add_note(f"Got type '{getFullyQualifiedName(token)}'.")
213 raise ex
215 if headers is not None and not isinstance(headers, dict):
216 ex = TypeError("Parameter 'headers' is not of type 'dict'.")
217 ex.add_note(f"Got type '{getFullyQualifiedName(headers)}'.")
218 raise ex
220 if timeout is None:
221 raise ValueError("Parameter 'timeout' is None.")
222 elif isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
223 ex = TypeError("Parameter 'timeout' is not a number.")
224 ex.add_note(f"Got type '{getFullyQualifiedName(timeout)}'.")
225 raise ex
226 elif timeout <= 0:
227 ex = ValueError("Parameter 'timeout' isn't positive.")
228 ex.add_note(f"Got value '{timeout}'.")
229 raise ex
231 if retries is None:
232 raise ValueError("Parameter 'retries' is None.")
233 elif isinstance(retries, bool) or not isinstance(retries, int):
234 ex = TypeError("Parameter 'retries' is not of type 'int'.")
235 ex.add_note(f"Got type '{getFullyQualifiedName(retries)}'.")
236 raise ex
237 elif retries < 0:
238 ex = ValueError("Parameter 'retries' is negative.")
239 ex.add_note(f"Got value '{retries}'.")
240 raise ex
242 if retryDelay is None:
243 raise ValueError("Parameter 'retryDelay' is None.")
244 elif isinstance(retryDelay, bool) or not isinstance(retryDelay, (int, float)):
245 ex = TypeError("Parameter 'retryDelay' is not a number.")
246 ex.add_note(f"Got type '{getFullyQualifiedName(retryDelay)}'.")
247 raise ex
248 elif retryDelay < 0:
249 ex = ValueError("Parameter 'retryDelay' is negative.")
250 ex.add_note(f"Got value '{retryDelay}'.")
251 raise ex
253 self._apiURL = parsedURL
254 self._token = token
255 self._headers = {} if headers is None else dict(headers)
256 self._timeout = float(timeout)
257 self._retries = retries
258 self._retryDelay = float(retryDelay)
260 @readonly
261 def APIURL(self) -> URL:
262 """
263 Read-only property to access the base URL of the REST API (:attr:`_apiURL`).
265 :returns: The base URL, without a trailing slash.
266 """
267 return self._apiURL
269 @readonly
270 def Headers(self) -> dict[str, str]:
271 """
272 Read-only property to return the headers sent with every request (:attr:`_headers`).
274 :returns: A copy of the headers, so changing it doesn't change what the client sends.
275 """
276 return dict(self._headers)
278 @readonly
279 def Timeout(self) -> float:
280 """
281 Read-only property to access the timeout of a single request (:attr:`_timeout`).
283 :returns: The timeout in seconds.
284 """
285 return self._timeout
287 @readonly
288 def Retries(self) -> int:
289 """
290 Read-only property to access how often a transiently failing request is tried again (:attr:`_retries`).
292 :returns: The number of further attempts.
293 """
294 return self._retries
296 @readonly
297 def RetryDelay(self) -> float:
298 """
299 Read-only property to access the pause before a request is tried again the first time (:attr:`_retryDelay`).
301 :returns: The pause in seconds.
302 """
303 return self._retryDelay
305 def GetJSONObject(
306 self,
307 resourcePath: str,
308 headers: Nullable[dict[str, str]] = None
309 ) -> tuple[JSONObject, Nullable[str]]:
310 """
311 Read a resource as a JSON object.
313 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name'``.
314 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`.
315 :returns: The JSON object, and the path of the next page, or ``None`` if the answer names none.
316 :raises ValueError: If parameter 'resourcePath' is ``None``.
317 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`.
318 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
319 :raises RESTError: If the request fails, or the answer isn't a JSON object.
320 """
321 document, nextResourcePath = self._Request(HTTPMethod.GET, resourcePath, headers=headers)
322 if document is None:
323 raise RESTError(f"API answered with an empty body: {self._apiURL / resourcePath.lstrip('/')}")
325 return document, nextResourcePath
327 def PostJSONObject(
328 self,
329 resourcePath: str,
330 document: JSONObject,
331 headers: Nullable[dict[str, str]] = None
332 ) -> Nullable[JSONObject]:
333 """
334 Create a resource from a JSON object.
336 A ``POST`` isn't idempotent, so a transient failure isn't tried again - the API may have created the resource
337 and lost only the answer.
339 :param resourcePath: Path of the collection below :attr:`APIURL`, e.g. ``'repos/owner/name/issues'``.
340 :param document: The JSON object to send.
341 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`.
342 :returns: The answer's JSON object, or ``None`` if the API answered with no body.
343 :raises ValueError: If parameter 'resourcePath' is ``None``.
344 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`.
345 :raises ValueError: If parameter 'document' is ``None``.
346 :raises TypeError: If parameter 'document' is not of type :class:`dict`.
347 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
348 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object.
349 """
350 if document is None:
351 raise ValueError("Parameter 'document' is None.")
353 return self._Request(HTTPMethod.POST, resourcePath, document, headers, idempotent=False)[0]
355 def PutJSONObject(
356 self,
357 resourcePath: str,
358 document: JSONObject,
359 headers: Nullable[dict[str, str]] = None
360 ) -> Nullable[JSONObject]:
361 """
362 Replace a resource by a JSON object.
364 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name/issues/1'``.
365 :param document: The JSON object to send.
366 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`.
367 :returns: The answer's JSON object, or ``None`` if the API answered with no body.
368 :raises ValueError: If parameter 'resourcePath' is ``None``.
369 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`.
370 :raises ValueError: If parameter 'document' is ``None``.
371 :raises TypeError: If parameter 'document' is not of type :class:`dict`.
372 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
373 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object.
374 """
375 if document is None:
376 raise ValueError("Parameter 'document' is None.")
378 return self._Request(HTTPMethod.PUT, resourcePath, document, headers)[0]
380 def PatchJSONObject(
381 self,
382 resourcePath: str,
383 document: JSONObject,
384 headers: Nullable[dict[str, str]] = None
385 ) -> Nullable[JSONObject]:
386 """
387 Alter a resource by a JSON object holding the fields to change.
389 :rfc:`9110` doesn't call ``PATCH`` idempotent - whether applying the same change twice is the same as applying
390 it once depends on the change - so a transient failure isn't tried again.
392 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name/issues/1'``.
393 :param document: The JSON object holding the fields to change.
394 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`.
395 :returns: The answer's JSON object, or ``None`` if the API answered with no body.
396 :raises ValueError: If parameter 'resourcePath' is ``None``.
397 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`.
398 :raises ValueError: If parameter 'document' is ``None``.
399 :raises TypeError: If parameter 'document' is not of type :class:`dict`.
400 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
401 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object.
402 """
403 if document is None:
404 raise ValueError("Parameter 'document' is None.")
406 return self._Request(HTTPMethod.PATCH, resourcePath, document, headers, idempotent=False)[0]
408 def DeleteResource(self, resourcePath: str, headers: Nullable[dict[str, str]] = None) -> Nullable[JSONObject]:
409 """
410 Delete a resource.
412 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name/issues/1'``.
413 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`.
414 :returns: The answer's JSON object, or ``None`` if the API answered with no body, which is what a
415 deletion usually answers with.
416 :raises ValueError: If parameter 'resourcePath' is ``None``.
417 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`.
418 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
419 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object.
420 """
421 return self._Request(HTTPMethod.DELETE, resourcePath, headers=headers)[0]
423 def _Request(
424 self,
425 method: HTTPMethod,
426 resourcePath: str,
427 document: Nullable[JSONObject] = None,
428 headers: Nullable[dict[str, str]] = None,
429 idempotent: bool = True
430 ) -> tuple[Nullable[JSONObject], Nullable[str]]:
431 """
432 Send one request to the REST API and read its answer.
434 :param method: The HTTP method.
435 :param resourcePath: Path of the resource below :attr:`APIURL`.
436 :param document: Optional, the JSON object to send as the request's body. Default: no body.
437 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`.
438 :param idempotent: Optional, ``True``, if repeating the request has the same effect as sending it once, which
439 is what makes trying it again safe. Default: ``True``.
440 :returns: The answer's JSON object or ``None`` if it had no body, and the path of the next page or
441 ``None`` if the answer names none.
442 :raises ValueError: If parameter 'resourcePath' is ``None``.
443 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`.
444 :raises TypeError: If parameter 'document' is not of type :class:`dict`.
445 :raises TypeError: If parameter 'headers' is not of type :class:`dict`.
446 :raises RESTError: If the request fails with an HTTP error, or the API can't be reached. |br|
447 The notes report what the API said and how often the request was tried.
448 :raises RESTError: If the answer isn't JSON, isn't valid JSON, or isn't a JSON object.
449 :raises RESTError: If the next page's URL doesn't belong to this API. |br|
450 The note says that the token is only sent to the API itself.
451 """
452 if resourcePath is None:
453 raise ValueError("Parameter 'resourcePath' is None.")
454 elif not isinstance(resourcePath, str):
455 ex = TypeError("Parameter 'resourcePath' is not of type 'str'.")
456 ex.add_note(f"Got type '{getFullyQualifiedName(resourcePath)}'.")
457 raise ex
459 if document is not None and not isinstance(document, dict):
460 ex = TypeError("Parameter 'document' is not of type 'dict'.")
461 ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.")
462 raise ex
464 if headers is not None and not isinstance(headers, dict):
465 ex = TypeError("Parameter 'headers' is not of type 'dict'.")
466 ex.add_note(f"Got type '{getFullyQualifiedName(headers)}'.")
467 raise ex
469 if document is None:
470 data = None
471 mediaType = None
472 else:
473 data = json_dumps(document).encode("utf-8")
474 mediaType = MediaType.JSON
476 # A resource path is always **below** the API, so a leading delimiter is removed - :meth:`URL.__truediv__
477 # <pyTooling.GenericPath.URL.URL.__truediv__>` would otherwise read it as naming its own root.
478 url = self._apiURL / resourcePath.lstrip("/")
479 request = Request(str(url), data=data, method=method, headers=self._RequestHeaders(mediaType, headers))
480 retries = self._retries if idempotent else 0
482 for attempt in range(1, retries + 2): 482 ↛ 528line 482 didn't jump to line 528 because the loop on line 482 didn't complete
483 try:
484 with urlopen(request, timeout=self._timeout) as response:
485 body = response.read()
486 contentType = response.headers.get("Content-Type", None)
487 link = response.headers.get("Link", None)
488 break
489 except HTTPError as ex:
490 if ex.code in TRANSIENT_HTTP_STATUS and attempt <= retries:
491 delay = self._RetryDelay(attempt)
492 # :rfc:`9110` writes 'Retry-After' as a non-negative number of seconds or as an HTTP-date. Only the
493 # first is honored, and only while it asks for longer than the backoff already waits.
494 if ex.headers is not None and (pause := ex.headers.get("Retry-After", "")).strip().isdigit():
495 delay = max(delay, min(float(pause), MAXIMUM_RETRY_AFTER))
497 sleep(delay)
498 continue
500 error = RESTError(f"Request failed with HTTP {ex.code}: {url}")
501 try:
502 answer = json_loads(ex.read())
503 except (OSError, ValueError):
504 answer = None
506 # A REST API reports the reason in a 'message' field. An answer that has none, or isn't JSON at all,
507 # is not a second failure - it just says nothing.
508 if isinstance(answer, dict) and isinstance(message := answer.get("message", None), str): 508 ↛ 511line 508 didn't jump to line 511 because the condition on line 508 was always true
509 error.add_note(f"Answer: {message}")
511 self._AddErrorNotes(error, ex.code)
512 if attempt > 1:
513 error.add_note(f"Tried {attempt} times.")
515 raise error from ex
516 except OSError as ex:
517 if attempt <= retries:
518 sleep(self._RetryDelay(attempt))
519 continue
521 error = RESTError(f"API couldn't be reached: {url}")
522 error.add_note(f"Reason: {ex.reason if isinstance(ex, URLError) else ex}")
523 if attempt > 1: 523 ↛ 526line 523 didn't jump to line 526 because the condition on line 523 was always true
524 error.add_note(f"Tried {attempt} times.")
526 raise error from ex
528 return self._ProcessAnswer(body, contentType, url), self._NextResourcePath(link)
530 def _RequestHeaders(self, mediaType: Nullable[MediaType], headers: Nullable[dict[str, str]]) -> dict[str, str]:
531 """
532 Return the headers one request is sent with.
534 The client's headers are the base, the authorization is added, and this request's own headers win, so a caller
535 can state an ``Accept`` or a conditional header for a single request.
537 :param mediaType: The media type of the request's body, or ``None`` for a request without one.
538 :param headers: This request's headers, or ``None``.
539 :returns: The headers of this request.
540 """
541 requestHeaders = dict(self._headers)
542 # The bearer scheme of :rfc:`6750` is what a token-based REST API expects, and what an OAuth 2.0 flow's access
543 # token is used with once the flow handed one out. An API authorizing differently overrides this method.
544 if self._token is not None:
545 requestHeaders["Authorization"] = f"Bearer {self._token}"
547 if mediaType is not None:
548 requestHeaders["Content-Type"] = mediaType
550 if headers is not None:
551 requestHeaders.update(headers)
553 return requestHeaders
555 def _AddErrorNotes(self, error: RESTError, status: int) -> None:
556 """
557 Add notes explaining what an HTTP status means for this API.
559 A derived class overrides this to say what a caller can do about a status its API answers with. The default
560 adds nothing, because a status alone says the same for every API.
562 :param error: The error the notes are added to.
563 :param status: The HTTP status the request failed with.
564 """
566 def _RetryDelay(self, attempt: int) -> float:
567 """
568 Return the pause before a request is tried again.
570 The pause grows exponentially: :attr:`RetryDelay` doubled for every earlier attempt.
572 :param attempt: The attempt that failed, starting at 1.
573 :returns: The pause in seconds.
574 """
575 return self._retryDelay * 2 ** (attempt - 1)
577 def _ProcessAnswer(self, body: bytes, contentType: Nullable[str], url: URL) -> Nullable[JSONObject]:
578 """
579 Read an answer's body as a JSON object.
581 :param body: The answer's body.
582 :param contentType: The answer's ``Content-Type`` header, or ``None``.
583 :param url: The URL that was requested.
584 :returns: The JSON object, or ``None`` if the answer had no body.
585 :raises RESTError: If the answer's media type isn't JSON. |br|
586 The note reports the media type that was announced.
587 :raises RESTError: If the answer isn't valid JSON.
588 :raises RESTError: If the answer is JSON, but not a JSON object.
589 """
590 if len(body) == 0:
591 return None
593 if contentType is not None and not MediaType.JSON.Matches(contentType):
594 error = RESTError(f"API didn't answer with JSON: {url}")
595 error.add_note(f"Got 'Content-Type: {contentType}'.")
596 raise error
598 try:
599 document = json_loads(body)
600 except ValueError as ex:
601 raise RESTError(f"API answered with invalid JSON: {url}") from ex
603 if not isinstance(document, dict):
604 raise RESTError(f"API didn't answer with a JSON object: {url}")
606 return document
608 def _NextResourcePath(self, link: Nullable[str]) -> Nullable[str]:
609 """
610 Return where a paginated collection continues.
612 :param link: The answer's :rfc:`8288` ``Link`` header, or ``None``.
613 :returns: Path of the next page below :attr:`APIURL`, or ``None`` if the answer names none.
614 :raises RESTError: If the next page's URL doesn't belong to this API. |br|
615 The note says that the token is only sent to the API itself.
616 """
617 if link is None:
618 return None
619 elif (match := _NEXT_LINK.search(link)) is None:
620 return None
622 nextURL = match.group(1)
623 prefix = f"{self._apiURL}/"
624 if not nextURL.startswith(prefix):
625 error = RESTError(f"The next page is outside the API: {nextURL}")
626 error.add_note("The request's token is only sent to the API itself.")
627 raise error
629 return nextURL[len(prefix):]