pfmsoft-api-request 0.1.3__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.
- pfmsoft/api_request/__init__.py +37 -0
- pfmsoft/api_request/cache/__init__.py +36 -0
- pfmsoft/api_request/cache/memory_cache.py +177 -0
- pfmsoft/api_request/cache/metadata_helpers.py +55 -0
- pfmsoft/api_request/cache/models.py +83 -0
- pfmsoft/api_request/cache/protocols.py +166 -0
- pfmsoft/api_request/cache/sqlite_cache/__init__.py +7 -0
- pfmsoft/api_request/cache/sqlite_cache/connection_helpers.py +126 -0
- pfmsoft/api_request/cache/sqlite_cache/query_helpers.py +107 -0
- pfmsoft/api_request/cache/sqlite_cache/sqlite_cache.py +233 -0
- pfmsoft/api_request/cache/sqlite_cache/table_definitions.sql +23 -0
- pfmsoft/api_request/cli/__init__.py +17 -0
- pfmsoft/api_request/cli/cache/__init__.py +12 -0
- pfmsoft/api_request/cli/cache/info.py +11 -0
- pfmsoft/api_request/cli/helpers.py +41 -0
- pfmsoft/api_request/cli/main_typer.py +52 -0
- pfmsoft/api_request/cli/request/__init__.py +11 -0
- pfmsoft/api_request/cli/request/request.py +222 -0
- pfmsoft/api_request/cli/request/validate.py +11 -0
- pfmsoft/api_request/helpers/http_session_factory.py +80 -0
- pfmsoft/api_request/helpers/json_io.py +256 -0
- pfmsoft/api_request/helpers/save_text_file.py +43 -0
- pfmsoft/api_request/helpers/whenever/__init__.py +4 -0
- pfmsoft/api_request/helpers/whenever/instant_helpers.py +41 -0
- pfmsoft/api_request/logging_config.py +202 -0
- pfmsoft/api_request/py.typed +0 -0
- pfmsoft/api_request/rate_limit/__init__.py +24 -0
- pfmsoft/api_request/rate_limit/aio_limiter.py +89 -0
- pfmsoft/api_request/rate_limit/protocols.py +101 -0
- pfmsoft/api_request/request/__init__.py +11 -0
- pfmsoft/api_request/request/api_requester.py +681 -0
- pfmsoft/api_request/request/intermediate_models.py +143 -0
- pfmsoft/api_request/request/models.py +355 -0
- pfmsoft/api_request/request/protocols.py +54 -0
- pfmsoft/api_request/settings.py +65 -0
- pfmsoft_api_request-0.1.3.dist-info/METADATA +171 -0
- pfmsoft_api_request-0.1.3.dist-info/RECORD +40 -0
- pfmsoft_api_request-0.1.3.dist-info/WHEEL +4 -0
- pfmsoft_api_request-0.1.3.dist-info/entry_points.txt +3 -0
- pfmsoft_api_request-0.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Intermediate request and response models for request orchestration.
|
|
2
|
+
|
|
3
|
+
These models are used internally by the request pipeline to represent work in
|
|
4
|
+
progress and normalized intermediate outcomes before conversion into the public
|
|
5
|
+
response models.
|
|
6
|
+
|
|
7
|
+
They are importable across implementation modules and tests, but they are not
|
|
8
|
+
part of the stable public package API and may change during internal refactors.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from pfmsoft.api_request.request.models import Request, ResponseMetadata, Source
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True, kw_only=True)
|
|
19
|
+
class IntermediateRequestBase:
|
|
20
|
+
"""Base model for intermediate request pipeline objects."""
|
|
21
|
+
|
|
22
|
+
request: Request
|
|
23
|
+
"""The original request that generated this intermediate object."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True, kw_only=True)
|
|
27
|
+
class IntermediateResponseBase(IntermediateRequestBase):
|
|
28
|
+
"""Base model for intermediate responses used during request processing."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True, kw_only=True)
|
|
32
|
+
class FailedRequestBase(IntermediateResponseBase):
|
|
33
|
+
"""Base model for failed intermediate responses."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True, kw_only=True)
|
|
37
|
+
class FailNoResponse(FailedRequestBase):
|
|
38
|
+
"""Intermediate failure used when no response was received."""
|
|
39
|
+
|
|
40
|
+
error_message: str
|
|
41
|
+
"""An error message describing the failure."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True, kw_only=True)
|
|
45
|
+
class FailWithResponse(FailedRequestBase):
|
|
46
|
+
"""Intermediate failure used when a response indicates failure."""
|
|
47
|
+
|
|
48
|
+
metadata: ResponseMetadata
|
|
49
|
+
"""The metadata of the response, including status code and headers."""
|
|
50
|
+
json: Any
|
|
51
|
+
"""The parsed JSON body of the response."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True, kw_only=True)
|
|
55
|
+
class UnprocessedRequest(IntermediateRequestBase):
|
|
56
|
+
"""Intermediate request that has not yet been sent."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True, kw_only=True)
|
|
60
|
+
class SuccessfulResponseBase(IntermediateResponseBase):
|
|
61
|
+
"""Base model for successful intermediate responses."""
|
|
62
|
+
|
|
63
|
+
metadata: ResponseMetadata
|
|
64
|
+
"""The metadata of the response, including status code and headers."""
|
|
65
|
+
json: Any
|
|
66
|
+
"""The parsed JSON body of the response."""
|
|
67
|
+
source: Source
|
|
68
|
+
"""The source of the response, for example cache or network."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(slots=True, kw_only=True)
|
|
72
|
+
class SuccessfulResponse(SuccessfulResponseBase):
|
|
73
|
+
"""Successful intermediate response used during request processing."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(slots=True, kw_only=True)
|
|
77
|
+
class CachableRequest(UnprocessedRequest):
|
|
78
|
+
"""Intermediate request that has an associated cache key."""
|
|
79
|
+
|
|
80
|
+
cache_key: UUID
|
|
81
|
+
"""The cache key associated with this request."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(slots=True, kw_only=True)
|
|
85
|
+
class RequestFromStaleCache(CachableRequest):
|
|
86
|
+
"""A stale cached request that needs revalidation."""
|
|
87
|
+
|
|
88
|
+
etag: str | None
|
|
89
|
+
"""The ETag of the cached response."""
|
|
90
|
+
last_modified: str | None = None
|
|
91
|
+
"""The Last-Modified header of the cached response, if available."""
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def conditional_headers(self) -> dict[str, str]:
|
|
95
|
+
"""Build conditional request headers for stale-cache revalidation.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A dictionary containing `If-None-Match` and/or
|
|
99
|
+
`If-Modified-Since`.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
ValueError: If both validators are missing.
|
|
103
|
+
"""
|
|
104
|
+
headers: dict[str, str] = {}
|
|
105
|
+
if not self.etag and not self.last_modified:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
"At least one of ETag or Last-Modified must be provided for a stale cache request."
|
|
108
|
+
)
|
|
109
|
+
if self.etag:
|
|
110
|
+
headers["If-None-Match"] = self.etag
|
|
111
|
+
if self.last_modified:
|
|
112
|
+
headers["If-Modified-Since"] = self.last_modified
|
|
113
|
+
return headers
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(slots=True, kw_only=True)
|
|
117
|
+
class Response304FromStaleCache(SuccessfulResponseBase):
|
|
118
|
+
"""A cached-body response refreshed by an HTTP 304 revalidation."""
|
|
119
|
+
|
|
120
|
+
source: Source = Source.CACHE_304
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(slots=True, kw_only=True)
|
|
124
|
+
class Response200FromStaleCache(SuccessfulResponseBase):
|
|
125
|
+
"""A refreshed cached response replaced by an HTTP 200 revalidation."""
|
|
126
|
+
|
|
127
|
+
source: Source = Source.CACHE_200
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
__all__ = [
|
|
131
|
+
"CachableRequest",
|
|
132
|
+
"FailNoResponse",
|
|
133
|
+
"FailWithResponse",
|
|
134
|
+
"FailedRequestBase",
|
|
135
|
+
"IntermediateRequestBase",
|
|
136
|
+
"IntermediateResponseBase",
|
|
137
|
+
"RequestFromStaleCache",
|
|
138
|
+
"Response200FromStaleCache",
|
|
139
|
+
"Response304FromStaleCache",
|
|
140
|
+
"SuccessfulResponse",
|
|
141
|
+
"SuccessfulResponseBase",
|
|
142
|
+
"UnprocessedRequest",
|
|
143
|
+
]
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Public request/response models used by API orchestration.
|
|
2
|
+
|
|
3
|
+
This module defines immutable dataclasses for request and response objects,
|
|
4
|
+
plus convenience properties for common HTTP metadata lookups.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import UUID, uuid4
|
|
12
|
+
|
|
13
|
+
from pydantic import RootModel
|
|
14
|
+
from whenever import Instant
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
type PARAMETER = str | int | float
|
|
19
|
+
"""Supported query-parameter value types."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
#######################################################################################
|
|
23
|
+
# Request Models
|
|
24
|
+
#######################################################################################
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True, kw_only=True)
|
|
28
|
+
class Request:
|
|
29
|
+
"""Represents one outbound HTTP request definition.
|
|
30
|
+
|
|
31
|
+
The model is immutable and can be safely shared across async tasks.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
request_key: UUID = field(default_factory=uuid4)
|
|
35
|
+
"""The UUID key for the request."""
|
|
36
|
+
url: str
|
|
37
|
+
"""The URL of the API request."""
|
|
38
|
+
method: str
|
|
39
|
+
"""The HTTP method of the API request (e.g., GET, POST)."""
|
|
40
|
+
headers: dict[str, str] = field(default_factory=dict[str, str])
|
|
41
|
+
"""The headers of the API request."""
|
|
42
|
+
body: Any | None = None
|
|
43
|
+
"""The body of the API request, if applicable."""
|
|
44
|
+
parameters: dict[str, PARAMETER] = field(default_factory=dict[str, PARAMETER])
|
|
45
|
+
"""The query parameters of the API request."""
|
|
46
|
+
cache_key: UUID | None = None
|
|
47
|
+
"""The UUID key for the cached response. If None, the response is not cached."""
|
|
48
|
+
rate_key: str | None = None
|
|
49
|
+
"""Optional key used by rate-limiter implementations to group requests."""
|
|
50
|
+
|
|
51
|
+
def purge_secrets(self) -> None:
|
|
52
|
+
"""Purge authorization headers from the request for security purposes."""
|
|
53
|
+
# replace access tokens with REDACTED in headers
|
|
54
|
+
redacted_headers = {
|
|
55
|
+
k: "REDACTED" if k.lower() == "authorization" else v
|
|
56
|
+
for k, v in self.headers.items()
|
|
57
|
+
}
|
|
58
|
+
self.headers = redacted_headers
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
#######################################################################################
|
|
62
|
+
# Response Models
|
|
63
|
+
#######################################################################################
|
|
64
|
+
class Source(StrEnum):
|
|
65
|
+
"""Enumeration of possible sources for a response."""
|
|
66
|
+
|
|
67
|
+
CACHE = "cache"
|
|
68
|
+
"""The response was retrieved from the cache."""
|
|
69
|
+
CACHE_304 = "cache-304"
|
|
70
|
+
"""The cached response was revalidated and remained current."""
|
|
71
|
+
CACHE_200 = "cache-200"
|
|
72
|
+
"""The cached response was revalidated and replaced by fresh content."""
|
|
73
|
+
NETWORK = "network"
|
|
74
|
+
"""The response was retrieved from the network."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(slots=True, kw_only=True, frozen=True)
|
|
78
|
+
class X_ratelimit:
|
|
79
|
+
"""Parsed X-RateLimit header values."""
|
|
80
|
+
|
|
81
|
+
group: str
|
|
82
|
+
"""X-RateLimit group identifier."""
|
|
83
|
+
limit: str
|
|
84
|
+
"""Configured request budget for the current window."""
|
|
85
|
+
remaining: str
|
|
86
|
+
"""Remaining budget in the current window."""
|
|
87
|
+
used: str
|
|
88
|
+
"""Consumed budget in the current window."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(slots=True, kw_only=True)
|
|
92
|
+
class ResponseMetadata:
|
|
93
|
+
"""Represents transport metadata for a single HTTP response.
|
|
94
|
+
|
|
95
|
+
Time unit conventions:
|
|
96
|
+
- `elapsed` is stored in microseconds.
|
|
97
|
+
- `received_timestamp` is stored in Unix nanoseconds.
|
|
98
|
+
- `expires_at` (derived) is stored in Unix seconds.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
status_code: int
|
|
102
|
+
"""The HTTP status code of the response."""
|
|
103
|
+
reason_phrase: str
|
|
104
|
+
"""The reason phrase associated with the status code."""
|
|
105
|
+
url: str
|
|
106
|
+
"""The URL that was requested to obtain this response."""
|
|
107
|
+
elapsed: int
|
|
108
|
+
"""The time taken to receive the response, in microseconds, because the source is a timedelta."""
|
|
109
|
+
bytes_downloaded: int
|
|
110
|
+
"""The number of bytes downloaded in the response body."""
|
|
111
|
+
headers: tuple[tuple[str, str], ...] = field(
|
|
112
|
+
default_factory=tuple[tuple[str, str], ...]
|
|
113
|
+
)
|
|
114
|
+
"""The headers of the response as a tuple of key-value pairs."""
|
|
115
|
+
received_timestamp: int = -1
|
|
116
|
+
"""The timestamp when the response was received, as a Unix timestamp in nanoseconds."""
|
|
117
|
+
_headers_lower: dict[str, str] = field(
|
|
118
|
+
init=False, repr=False, default_factory=dict[str, str]
|
|
119
|
+
)
|
|
120
|
+
"""A lower case version of the headers for easier access to common headers like ETag and Last-Modified."""
|
|
121
|
+
|
|
122
|
+
def __post_init__(self):
|
|
123
|
+
"""Build a lowercase header lookup map for convenience accessors.
|
|
124
|
+
|
|
125
|
+
If duplicate header names differ only by case, the later value wins
|
|
126
|
+
in the lowercase map and a warning is logged.
|
|
127
|
+
"""
|
|
128
|
+
self._init_lowercase_headers()
|
|
129
|
+
|
|
130
|
+
def _init_lowercase_headers(self):
|
|
131
|
+
"""Initialize the lowercase headers mapping."""
|
|
132
|
+
self._headers_lower = {k.lower(): v for k, v in self.headers}
|
|
133
|
+
if len(self.headers) != len(self._headers_lower):
|
|
134
|
+
logger.warning(
|
|
135
|
+
"Duplicate headers found when converting to lower case. This may lead to "
|
|
136
|
+
"unexpected behavior when accessing headers. Original headers: %s, Lower "
|
|
137
|
+
"case headers: %s",
|
|
138
|
+
self.headers,
|
|
139
|
+
self._headers_lower,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def headers_lower(self) -> dict[str, str]:
|
|
144
|
+
"""Return lowercase header mapping used by convenience properties."""
|
|
145
|
+
return self._headers_lower
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def received_at(self) -> Instant:
|
|
149
|
+
"""Convert `received_timestamp` to an Instant.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
ValueError: If `received_timestamp` is unset (`-1`).
|
|
153
|
+
"""
|
|
154
|
+
if self.received_timestamp != -1:
|
|
155
|
+
return Instant.from_timestamp_nanos(self.received_timestamp)
|
|
156
|
+
raise ValueError("Received timestamp is not set.")
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def etag(self) -> str | None:
|
|
160
|
+
"""Extract the ETag from the response headers, if present."""
|
|
161
|
+
return self.headers_lower.get("etag")
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def last_modified(self) -> str | None:
|
|
165
|
+
"""Extract the Last-Modified header from the response headers, if present."""
|
|
166
|
+
return self.headers_lower.get("last-modified")
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def expires(self) -> str | None:
|
|
170
|
+
"""Extract the Expires header from the response headers, if present."""
|
|
171
|
+
return self.headers_lower.get("expires")
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def date(self) -> str | None:
|
|
175
|
+
"""Extract the Date header from the response headers, if present."""
|
|
176
|
+
return self.headers_lower.get("date")
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def date_as_instant(self) -> Instant | None:
|
|
180
|
+
"""Convert the Date header to an Instant, if possible."""
|
|
181
|
+
date_str = self.date
|
|
182
|
+
if date_str:
|
|
183
|
+
try:
|
|
184
|
+
return Instant.parse_rfc2822(date_str)
|
|
185
|
+
except ValueError:
|
|
186
|
+
pass
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def cache_control(self) -> str | None:
|
|
191
|
+
"""Extract the Cache-Control header from the response headers, if present."""
|
|
192
|
+
return self.headers_lower.get("cache-control")
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def max_age(self) -> int | None:
|
|
196
|
+
"""Extract the max-age directive from the Cache-Control header, if present."""
|
|
197
|
+
cache_control = self.cache_control
|
|
198
|
+
if cache_control:
|
|
199
|
+
directives = cache_control.split(",")
|
|
200
|
+
for directive in directives:
|
|
201
|
+
if "max-age" in directive:
|
|
202
|
+
try:
|
|
203
|
+
return int(directive.split("=")[1].strip())
|
|
204
|
+
except IndexError, ValueError:
|
|
205
|
+
pass
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def expires_at(self) -> int | None:
|
|
210
|
+
"""Return cache expiration instant derived from response headers.
|
|
211
|
+
|
|
212
|
+
Precedence:
|
|
213
|
+
1. `Cache-Control: max-age` with a valid `Date` header.
|
|
214
|
+
2. `Expires` header.
|
|
215
|
+
3. `None` when neither can be parsed.
|
|
216
|
+
"""
|
|
217
|
+
if self.max_age is not None and self.date is not None:
|
|
218
|
+
try:
|
|
219
|
+
response_date = Instant.parse_rfc2822(self.date)
|
|
220
|
+
return response_date.add(seconds=self.max_age).timestamp()
|
|
221
|
+
except ValueError:
|
|
222
|
+
pass
|
|
223
|
+
if self.expires:
|
|
224
|
+
try:
|
|
225
|
+
return Instant.parse_rfc2822(self.expires).timestamp()
|
|
226
|
+
except ValueError:
|
|
227
|
+
pass
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def pages(self) -> int:
|
|
232
|
+
"""Extract the number of pages from the X-Pages header, if present."""
|
|
233
|
+
pages = self.headers_lower.get("x-pages", 1)
|
|
234
|
+
return int(pages)
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def ratelimit(self) -> X_ratelimit:
|
|
238
|
+
"""Extract the rate limit information from the X-RateLimit headers, if present."""
|
|
239
|
+
group = self.headers_lower.get("x-ratelimit-group", "unknown")
|
|
240
|
+
limit = self.headers_lower.get("x-ratelimit-limit", "unknown")
|
|
241
|
+
remaining = self.headers_lower.get("x-ratelimit-remaining", "unknown")
|
|
242
|
+
used = self.headers_lower.get("x-ratelimit-used", "unknown")
|
|
243
|
+
return X_ratelimit(group=group, limit=limit, remaining=remaining, used=used)
|
|
244
|
+
|
|
245
|
+
def purge_secrets(self) -> None:
|
|
246
|
+
"""Purge authorization headers from the response metadata for security purposes."""
|
|
247
|
+
# replace access tokens with REDACTED in headers
|
|
248
|
+
# check the headers tuple for any authorization headers and redact them
|
|
249
|
+
redacted_headers = tuple(
|
|
250
|
+
(k, "REDACTED") if k.lower() == "authorization" else (k, v)
|
|
251
|
+
for k, v in self.headers
|
|
252
|
+
)
|
|
253
|
+
self.headers = redacted_headers
|
|
254
|
+
self._init_lowercase_headers() # reinitialize lowercase headers after purging
|
|
255
|
+
|
|
256
|
+
def serialize(self, indent: int | None = None) -> str:
|
|
257
|
+
"""Serialize the ResponseMetadata object to a JSON string."""
|
|
258
|
+
return ResponseMetadataRoot(self).model_dump_json(indent=indent)
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
def deserialize(cls, json_str: str) -> ResponseMetadata:
|
|
262
|
+
"""Deserialize a ResponseMetadata object from a JSON string."""
|
|
263
|
+
return ResponseMetadataRoot.model_validate_json(json_str).root
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass(slots=True, kw_only=True, frozen=True)
|
|
267
|
+
class Response:
|
|
268
|
+
"""Represents one successful API response returned to callers."""
|
|
269
|
+
|
|
270
|
+
metadata: ResponseMetadata
|
|
271
|
+
"""The metadata of the response, including status code, headers, etc."""
|
|
272
|
+
json: Any
|
|
273
|
+
"""The parsed JSON body of the response."""
|
|
274
|
+
request: Request
|
|
275
|
+
"""The original request that generated this response."""
|
|
276
|
+
source: Source
|
|
277
|
+
"""The source of the response, for example cache or network."""
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def deserialize(cls, json_str: str) -> Response:
|
|
281
|
+
"""Deserialize a Response from JSON text."""
|
|
282
|
+
value = ResponseRoot.model_validate_json(json_str).root
|
|
283
|
+
return value
|
|
284
|
+
|
|
285
|
+
def purge_secrets(self) -> None:
|
|
286
|
+
"""Purge authorization headers from the response metadata for security purposes."""
|
|
287
|
+
self.metadata.purge_secrets()
|
|
288
|
+
self.request.purge_secrets()
|
|
289
|
+
|
|
290
|
+
def serialize(self, indent: int | None = None) -> str:
|
|
291
|
+
"""Serialize the Response object to a JSON string."""
|
|
292
|
+
return ResponseRoot(self).model_dump_json(indent=indent)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
ResponseRoot = RootModel[Response]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass(slots=True, kw_only=True, frozen=True)
|
|
299
|
+
class FailedResponse:
|
|
300
|
+
"""Represents a failed ESI response, typically due to an error status code."""
|
|
301
|
+
|
|
302
|
+
metadata: ResponseMetadata | None = None
|
|
303
|
+
"""The metadata of the response, including status code, headers, etc. May be None
|
|
304
|
+
if the request failed before receiving a response."""
|
|
305
|
+
json: Any | None = None
|
|
306
|
+
"""The parsed JSON body of the response. May be None if unavailable."""
|
|
307
|
+
request: Request
|
|
308
|
+
"""The original request that generated this failed response."""
|
|
309
|
+
error_messages: list[str] = field(default_factory=list[str])
|
|
310
|
+
"""An optional list of error messages describing the failure."""
|
|
311
|
+
|
|
312
|
+
def purge_secrets(self) -> None:
|
|
313
|
+
"""Purge authorization headers from the response metadata for security purposes."""
|
|
314
|
+
if self.metadata:
|
|
315
|
+
self.metadata.purge_secrets()
|
|
316
|
+
self.request.purge_secrets()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@dataclass(slots=True, kw_only=True, frozen=True)
|
|
320
|
+
class Responses:
|
|
321
|
+
"""Container for batched request outcomes."""
|
|
322
|
+
|
|
323
|
+
successful: dict[UUID, Response] = field(default_factory=dict[UUID, Response])
|
|
324
|
+
"""A dictionary of successful responses, keyed by request UUID."""
|
|
325
|
+
failed: dict[UUID, FailedResponse] = field(
|
|
326
|
+
default_factory=dict[UUID, FailedResponse]
|
|
327
|
+
)
|
|
328
|
+
"""A dictionary of failed responses, keyed by request UUID."""
|
|
329
|
+
|
|
330
|
+
def to_string(self, indent: int) -> str:
|
|
331
|
+
"""Serialize this Responses container as JSON text.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
indent: Indentation level passed to pydantic JSON serializer.
|
|
335
|
+
"""
|
|
336
|
+
json_str = ResponsesRoot(root=self).model_dump_json(indent=indent)
|
|
337
|
+
return json_str
|
|
338
|
+
|
|
339
|
+
@classmethod
|
|
340
|
+
def from_string(cls, json_str: str) -> Responses:
|
|
341
|
+
"""Deserialize a Responses container from JSON text."""
|
|
342
|
+
value = ResponsesRoot.model_validate_json(json_str).root
|
|
343
|
+
return value
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
type Requests = dict[UUID, Request]
|
|
347
|
+
"""Batch request mapping keyed by request UUID."""
|
|
348
|
+
|
|
349
|
+
#######################################################################################
|
|
350
|
+
# Root Models
|
|
351
|
+
#######################################################################################
|
|
352
|
+
ResponseMetadataRoot = RootModel[ResponseMetadata]
|
|
353
|
+
ResponseRoot = RootModel[Response]
|
|
354
|
+
RequestsRoot = RootModel[Requests]
|
|
355
|
+
ResponsesRoot = RootModel[Responses]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Request orchestration protocol contracts.
|
|
2
|
+
|
|
3
|
+
This module defines the public behavioral surface expected from requester
|
|
4
|
+
implementations.
|
|
5
|
+
|
|
6
|
+
Typical usage:
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from api_request import ApiRequester, Request
|
|
12
|
+
from api_request.cache import InMemoryCache
|
|
13
|
+
from api_request.rate_limit import AiolimiterRateLimiterFactory
|
|
14
|
+
|
|
15
|
+
request = Request(
|
|
16
|
+
request_key=uuid4(),
|
|
17
|
+
method="GET",
|
|
18
|
+
url="https://esi.evetech.net/status/",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
async with ApiRequester(
|
|
22
|
+
cache_factory=InMemoryCache,
|
|
23
|
+
rate_limiter_factory=AiolimiterRateLimiterFactory(max_rate=100.0),
|
|
24
|
+
) as requester:
|
|
25
|
+
responses = await requester.process_requests({request.request_key: request})
|
|
26
|
+
```
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from typing import Protocol
|
|
30
|
+
|
|
31
|
+
from pfmsoft.api_request.request.models import (
|
|
32
|
+
Requests,
|
|
33
|
+
Responses,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ApiRequesterProtocol(Protocol):
|
|
38
|
+
"""Protocol for batched request orchestration.
|
|
39
|
+
|
|
40
|
+
Contract:
|
|
41
|
+
- Input keys in `requests` are preserved in output mappings.
|
|
42
|
+
- Successful request outcomes are returned in `Responses.successful`.
|
|
43
|
+
- Request-scoped failures are returned in `Responses.failed`.
|
|
44
|
+
- Implementations are typically used as async context managers to
|
|
45
|
+
provision HTTP/cache/rate-limit dependencies.
|
|
46
|
+
- Implementations may raise for fatal infrastructure/configuration errors.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
async def process_requests(
|
|
50
|
+
self,
|
|
51
|
+
requests: Requests,
|
|
52
|
+
) -> Responses:
|
|
53
|
+
"""Process a batch of requests and return normalized success/failure maps."""
|
|
54
|
+
...
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Settings for the api-request module."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from uuid import NAMESPACE_DNS, uuid5
|
|
7
|
+
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
from typer import get_app_dir
|
|
10
|
+
|
|
11
|
+
from pfmsoft.api_request import (
|
|
12
|
+
__app_name__,
|
|
13
|
+
__project_namespace__,
|
|
14
|
+
__url__,
|
|
15
|
+
__version__,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
USER_AGENT = f"{__app_name__} ({__version__}) (+{__url__})"
|
|
21
|
+
APP_DOMAIN = f"{__project_namespace__}.{__app_name__}"
|
|
22
|
+
APP_NAMESPACE = uuid5(NAMESPACE_DNS, APP_DOMAIN)
|
|
23
|
+
ENV_PREFIX = APP_DOMAIN.replace(".", "_").replace("-", "_").upper() + "_"
|
|
24
|
+
SETTINGS_KEY = ENV_PREFIX + "SETTINGS"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True, kw_only=True)
|
|
28
|
+
class ApiRequestSettings:
|
|
29
|
+
"""Settings for the api-request module."""
|
|
30
|
+
|
|
31
|
+
application_directory: Path
|
|
32
|
+
logging_directory: Path
|
|
33
|
+
web_cache_path: Path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ApiRequestSettingsPydantic(BaseSettings):
|
|
37
|
+
"""Pydantic settings for the api-request module."""
|
|
38
|
+
|
|
39
|
+
application_directory: Path = Path(get_app_dir(__app_name__))
|
|
40
|
+
|
|
41
|
+
model_config = SettingsConfigDict(
|
|
42
|
+
env_prefix=ENV_PREFIX,
|
|
43
|
+
env_file=(".env", ".env.dev"),
|
|
44
|
+
env_file_encoding="utf-8",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_settings() -> ApiRequestSettings:
|
|
49
|
+
"""Get the settings for the api-request module."""
|
|
50
|
+
logger.info("Loading settings from environment variables...")
|
|
51
|
+
logger.info(f"Environment variable prefix: {ENV_PREFIX}")
|
|
52
|
+
pydantic_settings = ApiRequestSettingsPydantic()
|
|
53
|
+
logger.info(
|
|
54
|
+
f"Loaded settings from environment variables: {pydantic_settings.model_dump()}"
|
|
55
|
+
)
|
|
56
|
+
application_directory = pydantic_settings.application_directory.resolve()
|
|
57
|
+
if not application_directory.exists():
|
|
58
|
+
application_directory.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
settings = ApiRequestSettings(
|
|
60
|
+
application_directory=application_directory,
|
|
61
|
+
logging_directory=application_directory / "logs",
|
|
62
|
+
web_cache_path=application_directory / "api_requests_web_cache.sqlite",
|
|
63
|
+
)
|
|
64
|
+
logger.info(f"Resolved application settings: {asdict(settings)}")
|
|
65
|
+
return settings
|