rnhttp 0.0.1__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.
- rnhttp/__init__.py +19 -0
- rnhttp/__whitelist.py +21 -0
- rnhttp/_compat.py +20 -0
- rnhttp/client.py +330 -0
- rnhttp/server.py +312 -0
- rnhttp/types.py +336 -0
- rnhttp-0.0.1.dist-info/METADATA +36 -0
- rnhttp-0.0.1.dist-info/RECORD +11 -0
- rnhttp-0.0.1.dist-info/WHEEL +5 -0
- rnhttp-0.0.1.dist-info/licenses/LICENSE +21 -0
- rnhttp-0.0.1.dist-info/top_level.txt +1 -0
rnhttp/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""rnhttp - HTTP/1.1 over Reticulum Network Stack."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
__version__ = version("rnhttp")
|
|
6
|
+
|
|
7
|
+
from .client import HttpClient
|
|
8
|
+
from .server import HttpServer
|
|
9
|
+
from .types import (
|
|
10
|
+
HttpRequest,
|
|
11
|
+
HttpResponse,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"HttpRequest",
|
|
16
|
+
"HttpResponse",
|
|
17
|
+
"HttpServer",
|
|
18
|
+
"HttpClient",
|
|
19
|
+
]
|
rnhttp/__whitelist.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
_._read_timeout # unused attribute (rnhttp/client.py:44)
|
|
2
|
+
_.post # unused method (rnhttp/client.py:189)
|
|
3
|
+
_.put # unused method (rnhttp/client.py:198)
|
|
4
|
+
_.delete # unused method (rnhttp/client.py:207)
|
|
5
|
+
_.is_connected # unused property (rnhttp/client.py:221)
|
|
6
|
+
_._read_timeout # unused attribute (rnhttp/server.py:52)
|
|
7
|
+
_.route # unused method (rnhttp/server.py:78)
|
|
8
|
+
_.add_handler # unused method (rnhttp/server.py:99)
|
|
9
|
+
HttpSerializerError # unused class (rnhttp/types.py:26)
|
|
10
|
+
_.on_message_begin # unused method (rnhttp/types.py:42)
|
|
11
|
+
_.on_method # unused method (rnhttp/types.py:45)
|
|
12
|
+
_.on_url # unused method (rnhttp/types.py:48)
|
|
13
|
+
_.on_version # unused method (rnhttp/types.py:51)
|
|
14
|
+
_.on_header # unused method (rnhttp/types.py:54)
|
|
15
|
+
_.on_body # unused method (rnhttp/types.py:57)
|
|
16
|
+
_.on_message_begin # unused method (rnhttp/types.py:71)
|
|
17
|
+
_.on_version # unused method (rnhttp/types.py:74)
|
|
18
|
+
_.on_status_code # unused method (rnhttp/types.py:77)
|
|
19
|
+
_.on_reason_phrase # unused method (rnhttp/types.py:80)
|
|
20
|
+
_.on_header # unused method (rnhttp/types.py:83)
|
|
21
|
+
_.on_body # unused method (rnhttp/types.py:86)
|
rnhttp/_compat.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# pyright: reportUnnecessaryTypeIgnoreComment=none
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from typing import (
|
|
4
|
+
Any,
|
|
5
|
+
cast,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
# Added in python 3.12
|
|
10
|
+
from typing import (
|
|
11
|
+
override, # pyright: ignore[reportUnknownVariableType,reportAttributeAccessIssue,reportUnknownType,reportUnnecessaryTypeIgnoreComment]
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
except ImportError:
|
|
15
|
+
from overrides import ( # pyright: ignore[reportMissingImports,reportUnnecessaryTypeIgnoreComment]
|
|
16
|
+
override, # pyright: ignore[reportUnknownVariableType,reportUnnecessaryTypeIgnoreComment]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
override = cast(Callable[[Callable[..., Any]], Callable[..., Any]], override) # pyright: ignore[reportExplicitAny]
|
|
20
|
+
__all__ = ["override"]
|
rnhttp/client.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""HTTP/1.1 client over RNS."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from typing import (
|
|
9
|
+
Any,
|
|
10
|
+
cast,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
import RNS
|
|
14
|
+
|
|
15
|
+
from .types import (
|
|
16
|
+
HttpRequest,
|
|
17
|
+
HttpResponse,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TransportError(Exception):
|
|
22
|
+
"""Exception raised for transport errors."""
|
|
23
|
+
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HttpClient:
|
|
28
|
+
"""HTTP/1.1 client using RNS for transport."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
destination_hash: bytes | str,
|
|
33
|
+
port: int,
|
|
34
|
+
identity_path: str | None = None,
|
|
35
|
+
connect_timeout: float = 30.0,
|
|
36
|
+
request_timeout: float = 60.0,
|
|
37
|
+
read_timeout: float = 30.0,
|
|
38
|
+
) -> None:
|
|
39
|
+
if isinstance(destination_hash, str):
|
|
40
|
+
destination_hash = bytes.fromhex(destination_hash)
|
|
41
|
+
|
|
42
|
+
self._destination_hash: bytes = destination_hash
|
|
43
|
+
self._port: int = port
|
|
44
|
+
self._identity_path: str = identity_path or self._default_identity_path()
|
|
45
|
+
self._connect_timeout: float = connect_timeout
|
|
46
|
+
self._request_timeout: float = request_timeout
|
|
47
|
+
self._read_timeout: float = read_timeout
|
|
48
|
+
self._identity: RNS.Identity | None = None
|
|
49
|
+
self._link: RNS.Link | None = None
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _default_identity_path() -> str:
|
|
53
|
+
"""Get default identity path."""
|
|
54
|
+
home = os.path.expanduser("~")
|
|
55
|
+
return os.path.join(home, ".rnhttp", "identity")
|
|
56
|
+
|
|
57
|
+
def _load_or_create_identity(self) -> RNS.Identity:
|
|
58
|
+
"""Load existing identity or create new one."""
|
|
59
|
+
if self._identity is not None:
|
|
60
|
+
return self._identity
|
|
61
|
+
|
|
62
|
+
if os.path.exists(self._identity_path):
|
|
63
|
+
self._identity = RNS.Identity.from_file(self._identity_path) # pyright: ignore[reportUnknownMemberType]
|
|
64
|
+
if self._identity is not None:
|
|
65
|
+
return self._identity
|
|
66
|
+
|
|
67
|
+
self._identity = RNS.Identity()
|
|
68
|
+
os.makedirs(os.path.dirname(self._identity_path), exist_ok=True)
|
|
69
|
+
_ = self._identity.to_file(self._identity_path) # pyright: ignore[reportUnknownMemberType]
|
|
70
|
+
|
|
71
|
+
return self._identity
|
|
72
|
+
|
|
73
|
+
async def connect(self) -> None:
|
|
74
|
+
"""Connect to the server."""
|
|
75
|
+
_ = self._load_or_create_identity()
|
|
76
|
+
RNS.Transport.request_path(self._destination_hash) # pyright: ignore[reportUnknownMemberType]
|
|
77
|
+
if not RNS.Transport.has_path(self._destination_hash): # pyright: ignore[reportUnknownMemberType]
|
|
78
|
+
if not RNS.Transport.await_path( # pyright: ignore[reportUnknownMemberType]
|
|
79
|
+
self._destination_hash, self._connect_timeout
|
|
80
|
+
):
|
|
81
|
+
raise TransportError("Timeout waiting for path to server")
|
|
82
|
+
|
|
83
|
+
server_identity = RNS.Identity.recall(self._destination_hash) # pyright: ignore[reportUnknownMemberType]
|
|
84
|
+
if server_identity is None:
|
|
85
|
+
raise TransportError("Could not recall server identity")
|
|
86
|
+
|
|
87
|
+
dest = RNS.Destination(
|
|
88
|
+
server_identity,
|
|
89
|
+
RNS.Destination.OUT,
|
|
90
|
+
RNS.Destination.SINGLE,
|
|
91
|
+
"HTTP",
|
|
92
|
+
str(self._port),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
connected = threading.Event()
|
|
96
|
+
|
|
97
|
+
def on_established(_link: RNS.Link) -> None:
|
|
98
|
+
nonlocal connected
|
|
99
|
+
connected.set()
|
|
100
|
+
|
|
101
|
+
self._link = RNS.Link(dest, on_established)
|
|
102
|
+
if not connected.wait(self._connect_timeout):
|
|
103
|
+
if self._link is not None: # pyright: ignore[reportUnnecessaryComparison]
|
|
104
|
+
self._link.teardown()
|
|
105
|
+
self._link = None
|
|
106
|
+
|
|
107
|
+
raise TransportError("Connection timeout")
|
|
108
|
+
|
|
109
|
+
async def request(
|
|
110
|
+
self,
|
|
111
|
+
path: str,
|
|
112
|
+
method: str = "GET",
|
|
113
|
+
headers: dict[str, str] | None = None,
|
|
114
|
+
body: bytes | None = None,
|
|
115
|
+
) -> HttpResponse:
|
|
116
|
+
"""Send an HTTP request.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
path: Request path
|
|
120
|
+
method: HTTP method (GET, POST, etc.)
|
|
121
|
+
headers: Additional headers
|
|
122
|
+
body: Request body
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
HttpResponse object
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
TransportError: If request fails
|
|
129
|
+
"""
|
|
130
|
+
if self._link is None:
|
|
131
|
+
await self.connect()
|
|
132
|
+
|
|
133
|
+
if self._link is None:
|
|
134
|
+
raise TransportError("Not connected")
|
|
135
|
+
|
|
136
|
+
request = HttpRequest(
|
|
137
|
+
method=method,
|
|
138
|
+
path=path,
|
|
139
|
+
headers=headers or {},
|
|
140
|
+
body=body,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
response_data = await self._send_request(bytes(request))
|
|
144
|
+
return HttpResponse.parse(response_data)
|
|
145
|
+
|
|
146
|
+
async def _send_request(self, data: bytes) -> bytes:
|
|
147
|
+
"""Send request data and wait for response."""
|
|
148
|
+
if self._link is None:
|
|
149
|
+
raise TransportError("Not connected")
|
|
150
|
+
|
|
151
|
+
channel = self._link.get_channel()
|
|
152
|
+
|
|
153
|
+
response_event = threading.Event()
|
|
154
|
+
response_data: bytes | None = None
|
|
155
|
+
response_error: Exception | None = None
|
|
156
|
+
|
|
157
|
+
def on_reader_ready(ready: int) -> None:
|
|
158
|
+
nonlocal response_data, response_error, response_event, buffer
|
|
159
|
+
try:
|
|
160
|
+
response_data = buffer.read(ready)
|
|
161
|
+
response_event.set()
|
|
162
|
+
|
|
163
|
+
except Exception as e:
|
|
164
|
+
response_error = e
|
|
165
|
+
response_event.set()
|
|
166
|
+
|
|
167
|
+
buffer = RNS.Buffer.create_bidirectional_buffer(1, 0, channel, on_reader_ready)
|
|
168
|
+
_ = buffer.write(data)
|
|
169
|
+
buffer.flush()
|
|
170
|
+
|
|
171
|
+
if not response_event.wait(self._request_timeout):
|
|
172
|
+
raise TransportError("Request timeout")
|
|
173
|
+
|
|
174
|
+
if response_error is not None:
|
|
175
|
+
raise TransportError( # pyright: ignore[reportUnreachable]
|
|
176
|
+
f"Request failed: {response_error}"
|
|
177
|
+
) from response_error
|
|
178
|
+
|
|
179
|
+
if response_data is None:
|
|
180
|
+
raise TransportError("No response received")
|
|
181
|
+
|
|
182
|
+
return response_data # pyright: ignore[reportUnreachable]
|
|
183
|
+
|
|
184
|
+
async def get(
|
|
185
|
+
self,
|
|
186
|
+
path: str,
|
|
187
|
+
headers: dict[str, str] | None = None,
|
|
188
|
+
) -> HttpResponse:
|
|
189
|
+
"""Send GET request."""
|
|
190
|
+
return await self.request(path, "GET", headers)
|
|
191
|
+
|
|
192
|
+
async def post(
|
|
193
|
+
self,
|
|
194
|
+
path: str,
|
|
195
|
+
body: bytes | None = None,
|
|
196
|
+
headers: dict[str, str] | None = None,
|
|
197
|
+
) -> HttpResponse:
|
|
198
|
+
"""Send POST request."""
|
|
199
|
+
return await self.request(path, "POST", headers, body)
|
|
200
|
+
|
|
201
|
+
async def put(
|
|
202
|
+
self,
|
|
203
|
+
path: str,
|
|
204
|
+
body: bytes | None = None,
|
|
205
|
+
headers: dict[str, str] | None = None,
|
|
206
|
+
) -> HttpResponse:
|
|
207
|
+
"""Send PUT request."""
|
|
208
|
+
return await self.request(path, "PUT", headers, body)
|
|
209
|
+
|
|
210
|
+
async def delete(
|
|
211
|
+
self,
|
|
212
|
+
path: str,
|
|
213
|
+
headers: dict[str, str] | None = None,
|
|
214
|
+
) -> HttpResponse:
|
|
215
|
+
"""Send DELETE request."""
|
|
216
|
+
return await self.request(path, "DELETE", headers)
|
|
217
|
+
|
|
218
|
+
async def close(self) -> None:
|
|
219
|
+
"""Close the connection."""
|
|
220
|
+
if self._link is not None:
|
|
221
|
+
self._link.teardown()
|
|
222
|
+
self._link = None
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def is_connected(self) -> bool:
|
|
226
|
+
"""Check if client is connected."""
|
|
227
|
+
return self._link is not None
|
|
228
|
+
|
|
229
|
+
async def __aenter__(self) -> "HttpClient":
|
|
230
|
+
"""Async context manager entry."""
|
|
231
|
+
await self.connect()
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
async def __aexit__(self, _exc_type: Any, _exc_val: Any, _exc_tb: Any) -> None: # pyright: ignore[reportAny, reportExplicitAny] # noqa: ANN401
|
|
235
|
+
"""Async context manager exit."""
|
|
236
|
+
await self.close()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def main():
|
|
240
|
+
parser = argparse.ArgumentParser(description="HTTP/1.1 client over Reticulum")
|
|
241
|
+
_ = parser.add_argument("destination", type=str, help="Server destination hash")
|
|
242
|
+
_ = parser.add_argument("port", type=int, help="Server port")
|
|
243
|
+
_ = parser.add_argument("method", type=str, default="GET", help="HTTP method")
|
|
244
|
+
_ = parser.add_argument("path", type=str, default="/", help="Request path")
|
|
245
|
+
_ = parser.add_argument("--config", type=str, help="RNS config directory")
|
|
246
|
+
_ = parser.add_argument("--identity", type=str, help="Identity file path")
|
|
247
|
+
_ = parser.add_argument(
|
|
248
|
+
"-v",
|
|
249
|
+
"--verbose",
|
|
250
|
+
action="store_true",
|
|
251
|
+
help="Enable verbose logging",
|
|
252
|
+
dest="verbose",
|
|
253
|
+
)
|
|
254
|
+
_ = parser.add_argument(
|
|
255
|
+
"-H", "--header", action="append", help="Add header (Format: Name: Value)"
|
|
256
|
+
)
|
|
257
|
+
_ = parser.add_argument("--body", type=str, help="Request body")
|
|
258
|
+
_ = parser.add_argument(
|
|
259
|
+
"-r",
|
|
260
|
+
"--response-code",
|
|
261
|
+
action="store_true",
|
|
262
|
+
help="Print the response code and exit",
|
|
263
|
+
dest="response_code",
|
|
264
|
+
)
|
|
265
|
+
args = parser.parse_args()
|
|
266
|
+
|
|
267
|
+
assert isinstance(args.config, str | None) # pyright: ignore[reportAny]
|
|
268
|
+
config_path = args.config
|
|
269
|
+
if config_path is None:
|
|
270
|
+
config_path = os.environ.get("RNS_CONFIG_PATH", None)
|
|
271
|
+
|
|
272
|
+
assert isinstance(args.verbose, bool) # pyright: ignore[reportAny]
|
|
273
|
+
_ = RNS.Reticulum(config_path, RNS.LOG_VERBOSE if args.verbose else RNS.LOG_WARNING)
|
|
274
|
+
|
|
275
|
+
headers: dict[str, str] = {}
|
|
276
|
+
assert isinstance(args.header, list | None) # pyright: ignore[reportAny]
|
|
277
|
+
if args.header is not None: # pyright: ignore[reportUnknownMemberType]
|
|
278
|
+
for header in cast(list[str], args.header):
|
|
279
|
+
if "=" in header:
|
|
280
|
+
name, value = header.split("=", 1)
|
|
281
|
+
headers[name] = value
|
|
282
|
+
|
|
283
|
+
assert isinstance(args.body, str | None) # pyright: ignore[reportAny]
|
|
284
|
+
body = args.body.encode("utf-8") if args.body else None
|
|
285
|
+
|
|
286
|
+
assert isinstance(args.destination, str) # pyright: ignore[reportAny]
|
|
287
|
+
assert isinstance(args.port, int) # pyright: ignore[reportAny]
|
|
288
|
+
assert isinstance(args.identity, str | None) # pyright: ignore[reportAny]
|
|
289
|
+
client = HttpClient(
|
|
290
|
+
destination_hash=args.destination,
|
|
291
|
+
port=args.port,
|
|
292
|
+
identity_path=args.identity,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
assert isinstance(args.method, str) # pyright: ignore[reportAny]
|
|
296
|
+
assert isinstance(args.path, str) # pyright: ignore[reportAny]
|
|
297
|
+
try:
|
|
298
|
+
async with client:
|
|
299
|
+
response = await client.request(
|
|
300
|
+
path=args.path,
|
|
301
|
+
method=args.method.upper(),
|
|
302
|
+
headers=headers,
|
|
303
|
+
body=body,
|
|
304
|
+
)
|
|
305
|
+
assert isinstance(args.response_code, bool) # pyright: ignore[reportAny]
|
|
306
|
+
if args.response_code:
|
|
307
|
+
print(response.status)
|
|
308
|
+
|
|
309
|
+
else:
|
|
310
|
+
_ = sys.stdout.write(
|
|
311
|
+
f"{response.version} {response.status} {response.reason}\n"
|
|
312
|
+
)
|
|
313
|
+
for name, value in response.headers.items():
|
|
314
|
+
_ = sys.stdout.write(f"{name}: {value}\n")
|
|
315
|
+
|
|
316
|
+
_ = sys.stdout.write("\n")
|
|
317
|
+
if response.body:
|
|
318
|
+
_ = sys.stdout.buffer.write(response.body)
|
|
319
|
+
|
|
320
|
+
_ = sys.stdout.buffer.flush()
|
|
321
|
+
|
|
322
|
+
sys.exit(0 if response.status < 400 else 1)
|
|
323
|
+
|
|
324
|
+
except TransportError as e:
|
|
325
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
326
|
+
sys.exit(1)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
if __name__ == "__main__":
|
|
330
|
+
asyncio.run(main())
|
rnhttp/server.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""HTTP/1.1 server over RNS."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import (
|
|
9
|
+
Awaitable,
|
|
10
|
+
Callable,
|
|
11
|
+
)
|
|
12
|
+
from typing import TypeVar
|
|
13
|
+
|
|
14
|
+
import RNS
|
|
15
|
+
|
|
16
|
+
from .types import (
|
|
17
|
+
HttpRequest,
|
|
18
|
+
HttpResponse,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
HandlerType = (
|
|
22
|
+
Callable[[HttpRequest], HttpResponse]
|
|
23
|
+
| Callable[[HttpRequest], Awaitable[HttpResponse]]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def await_in_sync(awaitable: Awaitable[T]) -> T:
|
|
30
|
+
"""Run any awaitable from synchronous code safely."""
|
|
31
|
+
try:
|
|
32
|
+
loop = asyncio.get_running_loop()
|
|
33
|
+
return loop.run_until_complete(awaitable)
|
|
34
|
+
|
|
35
|
+
except RuntimeError:
|
|
36
|
+
return asyncio.run(awaitable) # pyright: ignore[reportUnknownVariableType, reportArgumentType]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HttpServer:
|
|
40
|
+
"""HTTP/1.1 server using RNS for transport."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
port: int,
|
|
45
|
+
identity_path: str | None = None,
|
|
46
|
+
request_timeout: float = 60.0,
|
|
47
|
+
read_timeout: float = 30.0,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._port: int = port
|
|
50
|
+
self._identity_path: str = identity_path or self._default_identity_path()
|
|
51
|
+
self._request_timeout: float = request_timeout
|
|
52
|
+
self._read_timeout: float = read_timeout
|
|
53
|
+
self._destination: RNS.Destination | None = None
|
|
54
|
+
self._handlers: dict[tuple[str, str], HandlerType] = {}
|
|
55
|
+
self._default_handler: HandlerType | None = None
|
|
56
|
+
self._running: bool = False
|
|
57
|
+
self._links: dict[int, tuple[RNS.Link, io.BufferedRWPair]] = {}
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _default_identity_path() -> str:
|
|
61
|
+
"""Get default identity path."""
|
|
62
|
+
home = os.path.expanduser("~")
|
|
63
|
+
return os.path.join(home, ".rnhttp", "identity")
|
|
64
|
+
|
|
65
|
+
def _load_or_create_identity(self) -> RNS.Identity:
|
|
66
|
+
"""Load existing identity or create new one."""
|
|
67
|
+
if os.path.exists(self._identity_path):
|
|
68
|
+
identity = RNS.Identity.from_file(self._identity_path) # pyright: ignore[reportUnknownMemberType]
|
|
69
|
+
if identity is not None:
|
|
70
|
+
return identity
|
|
71
|
+
|
|
72
|
+
identity = RNS.Identity()
|
|
73
|
+
os.makedirs(os.path.dirname(self._identity_path), exist_ok=True)
|
|
74
|
+
_ = identity.to_file(self._identity_path) # pyright: ignore[reportUnknownMemberType]
|
|
75
|
+
|
|
76
|
+
return identity
|
|
77
|
+
|
|
78
|
+
def route(
|
|
79
|
+
self, path: str, method: str = "GET"
|
|
80
|
+
) -> Callable[[HandlerType], HandlerType]:
|
|
81
|
+
"""Decorator to register a handler for a path and method.
|
|
82
|
+
|
|
83
|
+
Usage:
|
|
84
|
+
@server.route("/api/data")
|
|
85
|
+
def handle_request(request: HttpRequest) -> HttpResponse:
|
|
86
|
+
return HttpResponse(status=200, body=b"OK")
|
|
87
|
+
|
|
88
|
+
@server.route("/api/data", method="POST")
|
|
89
|
+
def handle_post(request: HttpRequest) -> HttpResponse:
|
|
90
|
+
return HttpResponse(status=200, body=b"OK")
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def decorator(handler: HandlerType) -> HandlerType:
|
|
94
|
+
self._handlers[(method.upper(), path)] = handler
|
|
95
|
+
return handler
|
|
96
|
+
|
|
97
|
+
return decorator
|
|
98
|
+
|
|
99
|
+
def add_handler(self, path: str, handler: HandlerType, method: str = "GET") -> None:
|
|
100
|
+
"""Add a handler for a path and method."""
|
|
101
|
+
self._handlers[(method.upper(), path)] = handler
|
|
102
|
+
|
|
103
|
+
def set_default_handler(self, handler: HandlerType) -> None:
|
|
104
|
+
"""Set a default handler for all requests."""
|
|
105
|
+
self._default_handler = handler
|
|
106
|
+
|
|
107
|
+
async def start(self) -> None:
|
|
108
|
+
"""Start the HTTP server."""
|
|
109
|
+
identity = self._load_or_create_identity()
|
|
110
|
+
|
|
111
|
+
self._destination = RNS.Destination(
|
|
112
|
+
identity,
|
|
113
|
+
RNS.Destination.IN,
|
|
114
|
+
RNS.Destination.SINGLE,
|
|
115
|
+
"HTTP",
|
|
116
|
+
str(self._port),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
self._running = True
|
|
120
|
+
_ = self._destination.accepts_links(True) # pyright: ignore[reportUnknownMemberType]
|
|
121
|
+
self._destination.set_link_established_callback(self._on_link) # pyright: ignore[reportUnknownMemberType]
|
|
122
|
+
_ = self._destination.announce() # pyright: ignore[reportUnknownMemberType]
|
|
123
|
+
|
|
124
|
+
async def stop(self) -> None:
|
|
125
|
+
"""Stop the HTTP server."""
|
|
126
|
+
self._running = False
|
|
127
|
+
for link, _ in list(self._links.values()):
|
|
128
|
+
link.teardown()
|
|
129
|
+
|
|
130
|
+
self._links.clear()
|
|
131
|
+
if self._destination is not None:
|
|
132
|
+
_ = self._destination.accepts_links(False) # pyright: ignore[reportUnknownMemberType]
|
|
133
|
+
|
|
134
|
+
def _on_link(self, link: RNS.Link) -> None:
|
|
135
|
+
"""Handle incoming link."""
|
|
136
|
+
if not self._running:
|
|
137
|
+
link.teardown()
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
print(f"Connected: {link}")
|
|
141
|
+
|
|
142
|
+
def _on_reader_ready(ready: int) -> None:
|
|
143
|
+
"""Handle incoming data on reader."""
|
|
144
|
+
nonlocal link
|
|
145
|
+
print(f"Reader ready {link}: {ready}")
|
|
146
|
+
_, buffer = self._links.get(id(link), (None, None))
|
|
147
|
+
if buffer is None:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
request: HttpRequest | Awaitable[HttpRequest] | None = None
|
|
151
|
+
response: HttpResponse | Awaitable[HttpResponse] | None = None
|
|
152
|
+
try:
|
|
153
|
+
request = HttpRequest.parse(buffer.read(ready))
|
|
154
|
+
|
|
155
|
+
except Exception as e:
|
|
156
|
+
response = HttpResponse(
|
|
157
|
+
status=400,
|
|
158
|
+
body=str(e).encode("utf-8"),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if response is None:
|
|
162
|
+
assert request is not None
|
|
163
|
+
try:
|
|
164
|
+
response = self._handle_request(link, request)
|
|
165
|
+
|
|
166
|
+
except Exception as e:
|
|
167
|
+
response = HttpResponse(
|
|
168
|
+
status=500,
|
|
169
|
+
body=str(e).encode("utf-8"),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
await_in_sync(self._send_response(buffer, response))
|
|
173
|
+
|
|
174
|
+
link.set_link_closed_callback(self._on_close) # pyright: ignore[reportUnknownMemberType]
|
|
175
|
+
channel = link.get_channel()
|
|
176
|
+
buffer = RNS.Buffer.create_bidirectional_buffer(0, 1, channel, _on_reader_ready)
|
|
177
|
+
self._links[id(link)] = (link, buffer)
|
|
178
|
+
|
|
179
|
+
async def _send_response(
|
|
180
|
+
self,
|
|
181
|
+
writer: io.BufferedRWPair,
|
|
182
|
+
response: HttpResponse | Awaitable[HttpResponse],
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Send response back on the link."""
|
|
185
|
+
if isinstance(response, Awaitable):
|
|
186
|
+
response = await response
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
_ = writer.write(bytes(response))
|
|
190
|
+
writer.flush()
|
|
191
|
+
|
|
192
|
+
except Exception: # TODO narrow this to actual exception
|
|
193
|
+
link_id = None
|
|
194
|
+
for lid, (_, buffer) in self._links.items():
|
|
195
|
+
if buffer is writer:
|
|
196
|
+
link_id = lid
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
if link_id is not None:
|
|
200
|
+
link, _ = self._links.pop(link_id, (None, None))
|
|
201
|
+
if link is not None:
|
|
202
|
+
link.teardown()
|
|
203
|
+
|
|
204
|
+
def _on_close(self, link: RNS.Link) -> None:
|
|
205
|
+
"""Handle link close."""
|
|
206
|
+
_ = self._links.pop(id(link), None)
|
|
207
|
+
|
|
208
|
+
def _handle_request(
|
|
209
|
+
self, link: RNS.Link, request: HttpRequest
|
|
210
|
+
) -> HttpResponse | Awaitable[HttpResponse]:
|
|
211
|
+
"""Handle incoming HTTP request."""
|
|
212
|
+
path = request.path
|
|
213
|
+
method = request.method.upper()
|
|
214
|
+
print(f"{link} {method} {path}")
|
|
215
|
+
|
|
216
|
+
key = (method, path)
|
|
217
|
+
if key in self._handlers:
|
|
218
|
+
return self._handlers[key](request)
|
|
219
|
+
|
|
220
|
+
for (_, p), handler in self._handlers.items():
|
|
221
|
+
if self._match_pattern(p, path):
|
|
222
|
+
return handler(request)
|
|
223
|
+
|
|
224
|
+
if self._default_handler:
|
|
225
|
+
return self._default_handler(request)
|
|
226
|
+
|
|
227
|
+
return HttpResponse(
|
|
228
|
+
status=404,
|
|
229
|
+
reason="Not Found",
|
|
230
|
+
body=b"Not Found",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _match_pattern(self, pattern: str, path: str) -> bool:
|
|
234
|
+
"""Simple pattern matching for routes."""
|
|
235
|
+
if pattern.endswith("*"):
|
|
236
|
+
return path.startswith(pattern[:-1])
|
|
237
|
+
return path == pattern
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def port(self) -> int:
|
|
241
|
+
"""Get the server port."""
|
|
242
|
+
return self._port
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def destination_hash(self) -> str | None:
|
|
246
|
+
"""Get the server destination hash as hex string."""
|
|
247
|
+
if self._destination is None:
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
assert isinstance(self._destination.hexhash, str | None) # pyright: ignore[reportAny]
|
|
251
|
+
return self._destination.hexhash
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def is_running(self) -> bool:
|
|
255
|
+
"""Check if server is running."""
|
|
256
|
+
return self._running
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def main():
|
|
260
|
+
parser = argparse.ArgumentParser(description="HTTP/1.1 server over Reticulum")
|
|
261
|
+
_ = parser.add_argument("port", type=int, help="Port number")
|
|
262
|
+
_ = parser.add_argument("--config", type=str, help="RNS config directory")
|
|
263
|
+
_ = parser.add_argument("--identity", type=str, help="Identity file path")
|
|
264
|
+
_ = parser.add_argument(
|
|
265
|
+
"-v",
|
|
266
|
+
"--verbose",
|
|
267
|
+
action="store_true",
|
|
268
|
+
help="Enable verbose logging",
|
|
269
|
+
dest="verbose",
|
|
270
|
+
)
|
|
271
|
+
args = parser.parse_args()
|
|
272
|
+
|
|
273
|
+
assert isinstance(args.config, str | None) # pyright: ignore[reportAny]
|
|
274
|
+
config_path = args.config
|
|
275
|
+
if config_path is None:
|
|
276
|
+
config_path = os.environ.get("RNS_CONFIG_PATH", None)
|
|
277
|
+
|
|
278
|
+
assert isinstance(args.verbose, bool) # pyright: ignore[reportAny]
|
|
279
|
+
_ = RNS.Reticulum(config_path, RNS.LOG_VERBOSE if args.verbose else RNS.LOG_WARNING)
|
|
280
|
+
|
|
281
|
+
assert isinstance(args.identity, str | None) # pyright: ignore[reportAny]
|
|
282
|
+
assert isinstance(args.port, int) # pyright: ignore[reportAny]
|
|
283
|
+
server = HttpServer(
|
|
284
|
+
port=args.port,
|
|
285
|
+
identity_path=args.identity,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def default_handler(_request: HttpRequest) -> HttpResponse:
|
|
289
|
+
return HttpResponse(
|
|
290
|
+
status=200,
|
|
291
|
+
body=b"Hello world!",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
server.set_default_handler(default_handler)
|
|
295
|
+
|
|
296
|
+
await server.start()
|
|
297
|
+
print(f"Server listening on HTTP.{server.port}", file=sys.stderr, flush=True)
|
|
298
|
+
print(f"Destination hash: {server.destination_hash}", file=sys.stderr, flush=True)
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
while server.is_running:
|
|
302
|
+
await asyncio.sleep(1)
|
|
303
|
+
|
|
304
|
+
except KeyboardInterrupt:
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
finally:
|
|
308
|
+
await server.stop()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__":
|
|
312
|
+
asyncio.run(main())
|
rnhttp/types.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""HTTP/1.1 protocol parser and serializer.
|
|
2
|
+
|
|
3
|
+
Based on RFC 9112 - HTTP/1.1 Message Syntax and Routing (June 2022)
|
|
4
|
+
Uses httptools for parsing."""
|
|
5
|
+
|
|
6
|
+
from typing import final
|
|
7
|
+
|
|
8
|
+
from httptools import (
|
|
9
|
+
HttpParserError as HttptoolsParserError,
|
|
10
|
+
)
|
|
11
|
+
from httptools import (
|
|
12
|
+
HttpRequestParser,
|
|
13
|
+
HttpResponseParser,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from ._compat import override
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HttpParserError(Exception):
|
|
20
|
+
"""Exception raised for HTTP parsing errors."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HttpSerializerError(Exception):
|
|
26
|
+
"""Exception raised for HTTP serialization errors."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RequestCallbacks:
|
|
32
|
+
"""Callback handler for request parsing."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self.method: bytes = b""
|
|
36
|
+
self.url: bytes = b""
|
|
37
|
+
self.version: bytes = b"HTTP/1.1"
|
|
38
|
+
self.headers: dict[bytes, bytes] = {}
|
|
39
|
+
self.body_chunks: list[bytes] = []
|
|
40
|
+
|
|
41
|
+
def on_message_begin(self) -> None:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def on_method(self, method: bytes) -> None:
|
|
45
|
+
self.method = method
|
|
46
|
+
|
|
47
|
+
def on_url(self, url: bytes) -> None:
|
|
48
|
+
self.url = url
|
|
49
|
+
|
|
50
|
+
def on_version(self, version: bytes) -> None:
|
|
51
|
+
self.version = version
|
|
52
|
+
|
|
53
|
+
def on_header(self, name: bytes, value: bytes) -> None:
|
|
54
|
+
self.headers[name] = value
|
|
55
|
+
|
|
56
|
+
def on_body(self, body: bytes) -> None:
|
|
57
|
+
self.body_chunks.append(body)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ResponseCallbacks:
|
|
61
|
+
"""Callback handler for response parsing."""
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self.version: bytes = b"HTTP/1.1"
|
|
65
|
+
self.status: int = 0
|
|
66
|
+
self.reason: bytes = b""
|
|
67
|
+
self.headers: dict[bytes, bytes] = {}
|
|
68
|
+
self.body_chunks: list[bytes] = []
|
|
69
|
+
|
|
70
|
+
def on_message_begin(self) -> None:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def on_version(self, version: bytes) -> None:
|
|
74
|
+
self.version = version
|
|
75
|
+
|
|
76
|
+
def on_status_code(self, status_code: bytes) -> None:
|
|
77
|
+
self.status = int(status_code)
|
|
78
|
+
|
|
79
|
+
def on_reason_phrase(self, reason: bytes) -> None:
|
|
80
|
+
self.reason = reason
|
|
81
|
+
|
|
82
|
+
def on_header(self, name: bytes, value: bytes) -> None:
|
|
83
|
+
self.headers[name] = value
|
|
84
|
+
|
|
85
|
+
def on_body(self, body: bytes) -> None:
|
|
86
|
+
self.body_chunks.append(body)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def encode_chunked(body: bytes) -> bytes:
|
|
90
|
+
"""Encode body using chunked transfer encoding.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
body: Body bytes to encode
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Chunked encoded body bytes
|
|
97
|
+
"""
|
|
98
|
+
offset = 0
|
|
99
|
+
data: bytes = b""
|
|
100
|
+
while offset < len(body):
|
|
101
|
+
chunk = body[offset : offset + 4096]
|
|
102
|
+
data += f"{len(chunk):x}".encode() + b"\r\n" + chunk + b"\r\n"
|
|
103
|
+
offset += len(chunk)
|
|
104
|
+
|
|
105
|
+
return data + b"0" + b"\r\n\r\n"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@final
|
|
109
|
+
class HttpRequest:
|
|
110
|
+
"""Represents an HTTP/1.1 request."""
|
|
111
|
+
|
|
112
|
+
__slots__ = ("method", "path", "version", "headers", "body")
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
method: str,
|
|
117
|
+
path: str,
|
|
118
|
+
version: str = "HTTP/1.1",
|
|
119
|
+
headers: dict[str, str] | None = None,
|
|
120
|
+
body: bytes | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
self.method = method
|
|
123
|
+
self.path = path
|
|
124
|
+
self.version = version
|
|
125
|
+
self.headers = headers if headers is not None else {}
|
|
126
|
+
self.body = body
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def parse(data: bytes) -> "HttpRequest":
|
|
130
|
+
if not data:
|
|
131
|
+
raise HttpParserError("Incomplete request")
|
|
132
|
+
|
|
133
|
+
callbacks = RequestCallbacks()
|
|
134
|
+
parser = HttpRequestParser(callbacks)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
parser.feed_data(data) # pyright: ignore[reportUnknownMemberType]
|
|
138
|
+
|
|
139
|
+
except HttptoolsParserError as e:
|
|
140
|
+
raise HttpParserError(str(e)) from e
|
|
141
|
+
|
|
142
|
+
method = parser.get_method()
|
|
143
|
+
if not method:
|
|
144
|
+
raise HttpParserError("Incomplete request")
|
|
145
|
+
|
|
146
|
+
version = parser.get_http_version()
|
|
147
|
+
|
|
148
|
+
return HttpRequest(
|
|
149
|
+
method=method.decode("utf-8"),
|
|
150
|
+
path=callbacks.url.decode("utf-8") if callbacks.url else "/",
|
|
151
|
+
version=f"HTTP/{version}" if version else "HTTP/1.1",
|
|
152
|
+
headers={
|
|
153
|
+
k.decode("utf-8").lower(): v.decode("utf-8")
|
|
154
|
+
for k, v in callbacks.headers.items()
|
|
155
|
+
},
|
|
156
|
+
body=b"".join(callbacks.body_chunks) if callbacks.body_chunks else None,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@override
|
|
160
|
+
def __repr__(self) -> str:
|
|
161
|
+
body_len = len(self.body) if self.body else 0
|
|
162
|
+
return (
|
|
163
|
+
f"HttpRequest(method={self.method!r}, path={self.path!r}, "
|
|
164
|
+
f"version={self.version!r}, body_len={body_len})"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@override
|
|
168
|
+
def __eq__(self, other: object) -> bool:
|
|
169
|
+
if not isinstance(other, HttpRequest):
|
|
170
|
+
return NotImplemented
|
|
171
|
+
return (
|
|
172
|
+
self.method == other.method
|
|
173
|
+
and self.path == other.path
|
|
174
|
+
and self.version == other.version
|
|
175
|
+
and self.headers == other.headers
|
|
176
|
+
and self.body == other.body
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def __bytes__(self) -> bytes:
|
|
180
|
+
status_line = (f"{self.method} {self.path} {self.version}").encode() + b"\r\n"
|
|
181
|
+
|
|
182
|
+
headers = dict(self.headers)
|
|
183
|
+
body = self.body
|
|
184
|
+
if body is not None:
|
|
185
|
+
if headers.get("Transfer-Encoding", "").lower() == "chunked":
|
|
186
|
+
body = encode_chunked(body)
|
|
187
|
+
headers["Transfer-Encoding"] = "chunked"
|
|
188
|
+
_ = headers.pop("Content-Length", None)
|
|
189
|
+
|
|
190
|
+
elif "Content-Length" not in headers:
|
|
191
|
+
headers["Content-Length"] = str(len(body))
|
|
192
|
+
|
|
193
|
+
header_bytes = (
|
|
194
|
+
b"\r\n".join([f"{k}: {v}".encode() for k, v in headers.items()])
|
|
195
|
+
+ b"\r\n"
|
|
196
|
+
+ b"\r\n"
|
|
197
|
+
)
|
|
198
|
+
if body is None:
|
|
199
|
+
return status_line + header_bytes
|
|
200
|
+
|
|
201
|
+
return status_line + header_bytes + body
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def reason_text(status: int) -> str:
|
|
205
|
+
"""Return default reason phrase for status code."""
|
|
206
|
+
reasons = {
|
|
207
|
+
100: "Continue",
|
|
208
|
+
101: "Switching Protocols",
|
|
209
|
+
200: "OK",
|
|
210
|
+
201: "Created",
|
|
211
|
+
202: "Accepted",
|
|
212
|
+
204: "No Content",
|
|
213
|
+
301: "Moved Permanently",
|
|
214
|
+
302: "Found",
|
|
215
|
+
304: "Not Modified",
|
|
216
|
+
400: "Bad Request",
|
|
217
|
+
401: "Unauthorized",
|
|
218
|
+
403: "Forbidden",
|
|
219
|
+
404: "Not Found",
|
|
220
|
+
405: "Method Not Allowed",
|
|
221
|
+
408: "Request Timeout",
|
|
222
|
+
409: "Conflict",
|
|
223
|
+
413: "Payload Too Large",
|
|
224
|
+
414: "URI Too Long",
|
|
225
|
+
500: "Internal Server Error",
|
|
226
|
+
501: "Not Implemented",
|
|
227
|
+
502: "Bad Gateway",
|
|
228
|
+
503: "Service Unavailable",
|
|
229
|
+
504: "Gateway Timeout",
|
|
230
|
+
}
|
|
231
|
+
return reasons.get(status, "Unknown")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@final
|
|
235
|
+
class HttpResponse:
|
|
236
|
+
"""Represents an HTTP/1.1 response."""
|
|
237
|
+
|
|
238
|
+
__slots__ = ("version", "status", "reason", "headers", "body")
|
|
239
|
+
|
|
240
|
+
def __init__(
|
|
241
|
+
self,
|
|
242
|
+
status: int,
|
|
243
|
+
reason: str | None = None,
|
|
244
|
+
version: str = "HTTP/1.1",
|
|
245
|
+
headers: dict[str, str] | None = None,
|
|
246
|
+
body: bytes | None = None,
|
|
247
|
+
) -> None:
|
|
248
|
+
self.version = version
|
|
249
|
+
self.status = status
|
|
250
|
+
self.reason = reason if reason is not None else reason_text(status)
|
|
251
|
+
self.headers = headers if headers is not None else {}
|
|
252
|
+
self.body = body
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def parse(data: bytes) -> "HttpResponse":
|
|
256
|
+
"""Parse an HTTP/1.1 response from raw bytes.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
data: Raw HTTP response bytes
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
HttpResponse object
|
|
263
|
+
|
|
264
|
+
Raises:
|
|
265
|
+
HttpParserError: If response is malformed
|
|
266
|
+
"""
|
|
267
|
+
if not data:
|
|
268
|
+
raise HttpParserError("Incomplete response")
|
|
269
|
+
|
|
270
|
+
callbacks = ResponseCallbacks()
|
|
271
|
+
parser = HttpResponseParser(callbacks)
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
parser.feed_data(data) # pyright: ignore[reportUnknownMemberType]
|
|
275
|
+
|
|
276
|
+
except HttptoolsParserError as e:
|
|
277
|
+
raise HttpParserError(str(e)) from e
|
|
278
|
+
|
|
279
|
+
status = parser.get_status_code()
|
|
280
|
+
if not status:
|
|
281
|
+
raise HttpParserError("Incomplete response")
|
|
282
|
+
|
|
283
|
+
version = parser.get_http_version()
|
|
284
|
+
return HttpResponse(
|
|
285
|
+
version=f"HTTP/{version}" if version else "HTTP/1.1",
|
|
286
|
+
status=status,
|
|
287
|
+
reason=callbacks.reason.decode("utf-8") if callbacks.reason else None,
|
|
288
|
+
headers={
|
|
289
|
+
k.decode("utf-8").lower(): v.decode("utf-8")
|
|
290
|
+
for k, v in callbacks.headers.items()
|
|
291
|
+
},
|
|
292
|
+
body=b"".join(callbacks.body_chunks) if callbacks.body_chunks else None,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
@override
|
|
296
|
+
def __repr__(self) -> str:
|
|
297
|
+
body_len = len(self.body) if self.body else 0
|
|
298
|
+
return (
|
|
299
|
+
f"HttpResponse(status={self.status}, reason={self.reason!r}, "
|
|
300
|
+
f"version={self.version!r}, body_len={body_len})"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
@override
|
|
304
|
+
def __eq__(self, other: object) -> bool:
|
|
305
|
+
if not isinstance(other, HttpResponse):
|
|
306
|
+
return NotImplemented
|
|
307
|
+
return (
|
|
308
|
+
self.version == other.version
|
|
309
|
+
and self.status == other.status
|
|
310
|
+
and self.reason == other.reason
|
|
311
|
+
and self.headers == other.headers
|
|
312
|
+
and self.body == other.body
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def __bytes__(self) -> bytes:
|
|
316
|
+
status_line = (f"{self.version} {self.status} {self.reason}").encode() + b"\r\n"
|
|
317
|
+
headers = dict(self.headers)
|
|
318
|
+
body = self.body
|
|
319
|
+
if body is not None:
|
|
320
|
+
if headers.get("Transfer-Encoding", "").lower() == "chunked":
|
|
321
|
+
body = encode_chunked(body)
|
|
322
|
+
headers["Transfer-Encoding"] = "chunked"
|
|
323
|
+
_ = headers.pop("Content-Length", None)
|
|
324
|
+
|
|
325
|
+
elif "Content-Length" not in headers:
|
|
326
|
+
headers["Content-Length"] = str(len(body))
|
|
327
|
+
|
|
328
|
+
header_bytes = (
|
|
329
|
+
b"\r\n".join([f"{k}: {v}".encode() for k, v in headers.items()])
|
|
330
|
+
+ b"\r\n"
|
|
331
|
+
+ b"\r\n"
|
|
332
|
+
)
|
|
333
|
+
if body is None:
|
|
334
|
+
return status_line + header_bytes
|
|
335
|
+
|
|
336
|
+
return status_line + header_bytes + body
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rnhttp
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: HTTP/1.1 over Reticulum Network Stack
|
|
5
|
+
Author-email: Eeems <eeems@eeems.email>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: rns>=1.1.0
|
|
21
|
+
Requires-Dist: httptools>=0.7.1
|
|
22
|
+
Requires-Dist: overrides==7.7.0; python_version < "3.12"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff; extra == "dev"
|
|
27
|
+
Requires-Dist: basedpyright; extra == "dev"
|
|
28
|
+
Requires-Dist: vulture; extra == "dev"
|
|
29
|
+
Requires-Dist: dodgy; extra == "dev"
|
|
30
|
+
Requires-Dist: pyroma; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
rnhttp
|
|
34
|
+
======
|
|
35
|
+
|
|
36
|
+
Library for HTTP/1.1 over Reticulum. It provides both a server and client library as well as an example server. You can run `python -m rnhttp.client` to make web requests against an arbirary server. The servers have a concept of the port they are hosting on as well. This will allow building applications over RNS that use the HTTP stack without having to build any sort of netowrk proxying.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
rnhttp/__init__.py,sha256=OkVTVa6Gzss84w1oMytfQrKpK3e_76XWsyg0MpLCzT0,338
|
|
2
|
+
rnhttp/__whitelist.py,sha256=2cIzViibk4ixv9IX-qqz8rp-GOkvG4YICS18ZTuiZ50,1093
|
|
3
|
+
rnhttp/_compat.py,sha256=8wBuTnFCiW0hj9tEZHds3Dcv33DRwQX5uaGIRkIaJv4,708
|
|
4
|
+
rnhttp/client.py,sha256=SWAvaqcoQl5BWq-CuVTtZLyC1w77dpNDE7OPd-emHUU,10914
|
|
5
|
+
rnhttp/server.py,sha256=LwrnW_YqgFlb4RdqcQNDjAsUsJZpGKB7ytKoKzSbYnA,10106
|
|
6
|
+
rnhttp/types.py,sha256=zsnf0OKiHFZjCbd8hfHoPcIc4T7Fm6jJFlVu9tLAaDI,9758
|
|
7
|
+
rnhttp-0.0.1.dist-info/licenses/LICENSE,sha256=KYPeRZiMx0-RtlpktedIUotMEOaCVwCrDyMjoltMR0A,1085
|
|
8
|
+
rnhttp-0.0.1.dist-info/METADATA,sha256=bHTlrYbv3bYZ8RZCWuoZNC0VD7r8gcxrThxbLrXXmHA,1583
|
|
9
|
+
rnhttp-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
rnhttp-0.0.1.dist-info/top_level.txt,sha256=iHiIPAxfH20gf73yOIEly96aecVVFFNXOYAgKwNEPR4,7
|
|
11
|
+
rnhttp-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nathaniel "Eeems" van Diepen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rnhttp
|