relaybus-http 0.0.2__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.
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Callable, Dict, Optional
|
|
5
|
+
from urllib.parse import quote, urlparse
|
|
6
|
+
import http.client
|
|
7
|
+
import threading
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
+
|
|
10
|
+
from relaybus_core import Message, OutgoingMessage, decode_envelope, encode_envelope
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class HttpPublisherConfig:
|
|
15
|
+
endpoint: str
|
|
16
|
+
doer: Callable[[str, str, Dict[str, str], bytes], int]
|
|
17
|
+
headers: Optional[Dict[str, str]] = None
|
|
18
|
+
idempotency_header: str = "Idempotency-Key"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class HttpPublisherConnectConfig:
|
|
23
|
+
endpoint: str
|
|
24
|
+
headers: Optional[Dict[str, str]] = None
|
|
25
|
+
idempotency_header: str = "Idempotency-Key"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HttpPublisher:
|
|
29
|
+
def __init__(self, config: HttpPublisherConfig) -> None:
|
|
30
|
+
if not config.endpoint:
|
|
31
|
+
raise ValueError("endpoint is required")
|
|
32
|
+
if config.doer is None:
|
|
33
|
+
raise ValueError("doer is required")
|
|
34
|
+
self._endpoint = config.endpoint
|
|
35
|
+
self._doer = config.doer
|
|
36
|
+
self._headers = config.headers or {}
|
|
37
|
+
self._idempotency_header = config.idempotency_header
|
|
38
|
+
|
|
39
|
+
def publish(self, topic: str, message: OutgoingMessage) -> None:
|
|
40
|
+
resolved = _resolve_topic(topic, message.topic)
|
|
41
|
+
body = encode_envelope(
|
|
42
|
+
OutgoingMessage(
|
|
43
|
+
topic=resolved,
|
|
44
|
+
payload=message.payload,
|
|
45
|
+
id=message.id,
|
|
46
|
+
ts=message.ts,
|
|
47
|
+
content_type=message.content_type,
|
|
48
|
+
meta=message.meta,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
url = _build_endpoint(self._endpoint, resolved)
|
|
52
|
+
headers = {"Content-Type": "application/json", **self._headers}
|
|
53
|
+
if message.id:
|
|
54
|
+
headers[self._idempotency_header] = message.id
|
|
55
|
+
status = self._doer("POST", url, headers, body)
|
|
56
|
+
if status < 200 or status >= 300:
|
|
57
|
+
raise ValueError(f"http status {status}")
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def connect(cls, config: HttpPublisherConnectConfig) -> "HttpPublisher":
|
|
61
|
+
def doer(method: str, url: str, headers: Dict[str, str], body: bytes) -> int:
|
|
62
|
+
parsed = urlparse(url)
|
|
63
|
+
if parsed.scheme == "https":
|
|
64
|
+
conn = http.client.HTTPSConnection(parsed.hostname, parsed.port)
|
|
65
|
+
else:
|
|
66
|
+
conn = http.client.HTTPConnection(parsed.hostname, parsed.port)
|
|
67
|
+
path = parsed.path or "/"
|
|
68
|
+
conn.request(method, path, body=body, headers=headers)
|
|
69
|
+
response = conn.getresponse()
|
|
70
|
+
response.read()
|
|
71
|
+
status = response.status
|
|
72
|
+
conn.close()
|
|
73
|
+
return status
|
|
74
|
+
|
|
75
|
+
return cls(
|
|
76
|
+
HttpPublisherConfig(
|
|
77
|
+
endpoint=config.endpoint,
|
|
78
|
+
doer=doer,
|
|
79
|
+
headers=config.headers,
|
|
80
|
+
idempotency_header=config.idempotency_header,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class HttpSubscriberConfig:
|
|
87
|
+
on_message: Callable[[Message], None]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class HttpSubscriberListenConfig:
|
|
92
|
+
port: int
|
|
93
|
+
host: str = ""
|
|
94
|
+
timeout: float = 30.0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class HttpSubscriber:
|
|
98
|
+
def __init__(self, config: HttpSubscriberConfig) -> None:
|
|
99
|
+
self._on_message = config.on_message
|
|
100
|
+
|
|
101
|
+
def handle(self, body: bytes | str) -> None:
|
|
102
|
+
decoded = decode_envelope(body)
|
|
103
|
+
self._on_message(decoded)
|
|
104
|
+
|
|
105
|
+
def listen(self, config: HttpSubscriberListenConfig) -> None:
|
|
106
|
+
stop_event = threading.Event()
|
|
107
|
+
|
|
108
|
+
class Handler(BaseHTTPRequestHandler):
|
|
109
|
+
def do_POST(self): # noqa: N802
|
|
110
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
111
|
+
body = self.rfile.read(length)
|
|
112
|
+
try:
|
|
113
|
+
self.server.subscriber.handle(body)
|
|
114
|
+
self.send_response(204)
|
|
115
|
+
stop_event.set()
|
|
116
|
+
except Exception:
|
|
117
|
+
self.send_response(400)
|
|
118
|
+
self.end_headers()
|
|
119
|
+
|
|
120
|
+
server = HTTPServer((config.host, config.port), Handler)
|
|
121
|
+
server.subscriber = self
|
|
122
|
+
|
|
123
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
124
|
+
thread.start()
|
|
125
|
+
if not stop_event.wait(timeout=config.timeout):
|
|
126
|
+
server.shutdown()
|
|
127
|
+
server.server_close()
|
|
128
|
+
thread.join(timeout=1)
|
|
129
|
+
raise TimeoutError("timeout waiting for message")
|
|
130
|
+
server.shutdown()
|
|
131
|
+
server.server_close()
|
|
132
|
+
thread.join(timeout=1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_topic(argument_topic: str, message_topic: Optional[str]) -> str:
|
|
136
|
+
topic = message_topic or argument_topic
|
|
137
|
+
if not topic:
|
|
138
|
+
raise ValueError("topic is required")
|
|
139
|
+
if argument_topic and message_topic and argument_topic != message_topic:
|
|
140
|
+
raise ValueError(f"topic mismatch: {message_topic} vs {argument_topic}")
|
|
141
|
+
return topic
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _build_endpoint(endpoint: str, topic: str) -> str:
|
|
145
|
+
if "{topic}" in endpoint:
|
|
146
|
+
return endpoint.replace("{topic}", quote(topic))
|
|
147
|
+
if not topic:
|
|
148
|
+
return endpoint
|
|
149
|
+
return endpoint.rstrip("/") + "/" + quote(topic)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
__all__ = [
|
|
153
|
+
"HttpPublisher",
|
|
154
|
+
"HttpPublisherConfig",
|
|
155
|
+
"HttpPublisherConnectConfig",
|
|
156
|
+
"HttpSubscriber",
|
|
157
|
+
"HttpSubscriberConfig",
|
|
158
|
+
"HttpSubscriberListenConfig",
|
|
159
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: relaybus-http
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Relaybus HTTP publisher and subscriber
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: relaybus-core
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# relaybus-http (Python)
|
|
10
|
+
|
|
11
|
+
HTTP publisher and subscriber utilities for Relaybus.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
pip install relaybus-http
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Example
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from relaybus_core import OutgoingMessage
|
|
23
|
+
from relaybus_http import (
|
|
24
|
+
HttpPublisher,
|
|
25
|
+
HttpPublisherConnectConfig,
|
|
26
|
+
HttpSubscriber,
|
|
27
|
+
HttpSubscriberConfig,
|
|
28
|
+
HttpSubscriberListenConfig,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
subscriber = HttpSubscriber(
|
|
32
|
+
HttpSubscriberConfig(
|
|
33
|
+
on_message=lambda msg: print(msg.topic, msg.payload),
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
subscriber.listen(HttpSubscriberListenConfig(port=8088))
|
|
37
|
+
|
|
38
|
+
publisher = HttpPublisher.connect(
|
|
39
|
+
HttpPublisherConnectConfig(endpoint="http://localhost:8088/{topic}")
|
|
40
|
+
)
|
|
41
|
+
publisher.publish("relaybus.demo", OutgoingMessage(topic="relaybus.demo", payload=b"hello"))
|
|
42
|
+
```
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
relaybus_http/__init__.py,sha256=GQZRs9aKu2RQocbZzNeYAJgwnyND7Es9TO8S_Fe3M54,5194
|
|
2
|
+
relaybus_http-0.0.2.dist-info/METADATA,sha256=Gjo9PvNjHNk8_YbspYh-AW7m3Zf0R025nuwgYTAXo3c,940
|
|
3
|
+
relaybus_http-0.0.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
4
|
+
relaybus_http-0.0.2.dist-info/RECORD,,
|