relaybus-http 0.0.2__tar.gz
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,26 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
*.log
|
|
3
|
+
|
|
4
|
+
# Go
|
|
5
|
+
*.test
|
|
6
|
+
*.out
|
|
7
|
+
|
|
8
|
+
# Node/TypeScript
|
|
9
|
+
node_modules/
|
|
10
|
+
dist/
|
|
11
|
+
*.tsbuildinfo
|
|
12
|
+
coverage/
|
|
13
|
+
|
|
14
|
+
# Python
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.pyc
|
|
17
|
+
*.pyo
|
|
18
|
+
*.pyd
|
|
19
|
+
.venv/
|
|
20
|
+
venv/
|
|
21
|
+
*.egg-info/
|
|
22
|
+
.pytest_cache/
|
|
23
|
+
|
|
24
|
+
# IDE
|
|
25
|
+
.idea/
|
|
26
|
+
.vscode/
|
|
@@ -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,34 @@
|
|
|
1
|
+
# relaybus-http (Python)
|
|
2
|
+
|
|
3
|
+
HTTP publisher and subscriber utilities for Relaybus.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install relaybus-http
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Example
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from relaybus_core import OutgoingMessage
|
|
15
|
+
from relaybus_http import (
|
|
16
|
+
HttpPublisher,
|
|
17
|
+
HttpPublisherConnectConfig,
|
|
18
|
+
HttpSubscriber,
|
|
19
|
+
HttpSubscriberConfig,
|
|
20
|
+
HttpSubscriberListenConfig,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
subscriber = HttpSubscriber(
|
|
24
|
+
HttpSubscriberConfig(
|
|
25
|
+
on_message=lambda msg: print(msg.topic, msg.payload),
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
subscriber.listen(HttpSubscriberListenConfig(port=8088))
|
|
29
|
+
|
|
30
|
+
publisher = HttpPublisher.connect(
|
|
31
|
+
HttpPublisherConnectConfig(endpoint="http://localhost:8088/{topic}")
|
|
32
|
+
)
|
|
33
|
+
publisher.publish("relaybus.demo", OutgoingMessage(topic="relaybus.demo", payload=b"hello"))
|
|
34
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "relaybus-http"
|
|
7
|
+
version = "0.0.2"
|
|
8
|
+
description = "Relaybus HTTP publisher and subscriber"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = ["relaybus-core"]
|
|
12
|
+
|
|
13
|
+
[tool.hatch.build.targets.wheel]
|
|
14
|
+
packages = ["relaybus_http"]
|
|
@@ -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,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from relaybus_core import OutgoingMessage, decode_envelope
|
|
8
|
+
from relaybus_http import HttpPublisher, HttpPublisherConfig, HttpSubscriber, HttpSubscriberConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_http_publisher_posts_envelope():
|
|
12
|
+
calls = []
|
|
13
|
+
|
|
14
|
+
def doer(method, url, headers, body):
|
|
15
|
+
calls.append({"method": method, "url": url, "headers": headers, "body": body})
|
|
16
|
+
return 204
|
|
17
|
+
|
|
18
|
+
publisher = HttpPublisher(
|
|
19
|
+
HttpPublisherConfig(endpoint="https://example.test/{topic}", doer=doer)
|
|
20
|
+
)
|
|
21
|
+
publisher.publish("alpha", OutgoingMessage(topic="alpha", payload=b"hi", id="id-1"))
|
|
22
|
+
|
|
23
|
+
assert len(calls) == 1
|
|
24
|
+
call = calls[0]
|
|
25
|
+
assert call["url"] == "https://example.test/alpha"
|
|
26
|
+
assert call["headers"]["Content-Type"] == "application/json"
|
|
27
|
+
assert call["headers"]["Idempotency-Key"] == "id-1"
|
|
28
|
+
decoded = decode_envelope(call["body"])
|
|
29
|
+
assert decoded.id == "id-1"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_http_publisher_rejects_non_2xx():
|
|
33
|
+
def doer(method, url, headers, body):
|
|
34
|
+
return 500
|
|
35
|
+
|
|
36
|
+
publisher = HttpPublisher(
|
|
37
|
+
HttpPublisherConfig(endpoint="https://example.test/{topic}", doer=doer)
|
|
38
|
+
)
|
|
39
|
+
with pytest.raises(ValueError, match="http status 500"):
|
|
40
|
+
publisher.publish("alpha", OutgoingMessage(topic="alpha", payload=b"hi"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_http_subscriber_decodes_body():
|
|
44
|
+
payload = json.dumps(
|
|
45
|
+
{
|
|
46
|
+
"v": "v1",
|
|
47
|
+
"id": "id",
|
|
48
|
+
"topic": "alpha",
|
|
49
|
+
"ts": "2024-01-01T00:00:00Z",
|
|
50
|
+
"content_type": "text/plain",
|
|
51
|
+
"payload_b64": "aGVsbG8=",
|
|
52
|
+
"meta": {},
|
|
53
|
+
}
|
|
54
|
+
).encode()
|
|
55
|
+
|
|
56
|
+
seen = {}
|
|
57
|
+
|
|
58
|
+
def handler(msg):
|
|
59
|
+
seen["id"] = msg.id
|
|
60
|
+
|
|
61
|
+
subscriber = HttpSubscriber(HttpSubscriberConfig(on_message=handler))
|
|
62
|
+
subscriber.handle(payload)
|
|
63
|
+
|
|
64
|
+
assert seen["id"] == "id"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_http_subscriber_rejects_invalid_json():
|
|
68
|
+
subscriber = HttpSubscriber(HttpSubscriberConfig(on_message=lambda msg: None))
|
|
69
|
+
with pytest.raises(ValueError, match="invalid json"):
|
|
70
|
+
subscriber.handle("{")
|