rnhttp 0.0.1__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.
- rnhttp-0.0.1/LICENSE +21 -0
- rnhttp-0.0.1/PKG-INFO +36 -0
- rnhttp-0.0.1/README.md +4 -0
- rnhttp-0.0.1/pyproject.toml +96 -0
- rnhttp-0.0.1/rnhttp/__init__.py +19 -0
- rnhttp-0.0.1/rnhttp/__whitelist.py +21 -0
- rnhttp-0.0.1/rnhttp/_compat.py +20 -0
- rnhttp-0.0.1/rnhttp/client.py +330 -0
- rnhttp-0.0.1/rnhttp/server.py +312 -0
- rnhttp-0.0.1/rnhttp/types.py +336 -0
- rnhttp-0.0.1/rnhttp.egg-info/PKG-INFO +36 -0
- rnhttp-0.0.1/rnhttp.egg-info/SOURCES.txt +18 -0
- rnhttp-0.0.1/rnhttp.egg-info/dependency_links.txt +1 -0
- rnhttp-0.0.1/rnhttp.egg-info/requires.txt +14 -0
- rnhttp-0.0.1/rnhttp.egg-info/top_level.txt +1 -0
- rnhttp-0.0.1/setup.cfg +4 -0
- rnhttp-0.0.1/tests/test_integration.py +428 -0
- rnhttp-0.0.1/tests/test_protocol.py +635 -0
- rnhttp-0.0.1/tests/test_server.py +236 -0
- rnhttp-0.0.1/tests/test_types.py +147 -0
rnhttp-0.0.1/LICENSE
ADDED
|
@@ -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.
|
rnhttp-0.0.1/PKG-INFO
ADDED
|
@@ -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.
|
rnhttp-0.0.1/README.md
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
rnhttp
|
|
2
|
+
======
|
|
3
|
+
|
|
4
|
+
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,96 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rnhttp"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "HTTP/1.1 over Reticulum Network Stack"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{name = "Eeems", email="eeems@eeems.email"}]
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 3 - Alpha",
|
|
10
|
+
"Environment :: Console",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Operating System :: POSIX :: Linux",
|
|
13
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
]
|
|
20
|
+
dynamic = ["readme"]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"rns>=1.1.0",
|
|
23
|
+
"httptools>=0.7.1",
|
|
24
|
+
"overrides==7.7.0; python_version<\"3.12\""
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
packages = ["rnhttp"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.dynamic]
|
|
31
|
+
readme = {file= ["README.md"], content-type = "text/markdown"}
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=7.0.0",
|
|
36
|
+
"pytest-asyncio>=0.21.0",
|
|
37
|
+
"ruff",
|
|
38
|
+
"basedpyright",
|
|
39
|
+
"vulture",
|
|
40
|
+
"dodgy",
|
|
41
|
+
"pyroma",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
asyncio_mode = "auto"
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["setuptools>=70.1", "nuitka>=4.0.6"]
|
|
50
|
+
build-backend = "nuitka.distutils.Build"
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
exclude = [".venv", "build", "**/__whitelist.py"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
extend-select = [
|
|
57
|
+
"UP",
|
|
58
|
+
"PL",
|
|
59
|
+
"ANN",
|
|
60
|
+
"S",
|
|
61
|
+
]
|
|
62
|
+
ignore = [
|
|
63
|
+
"PLW0603",
|
|
64
|
+
"PLR2004",
|
|
65
|
+
"PLR0915",
|
|
66
|
+
"PLR0912",
|
|
67
|
+
"PLR0911",
|
|
68
|
+
"PLR6301",
|
|
69
|
+
"PLR0913",
|
|
70
|
+
"PLW1641",
|
|
71
|
+
"S101",
|
|
72
|
+
"S404",
|
|
73
|
+
"S603",
|
|
74
|
+
"S607",
|
|
75
|
+
"ANN401",
|
|
76
|
+
"ANN001",
|
|
77
|
+
"ANN003",
|
|
78
|
+
"ANN201",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
[tool.pyright]
|
|
82
|
+
exclude = [".venv", "build", "**/__whitelist.py"]
|
|
83
|
+
reportMissingTypeStubs = false
|
|
84
|
+
|
|
85
|
+
[tool.vulture]
|
|
86
|
+
ignore_names = ["__*", "_*", "_"]
|
|
87
|
+
ignore_decorators = [
|
|
88
|
+
"@app.request",
|
|
89
|
+
"@app.page",
|
|
90
|
+
"@app.file",
|
|
91
|
+
'@pytest.fixture',
|
|
92
|
+
]
|
|
93
|
+
exclude = [
|
|
94
|
+
".venv/",
|
|
95
|
+
"build/",
|
|
96
|
+
]
|
|
@@ -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
|
+
]
|
|
@@ -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)
|
|
@@ -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"]
|
|
@@ -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())
|