pympacds-http 0.1.0__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.
- pympacds_http/__init__.py +3 -0
- pympacds_http/contracts.py +43 -0
- pympacds_http/service.py +420 -0
- pympacds_http-0.1.0.dist-info/METADATA +102 -0
- pympacds_http-0.1.0.dist-info/RECORD +8 -0
- pympacds_http-0.1.0.dist-info/WHEEL +5 -0
- pympacds_http-0.1.0.dist-info/entry_points.txt +2 -0
- pympacds_http-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""HTTP D-Bus contract (REQ-HTTP-004)."""
|
|
2
|
+
|
|
3
|
+
from pympacds.contracts import ServiceContract, dbus_method, dbus_signal
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HttpContract(ServiceContract):
|
|
7
|
+
"""HTTP request interface."""
|
|
8
|
+
|
|
9
|
+
iface_name = "HTTP"
|
|
10
|
+
iface_version = "1.0.0"
|
|
11
|
+
iface_provides = ["http"]
|
|
12
|
+
iface_requires = ["network"]
|
|
13
|
+
|
|
14
|
+
def __init__(self, ifname: str, base):
|
|
15
|
+
super().__init__(ifname, base)
|
|
16
|
+
self._require(
|
|
17
|
+
"dbus_http_send",
|
|
18
|
+
"dbus_http_download",
|
|
19
|
+
"dbus_http_request",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
@dbus_method()
|
|
23
|
+
def send(self, payload: "s") -> "s":
|
|
24
|
+
return self.base.dbus_http_send(payload)
|
|
25
|
+
|
|
26
|
+
@dbus_method()
|
|
27
|
+
def download(self, payload: "s", download_path: "s") -> "s":
|
|
28
|
+
return self.base.dbus_http_download(payload, download_path)
|
|
29
|
+
|
|
30
|
+
@dbus_method()
|
|
31
|
+
def request(
|
|
32
|
+
self,
|
|
33
|
+
method: "s",
|
|
34
|
+
url: "s",
|
|
35
|
+
headers: "s",
|
|
36
|
+
body: "s",
|
|
37
|
+
download_path: "s",
|
|
38
|
+
) -> "s":
|
|
39
|
+
return self.base.dbus_http_request(method, url, headers, body, download_path)
|
|
40
|
+
|
|
41
|
+
@dbus_signal()
|
|
42
|
+
def request_completed(self, token: "s", result: "s") -> "ss":
|
|
43
|
+
return [token, result]
|
pympacds_http/service.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""HTTP client service (REQ-HTTP-001..007)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import configparser
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import ssl
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import aiohttp
|
|
14
|
+
from pympacds.builtin_contracts import ConfigContract, HealthContract
|
|
15
|
+
from pympacds.dbus import DBusManager
|
|
16
|
+
from pympacds.process import ProcessBase
|
|
17
|
+
|
|
18
|
+
from .contracts import HttpContract
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HttpService(ProcessBase):
|
|
22
|
+
"""Sends HTTP requests on behalf of other services, exposed over D-Bus.
|
|
23
|
+
|
|
24
|
+
``send``/``download``/``request`` follow a fire-and-forget pattern: each
|
|
25
|
+
returns a random request token immediately and the actual request runs as
|
|
26
|
+
an asyncio task; on completion the ``request_completed(token, result)``
|
|
27
|
+
signal is emitted (REQ-HTTP-004).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
session_factory: Optional callable ``f(service) -> session`` returning
|
|
31
|
+
an aiohttp-like session, used by tests to inject a fake (no
|
|
32
|
+
network). Defaults to building a real ``aiohttp.ClientSession``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, session_factory=None):
|
|
36
|
+
super().__init__(
|
|
37
|
+
name="http",
|
|
38
|
+
version="0.1.0",
|
|
39
|
+
description="HTTP client service for pympacds",
|
|
40
|
+
)
|
|
41
|
+
self.bus: Any = None # DBusManager, created in start_dbus()
|
|
42
|
+
self._start_time = time.time()
|
|
43
|
+
self._session_factory = session_factory
|
|
44
|
+
self._session: aiohttp.ClientSession | None = None
|
|
45
|
+
self._contract = None
|
|
46
|
+
self._health_contract = None
|
|
47
|
+
self._config_contract = None
|
|
48
|
+
self._tasks: set[asyncio.Task] = set()
|
|
49
|
+
|
|
50
|
+
# -- config helpers -------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def _cfg(self, key: str, default):
|
|
53
|
+
try:
|
|
54
|
+
return self.config["http"].get(key, default)
|
|
55
|
+
except (KeyError, configparser.Error):
|
|
56
|
+
return default
|
|
57
|
+
|
|
58
|
+
def _cfg_int(self, key: str, default: int) -> int:
|
|
59
|
+
try:
|
|
60
|
+
return int(self._cfg(key, default))
|
|
61
|
+
except (TypeError, ValueError):
|
|
62
|
+
return default
|
|
63
|
+
|
|
64
|
+
def _cfg_bool(self, key: str, default: bool) -> bool:
|
|
65
|
+
return str(self._cfg(key, default)).lower() in ("true", "1", "yes", "on")
|
|
66
|
+
|
|
67
|
+
def _dbus_flag(self, key: str, default: bool) -> bool:
|
|
68
|
+
if not self.config.has_section("dbus"):
|
|
69
|
+
return default
|
|
70
|
+
try:
|
|
71
|
+
return self.config["dbus"].getboolean(key, default)
|
|
72
|
+
except ValueError:
|
|
73
|
+
return default
|
|
74
|
+
|
|
75
|
+
# -- synchronous setup ----------------------------------------------
|
|
76
|
+
|
|
77
|
+
def setup(self, inputargs: list[str] | None = None) -> bool:
|
|
78
|
+
"""Parse args, load config, and validate the mutual-TLS pair."""
|
|
79
|
+
if not super().setup(inputargs):
|
|
80
|
+
return False
|
|
81
|
+
return self._validate_tls_pair()
|
|
82
|
+
|
|
83
|
+
def _validate_tls_pair(self) -> bool:
|
|
84
|
+
"""Require ``tls_certfile``/``tls_keyfile`` together (REQ-HTTP-006)."""
|
|
85
|
+
certfile = self._cfg("tls_certfile", "").strip()
|
|
86
|
+
keyfile = self._cfg("tls_keyfile", "").strip()
|
|
87
|
+
if bool(certfile) != bool(keyfile):
|
|
88
|
+
self.logger.error("http.tls_certfile and http.tls_keyfile must be provided together")
|
|
89
|
+
return False
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
# -- D-Bus setup ----------------------------------------------------
|
|
93
|
+
|
|
94
|
+
async def start_dbus(self) -> bool:
|
|
95
|
+
self.bus = DBusManager(
|
|
96
|
+
logger=self.logger,
|
|
97
|
+
busname="http",
|
|
98
|
+
bus_prefix=self.bus_prefix,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
self._contract = HttpContract(f"{self.bus_prefix}.HTTP", self)
|
|
102
|
+
self.bus.add_interface("http", self._contract)
|
|
103
|
+
|
|
104
|
+
if self._dbus_flag("contract_health", True):
|
|
105
|
+
self._health_contract = HealthContract(f"{self.bus_prefix}.Health", self)
|
|
106
|
+
self.bus.add_interface("health", self._health_contract)
|
|
107
|
+
|
|
108
|
+
if self._dbus_flag("contract_config", False):
|
|
109
|
+
self._config_contract = ConfigContract(f"{self.bus_prefix}.Config", self)
|
|
110
|
+
self.bus.add_interface("config", self._config_contract)
|
|
111
|
+
|
|
112
|
+
await self.bus.start()
|
|
113
|
+
|
|
114
|
+
self._build_session()
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
def _build_session(self) -> None:
|
|
118
|
+
"""Build the aiohttp session (no network I/O) — REQ-HTTP-006."""
|
|
119
|
+
if self._session_factory is not None:
|
|
120
|
+
self._session = self._session_factory(self)
|
|
121
|
+
return
|
|
122
|
+
timeout = aiohttp.ClientTimeout(total=self._cfg_int("timeout_s", 10))
|
|
123
|
+
connector = aiohttp.TCPConnector(ssl=self._build_ssl_context())
|
|
124
|
+
self._session = aiohttp.ClientSession(timeout=timeout, connector=connector)
|
|
125
|
+
|
|
126
|
+
def _build_ssl_context(self):
|
|
127
|
+
"""Return the connector ``ssl`` value: a context for mutual TLS,
|
|
128
|
+
``False`` to disable verification, or ``True`` for the default."""
|
|
129
|
+
tls_verify = self._cfg_bool("tls_verify", True)
|
|
130
|
+
certfile = self._cfg("tls_certfile", "").strip()
|
|
131
|
+
keyfile = self._cfg("tls_keyfile", "").strip()
|
|
132
|
+
|
|
133
|
+
if certfile and keyfile:
|
|
134
|
+
ctx = ssl.create_default_context()
|
|
135
|
+
ctx.load_cert_chain(certfile=certfile, keyfile=keyfile)
|
|
136
|
+
if not tls_verify:
|
|
137
|
+
ctx.check_hostname = False
|
|
138
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
139
|
+
return ctx
|
|
140
|
+
if not tls_verify:
|
|
141
|
+
return False
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
# -- fire-and-forget D-Bus handlers (REQ-HTTP-004) ------------------
|
|
145
|
+
|
|
146
|
+
def dbus_http_send(self, payload: str) -> str:
|
|
147
|
+
token = secrets.token_hex(16)
|
|
148
|
+
self._spawn(token, self._run_send(payload))
|
|
149
|
+
return token
|
|
150
|
+
|
|
151
|
+
def dbus_http_download(self, payload: str, download_path: str) -> str:
|
|
152
|
+
token = secrets.token_hex(16)
|
|
153
|
+
self._spawn(token, self._run_send(payload, download_path=download_path))
|
|
154
|
+
return token
|
|
155
|
+
|
|
156
|
+
def dbus_http_request(
|
|
157
|
+
self,
|
|
158
|
+
method: str,
|
|
159
|
+
url: str,
|
|
160
|
+
headers: str,
|
|
161
|
+
body: str,
|
|
162
|
+
download_path: str,
|
|
163
|
+
) -> str:
|
|
164
|
+
token = secrets.token_hex(16)
|
|
165
|
+
self._spawn(token, self._run_request(method, url, headers, body, download_path))
|
|
166
|
+
return token
|
|
167
|
+
|
|
168
|
+
def _spawn(self, token: str, coro) -> None:
|
|
169
|
+
"""Schedule a request task on the running loop."""
|
|
170
|
+
loop = asyncio.get_running_loop()
|
|
171
|
+
task = loop.create_task(self._complete(token, coro), name=f"http_{token}")
|
|
172
|
+
self._tasks.add(task)
|
|
173
|
+
task.add_done_callback(self._tasks.discard)
|
|
174
|
+
|
|
175
|
+
async def _complete(self, token: str, coro) -> None:
|
|
176
|
+
"""Await a request and emit ``request_completed`` (never lost)."""
|
|
177
|
+
try:
|
|
178
|
+
result = await coro
|
|
179
|
+
except asyncio.CancelledError:
|
|
180
|
+
raise
|
|
181
|
+
except Exception as exc: # noqa: BLE001
|
|
182
|
+
result = {
|
|
183
|
+
"status_code": None,
|
|
184
|
+
"url": "",
|
|
185
|
+
"elapsed_ms": 0,
|
|
186
|
+
"body": None,
|
|
187
|
+
"download_path": None,
|
|
188
|
+
"bytes_written": None,
|
|
189
|
+
"error": f"internal error: {exc}",
|
|
190
|
+
}
|
|
191
|
+
self._emit_completed(token, result)
|
|
192
|
+
|
|
193
|
+
def _emit_completed(self, token: str, result: dict) -> None:
|
|
194
|
+
if self._contract is not None:
|
|
195
|
+
self._contract.request_completed(token, json.dumps(result))
|
|
196
|
+
|
|
197
|
+
# -- request execution ----------------------------------------------
|
|
198
|
+
|
|
199
|
+
async def _run_send(self, payload: str, download_path: str | None = None) -> dict:
|
|
200
|
+
url = self._cfg("default_url", "").strip()
|
|
201
|
+
method = self._cfg("default_method", "POST").strip()
|
|
202
|
+
headers = self._parse_headers(self._cfg("default_headers", ""))
|
|
203
|
+
return await self._execute(
|
|
204
|
+
method, url, headers, payload, self._normalize_path(download_path)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
async def _run_request(
|
|
208
|
+
self,
|
|
209
|
+
method: str,
|
|
210
|
+
url: str,
|
|
211
|
+
headers: str,
|
|
212
|
+
body: str,
|
|
213
|
+
download_path: str,
|
|
214
|
+
) -> dict:
|
|
215
|
+
parsed_headers = self._parse_headers(headers)
|
|
216
|
+
return await self._execute(
|
|
217
|
+
method, url, parsed_headers, body, self._normalize_path(download_path)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _normalize_path(path: str | None) -> str | None:
|
|
222
|
+
if not path:
|
|
223
|
+
return None
|
|
224
|
+
path = path.strip()
|
|
225
|
+
return path or None
|
|
226
|
+
|
|
227
|
+
def _parse_headers(self, raw: str) -> dict:
|
|
228
|
+
"""Parse a JSON-object header string; invalid input yields {}."""
|
|
229
|
+
if not raw or not raw.strip():
|
|
230
|
+
return {}
|
|
231
|
+
try:
|
|
232
|
+
data = json.loads(raw)
|
|
233
|
+
except json.JSONDecodeError:
|
|
234
|
+
self.logger.warning("http: headers are not valid JSON; ignoring")
|
|
235
|
+
return {}
|
|
236
|
+
if not isinstance(data, dict):
|
|
237
|
+
self.logger.warning("http: headers must be a JSON object; ignoring")
|
|
238
|
+
return {}
|
|
239
|
+
return {str(k): str(v) for k, v in data.items()}
|
|
240
|
+
|
|
241
|
+
async def _execute(
|
|
242
|
+
self,
|
|
243
|
+
method: str,
|
|
244
|
+
url: str,
|
|
245
|
+
headers: dict,
|
|
246
|
+
body: str,
|
|
247
|
+
download_path: str | None,
|
|
248
|
+
) -> dict:
|
|
249
|
+
"""Run a request with retry/backoff and return the result JSON (REQ-HTTP-005)."""
|
|
250
|
+
start = time.monotonic()
|
|
251
|
+
|
|
252
|
+
def _result(**fields) -> dict:
|
|
253
|
+
result = {
|
|
254
|
+
"status_code": None,
|
|
255
|
+
"url": url,
|
|
256
|
+
"elapsed_ms": self._elapsed_ms(start),
|
|
257
|
+
"body": None,
|
|
258
|
+
"download_path": download_path,
|
|
259
|
+
"bytes_written": None,
|
|
260
|
+
"error": None,
|
|
261
|
+
}
|
|
262
|
+
result.update(fields)
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
if not url:
|
|
266
|
+
return _result(error="no URL configured")
|
|
267
|
+
|
|
268
|
+
session = self._session
|
|
269
|
+
if session is None:
|
|
270
|
+
return _result(error="HTTP session is not started")
|
|
271
|
+
|
|
272
|
+
retries = max(0, self._cfg_int("retries", 3))
|
|
273
|
+
backoff = max(0, self._cfg_int("retry_backoff_s", 1))
|
|
274
|
+
|
|
275
|
+
for attempt in range(1, retries + 2):
|
|
276
|
+
ok, fields = await self._attempt(session, method, url, headers, body, download_path)
|
|
277
|
+
if ok:
|
|
278
|
+
return _result(**fields)
|
|
279
|
+
if attempt <= retries:
|
|
280
|
+
delay = backoff * (2 ** (attempt - 1))
|
|
281
|
+
self.logger.warning(
|
|
282
|
+
"http: attempt %d failed (%s); retrying in %.1fs",
|
|
283
|
+
attempt,
|
|
284
|
+
fields.get("error"),
|
|
285
|
+
delay,
|
|
286
|
+
)
|
|
287
|
+
await asyncio.sleep(delay)
|
|
288
|
+
else:
|
|
289
|
+
return _result(**fields)
|
|
290
|
+
|
|
291
|
+
return _result(error="unreachable") # pragma: no cover
|
|
292
|
+
|
|
293
|
+
async def _attempt(
|
|
294
|
+
self,
|
|
295
|
+
session,
|
|
296
|
+
method: str,
|
|
297
|
+
url: str,
|
|
298
|
+
headers: dict,
|
|
299
|
+
body: str,
|
|
300
|
+
download_path: str | None,
|
|
301
|
+
) -> tuple[bool, dict]:
|
|
302
|
+
"""Perform one request. Returns ``(ok, fields)``; ``ok=False`` marks
|
|
303
|
+
a transient failure (network error, timeout, or 5xx) to retry."""
|
|
304
|
+
max_redirects = max(0, self._cfg_int("max_redirects", 5))
|
|
305
|
+
try:
|
|
306
|
+
async with session.request(
|
|
307
|
+
method,
|
|
308
|
+
url,
|
|
309
|
+
headers=headers or None,
|
|
310
|
+
data=body or None,
|
|
311
|
+
max_redirects=max_redirects,
|
|
312
|
+
) as resp:
|
|
313
|
+
status = resp.status
|
|
314
|
+
final_url = str(resp.url)
|
|
315
|
+
payload = await resp.read()
|
|
316
|
+
|
|
317
|
+
if status >= 500:
|
|
318
|
+
return False, {
|
|
319
|
+
"status_code": status,
|
|
320
|
+
"url": final_url,
|
|
321
|
+
"error": f"server returned HTTP {status}",
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
fields: dict = {"status_code": status, "url": final_url, "error": None}
|
|
325
|
+
if download_path:
|
|
326
|
+
try:
|
|
327
|
+
written = self._write_atomic(download_path, payload)
|
|
328
|
+
except OSError as exc:
|
|
329
|
+
fields["error"] = f"download write failed: {exc}"
|
|
330
|
+
return True, fields
|
|
331
|
+
fields["download_path"] = download_path
|
|
332
|
+
fields["bytes_written"] = written
|
|
333
|
+
else:
|
|
334
|
+
fields["body"] = payload.decode("utf-8", errors="replace")
|
|
335
|
+
return True, fields
|
|
336
|
+
except (aiohttp.ClientError, asyncio.TimeoutError, OSError, ValueError) as exc:
|
|
337
|
+
return False, {"error": str(exc)}
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def _write_atomic(path: str, data: bytes) -> int:
|
|
341
|
+
"""Write ``data`` to ``path`` atomically (temp file + rename)."""
|
|
342
|
+
directory = os.path.dirname(path)
|
|
343
|
+
if directory:
|
|
344
|
+
os.makedirs(directory, exist_ok=True)
|
|
345
|
+
fd, tmp = tempfile.mkstemp(dir=directory or ".", prefix=".httpdl-")
|
|
346
|
+
try:
|
|
347
|
+
with os.fdopen(fd, "wb") as f:
|
|
348
|
+
f.write(data)
|
|
349
|
+
os.replace(tmp, path)
|
|
350
|
+
except Exception: # noqa: BLE001
|
|
351
|
+
try:
|
|
352
|
+
os.unlink(tmp)
|
|
353
|
+
except OSError:
|
|
354
|
+
pass
|
|
355
|
+
raise
|
|
356
|
+
return len(data)
|
|
357
|
+
|
|
358
|
+
@staticmethod
|
|
359
|
+
def _elapsed_ms(start: float) -> int:
|
|
360
|
+
return int((time.monotonic() - start) * 1000)
|
|
361
|
+
|
|
362
|
+
# -- graceful shutdown (REQ-HTTP-006) -------------------------------
|
|
363
|
+
|
|
364
|
+
async def close_loop(self) -> None:
|
|
365
|
+
for task in list(self._tasks):
|
|
366
|
+
task.cancel()
|
|
367
|
+
if self._tasks:
|
|
368
|
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
369
|
+
self._tasks.clear()
|
|
370
|
+
if self._session is not None:
|
|
371
|
+
await self._session.close()
|
|
372
|
+
self._session = None
|
|
373
|
+
await super().close_loop()
|
|
374
|
+
|
|
375
|
+
# -- health / config contract base callbacks (REQ-XCUT-004) --------
|
|
376
|
+
|
|
377
|
+
def dbus_health_ping(self) -> bool:
|
|
378
|
+
return True
|
|
379
|
+
|
|
380
|
+
def dbus_health_status(self) -> str:
|
|
381
|
+
return json.dumps(
|
|
382
|
+
{
|
|
383
|
+
"name": self.name,
|
|
384
|
+
"version": self.version,
|
|
385
|
+
"uptime": self.dbus_health_get_uptime(),
|
|
386
|
+
"provides": self.dbus_health_get_provides(),
|
|
387
|
+
"requires": self.dbus_health_get_requires(),
|
|
388
|
+
}
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def dbus_health_get_uptime(self) -> int:
|
|
392
|
+
return int(time.time() - self._start_time)
|
|
393
|
+
|
|
394
|
+
def dbus_config_get(self, section: str, key: str) -> str:
|
|
395
|
+
try:
|
|
396
|
+
return self.config[section][key]
|
|
397
|
+
except (KeyError, configparser.Error):
|
|
398
|
+
return ""
|
|
399
|
+
|
|
400
|
+
def dbus_config_set(self, section: str, key: str, value: str) -> bool:
|
|
401
|
+
try:
|
|
402
|
+
if not self.config.has_section(section):
|
|
403
|
+
self.config.add_section(section)
|
|
404
|
+
self.config[section][key] = value
|
|
405
|
+
except Exception: # noqa: BLE001
|
|
406
|
+
return False
|
|
407
|
+
if self._config_contract is not None:
|
|
408
|
+
try:
|
|
409
|
+
self._config_contract.config_changed(section, key, value)
|
|
410
|
+
except Exception: # noqa: BLE001
|
|
411
|
+
pass
|
|
412
|
+
return True
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def main() -> None:
|
|
416
|
+
HttpService().start()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
if __name__ == "__main__":
|
|
420
|
+
main()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pympacds-http
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HTTP client service for pympacds
|
|
5
|
+
Author-email: Oscar Diaz <odiaz@ieee.org>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: pympacds>=0.2.0
|
|
10
|
+
Requires-Dist: aiohttp>=3.0
|
|
11
|
+
|
|
12
|
+
# pympacds-http
|
|
13
|
+
|
|
14
|
+
HTTP client service for [pympacds](https://github.com/dargor0/pympacds). Sends
|
|
15
|
+
data over HTTP on behalf of other services and exposes it over D-Bus, with
|
|
16
|
+
automatic retry and optional mutual TLS.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- Fire-and-forget D-Bus API: `send`, `download`, and `request` each return a
|
|
21
|
+
random request token immediately; the result is delivered asynchronously via
|
|
22
|
+
the `request_completed(token, result)` signal, so callers correlate a call
|
|
23
|
+
with its outcome by token.
|
|
24
|
+
- A single pooled `aiohttp.ClientSession` (native asyncio, no thread
|
|
25
|
+
offloading), created at startup with no network I/O and closed on shutdown.
|
|
26
|
+
- Automatic retry of transient failures (network error, timeout, HTTP 5xx)
|
|
27
|
+
with exponential backoff (`retries`, `retry_backoff_s`).
|
|
28
|
+
- TLS verification control (`tls_verify`) plus mutual TLS via
|
|
29
|
+
`tls_certfile`/`tls_keyfile` (must be provided together).
|
|
30
|
+
- `download` / `request` with a `download_path` write the response body
|
|
31
|
+
atomically to disk (temp file + rename) for binary/large downloads.
|
|
32
|
+
- Runtime configuration: `[http]` keys are writable via the framework
|
|
33
|
+
`ConfigContract`.
|
|
34
|
+
- Exports the framework `HealthContract` (always) and `ConfigContract`
|
|
35
|
+
(opt-in via `[dbus] contract_config = true`) alongside the HTTP contract.
|
|
36
|
+
|
|
37
|
+
### Why aiohttp
|
|
38
|
+
|
|
39
|
+
The service uses [aiohttp](https://docs.aiohttp.org/) because it is asyncio
|
|
40
|
+
native: requests run directly on the framework's single event loop with no
|
|
41
|
+
thread offloading. It is isolated in this service's own package, so the core
|
|
42
|
+
`pympacds` framework keeps its zero-dependency property (the core's
|
|
43
|
+
`httpconfprov` middleware continues to use the stdlib `urllib.request`).
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install pympacds-http
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
The service reads its parameters from the `[http]` INI section (see
|
|
54
|
+
`config/http.ini.example`). Key options:
|
|
55
|
+
|
|
56
|
+
| Key | Default | Description |
|
|
57
|
+
|-----|---------|-------------|
|
|
58
|
+
| `timeout_s` | `10` | Per-request timeout |
|
|
59
|
+
| `retries` | `3` | Number of retry attempts |
|
|
60
|
+
| `retry_backoff_s` | `1` | Initial backoff (exponential) between retries |
|
|
61
|
+
| `tls_verify` | `true` | Verify TLS certificates |
|
|
62
|
+
| `tls_certfile` / `tls_keyfile` | `""` | Client cert/key (PEM) for mutual TLS |
|
|
63
|
+
| `max_redirects` | `5` | Maximum redirects to follow |
|
|
64
|
+
| `default_url` | `""` | Target URL used by `send()` (empty = `send()` fails) |
|
|
65
|
+
| `default_method` | `POST` | Default HTTP method used by `send()` |
|
|
66
|
+
| `default_headers` | `""` | Default headers (JSON object) used by `send()` |
|
|
67
|
+
|
|
68
|
+
Run with:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pympacds-http -c /etc/pympacds/http.ini
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## D-Bus API
|
|
75
|
+
|
|
76
|
+
Interface `org.pympacds.HTTP` at object path `/org/pympacds/http`:
|
|
77
|
+
|
|
78
|
+
| Member | Type | Description |
|
|
79
|
+
|--------|------|-------------|
|
|
80
|
+
| `send(payload)` | `send(s) -> s` | Send with default url/method/headers; returns a token |
|
|
81
|
+
| `download(payload, download_path)` | `download(ss) -> s` | Like `send`, but saves the body to `download_path` |
|
|
82
|
+
| `request(method, url, headers, body, download_path)` | `request(sssss) -> s` | Explicit request; empty `download_path` = return body |
|
|
83
|
+
| `request_completed(token, result)` | signal `(ss)` | Emitted on completion; carries the token and JSON result |
|
|
84
|
+
|
|
85
|
+
All sends are **fire-and-forget**: the method returns immediately with a
|
|
86
|
+
randomized token, the request runs asynchronously, and `request_completed` is
|
|
87
|
+
emitted with the full JSON outcome:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"status_code": 200,
|
|
92
|
+
"url": "https://example.com/api",
|
|
93
|
+
"elapsed_ms": 123,
|
|
94
|
+
"body": "...",
|
|
95
|
+
"download_path": null,
|
|
96
|
+
"bytes_written": null,
|
|
97
|
+
"error": null
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The framework `HealthContract` (at `.../health`) and `ConfigContract`
|
|
102
|
+
(`[dbus] contract_config = true`) are also exported.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pympacds_http/__init__.py,sha256=C-do81Q61iZRjMsxRFFS-RRAGHxHTpFop_6rqNRKd7U,81
|
|
2
|
+
pympacds_http/contracts.py,sha256=VbWnBkLg5KQNVPlnfzA5lk5qLfhcTdmqq0slOrJbIuU,1157
|
|
3
|
+
pympacds_http/service.py,sha256=H6sg5r10zITm3WjdYbtHnTaMexac9g59qiVSX9NXUXQ,14790
|
|
4
|
+
pympacds_http-0.1.0.dist-info/METADATA,sha256=pCM5-Ow3E1I01aAj4Yk7QVlT7rfI9XVZT2Law0nvaQo,3939
|
|
5
|
+
pympacds_http-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
pympacds_http-0.1.0.dist-info/entry_points.txt,sha256=_FK5STvcctcxKFNwGfSDmJfPwB4R_Z2685HeOIH7BpY,61
|
|
7
|
+
pympacds_http-0.1.0.dist-info/top_level.txt,sha256=m9QXEtI4zX5-pc4ACLiXV2uhKjZqIxiqT1C_ztXSmCg,14
|
|
8
|
+
pympacds_http-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pympacds_http
|