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("{")