mrok 0.3.0__py3-none-any.whl → 0.4.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.
- mrok/agent/devtools/__init__.py +0 -0
- mrok/agent/devtools/__main__.py +34 -0
- mrok/agent/devtools/inspector/__init__.py +0 -0
- mrok/agent/devtools/inspector/__main__.py +25 -0
- mrok/agent/devtools/inspector/app.py +556 -0
- mrok/agent/devtools/inspector/server.py +18 -0
- mrok/agent/sidecar/app.py +9 -10
- mrok/agent/sidecar/main.py +35 -16
- mrok/agent/ziticorn.py +27 -18
- mrok/cli/commands/__init__.py +2 -1
- mrok/cli/commands/agent/__init__.py +2 -0
- mrok/cli/commands/agent/dev/__init__.py +7 -0
- mrok/cli/commands/agent/dev/console.py +25 -0
- mrok/cli/commands/agent/dev/web.py +37 -0
- mrok/cli/commands/agent/run/asgi.py +35 -16
- mrok/cli/commands/agent/run/sidecar.py +29 -13
- mrok/cli/commands/agent/utils.py +5 -0
- mrok/cli/commands/controller/run.py +1 -5
- mrok/cli/commands/proxy/__init__.py +6 -0
- mrok/cli/commands/proxy/run.py +49 -0
- mrok/cli/utils.py +5 -0
- mrok/conf.py +6 -0
- mrok/controller/auth.py +2 -2
- mrok/datastructures.py +159 -0
- mrok/http/config.py +3 -6
- mrok/http/constants.py +22 -0
- mrok/http/forwarder.py +62 -23
- mrok/http/lifespan.py +29 -0
- mrok/http/middlewares.py +143 -0
- mrok/http/types.py +43 -0
- mrok/http/utils.py +90 -0
- mrok/logging.py +22 -0
- mrok/master.py +272 -0
- mrok/metrics.py +139 -0
- mrok/proxy/__init__.py +3 -0
- mrok/proxy/app.py +77 -0
- mrok/proxy/dataclasses.py +12 -0
- mrok/proxy/main.py +58 -0
- mrok/proxy/streams.py +124 -0
- mrok/proxy/types.py +12 -0
- mrok/proxy/ziti.py +173 -0
- {mrok-0.3.0.dist-info → mrok-0.4.1.dist-info}/METADATA +7 -1
- {mrok-0.3.0.dist-info → mrok-0.4.1.dist-info}/RECORD +46 -20
- mrok/http/master.py +0 -132
- {mrok-0.3.0.dist-info → mrok-0.4.1.dist-info}/WHEEL +0 -0
- {mrok-0.3.0.dist-info → mrok-0.4.1.dist-info}/entry_points.txt +0 -0
- {mrok-0.3.0.dist-info → mrok-0.4.1.dist-info}/licenses/LICENSE.txt +0 -0
mrok/http/forwarder.py
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import abc
|
|
2
2
|
import asyncio
|
|
3
3
|
import logging
|
|
4
|
-
from collections.abc import Awaitable, Callable
|
|
5
|
-
from typing import Any
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
from mrok.http.types import ASGIReceive, ASGISend, Scope, StreamReader, StreamWriter
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
ASGIReceive = Callable[[], Awaitable[dict[str, Any]]]
|
|
11
|
-
ASGISend = Callable[[dict[str, Any]], Awaitable[None]]
|
|
7
|
+
logger = logging.getLogger("mrok.proxy")
|
|
12
8
|
|
|
13
9
|
|
|
14
10
|
class BackendNotFoundError(Exception):
|
|
@@ -24,24 +20,71 @@ class ForwardAppBase(abc.ABC):
|
|
|
24
20
|
and streaming logic (requests and responses).
|
|
25
21
|
"""
|
|
26
22
|
|
|
27
|
-
def __init__(
|
|
28
|
-
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
read_chunk_size: int = 65536,
|
|
26
|
+
lifespan_timeout: float = 10.0,
|
|
27
|
+
) -> None:
|
|
29
28
|
self._read_chunk_size: int = int(read_chunk_size)
|
|
29
|
+
self._lifespan_timeout = lifespan_timeout
|
|
30
|
+
|
|
31
|
+
async def handle_lifespan(self, receive: ASGIReceive, send: ASGISend) -> None:
|
|
32
|
+
while True:
|
|
33
|
+
event = await receive()
|
|
34
|
+
etype = event.get("type")
|
|
35
|
+
|
|
36
|
+
if etype == "lifespan.startup":
|
|
37
|
+
try:
|
|
38
|
+
await asyncio.wait_for(self.startup(), self._lifespan_timeout)
|
|
39
|
+
except TimeoutError:
|
|
40
|
+
logger.exception("Lifespan startup timed out")
|
|
41
|
+
await send({"type": "lifespan.startup.failed", "message": "startup timeout"})
|
|
42
|
+
continue
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.exception("Exception during lifespan startup")
|
|
45
|
+
await send({"type": "lifespan.startup.failed", "message": str(e)})
|
|
46
|
+
continue
|
|
47
|
+
await send({"type": "lifespan.startup.complete"})
|
|
48
|
+
|
|
49
|
+
elif etype == "lifespan.shutdown":
|
|
50
|
+
try:
|
|
51
|
+
await asyncio.wait_for(self.shutdown(), self._lifespan_timeout)
|
|
52
|
+
except TimeoutError:
|
|
53
|
+
logger.exception("Lifespan shutdown timed out")
|
|
54
|
+
await send({"type": "lifespan.shutdown.failed", "message": "shutdown timeout"})
|
|
55
|
+
return
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
logger.exception("Exception during lifespan shutdown")
|
|
58
|
+
await send({"type": "lifespan.shutdown.failed", "message": str(exc)})
|
|
59
|
+
return
|
|
60
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
61
|
+
return
|
|
30
62
|
|
|
31
63
|
@abc.abstractmethod
|
|
32
64
|
async def select_backend(
|
|
33
65
|
self,
|
|
34
66
|
scope: Scope,
|
|
35
67
|
headers: dict[str, str],
|
|
36
|
-
) -> tuple[
|
|
68
|
+
) -> tuple[StreamReader, StreamWriter] | tuple[None, None]:
|
|
37
69
|
"""Return (reader, writer) connected to the target backend."""
|
|
38
70
|
|
|
71
|
+
async def startup(self):
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
async def shutdown(self):
|
|
75
|
+
return
|
|
76
|
+
|
|
39
77
|
async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend) -> None:
|
|
40
78
|
"""ASGI callable entry point.
|
|
41
79
|
|
|
42
80
|
Delegates to smaller helper methods for readability. Subclasses only
|
|
43
81
|
need to implement backend selection.
|
|
44
82
|
"""
|
|
83
|
+
scope_type = scope.get("type")
|
|
84
|
+
if scope_type == "lifespan":
|
|
85
|
+
await self.handle_lifespan(receive, send)
|
|
86
|
+
return
|
|
87
|
+
|
|
45
88
|
if scope.get("type") != "http":
|
|
46
89
|
await send({"type": "http.response.start", "status": 500, "headers": []})
|
|
47
90
|
await send({"type": "http.response.body", "body": b"Unsupported"})
|
|
@@ -116,7 +159,7 @@ class ForwardAppBase(abc.ABC):
|
|
|
116
159
|
|
|
117
160
|
async def write_request_line_and_headers(
|
|
118
161
|
self,
|
|
119
|
-
writer:
|
|
162
|
+
writer: StreamWriter,
|
|
120
163
|
method: str,
|
|
121
164
|
path_qs: str,
|
|
122
165
|
headers: list[tuple[bytes, bytes]],
|
|
@@ -130,7 +173,7 @@ class ForwardAppBase(abc.ABC):
|
|
|
130
173
|
await writer.drain()
|
|
131
174
|
|
|
132
175
|
async def stream_request_body(
|
|
133
|
-
self, receive: ASGIReceive, writer:
|
|
176
|
+
self, receive: ASGIReceive, writer: StreamWriter, use_chunked: bool
|
|
134
177
|
) -> None:
|
|
135
178
|
if use_chunked:
|
|
136
179
|
await self.stream_request_chunked(receive, writer)
|
|
@@ -138,9 +181,7 @@ class ForwardAppBase(abc.ABC):
|
|
|
138
181
|
|
|
139
182
|
await self.stream_request_until_end(receive, writer)
|
|
140
183
|
|
|
141
|
-
async def stream_request_chunked(
|
|
142
|
-
self, receive: ASGIReceive, writer: asyncio.StreamWriter
|
|
143
|
-
) -> None:
|
|
184
|
+
async def stream_request_chunked(self, receive: ASGIReceive, writer: StreamWriter) -> None:
|
|
144
185
|
"""Send request body to backend using HTTP/1.1 chunked encoding."""
|
|
145
186
|
while True:
|
|
146
187
|
event = await receive()
|
|
@@ -160,9 +201,7 @@ class ForwardAppBase(abc.ABC):
|
|
|
160
201
|
writer.write(b"0\r\n\r\n")
|
|
161
202
|
await writer.drain()
|
|
162
203
|
|
|
163
|
-
async def stream_request_until_end(
|
|
164
|
-
self, receive: ASGIReceive, writer: asyncio.StreamWriter
|
|
165
|
-
) -> None:
|
|
204
|
+
async def stream_request_until_end(self, receive: ASGIReceive, writer: StreamWriter) -> None:
|
|
166
205
|
"""Send request body to backend when content length/transfer-encoding
|
|
167
206
|
already provided (no chunking).
|
|
168
207
|
"""
|
|
@@ -180,7 +219,7 @@ class ForwardAppBase(abc.ABC):
|
|
|
180
219
|
return
|
|
181
220
|
|
|
182
221
|
async def read_status_and_headers(
|
|
183
|
-
self, reader:
|
|
222
|
+
self, reader: StreamReader, first_line: bytes
|
|
184
223
|
) -> tuple[int, list[tuple[bytes, bytes]], dict[bytes, bytes]]:
|
|
185
224
|
parts = first_line.decode(errors="ignore").split(" ", 2)
|
|
186
225
|
status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else 502
|
|
@@ -217,14 +256,14 @@ class ForwardAppBase(abc.ABC):
|
|
|
217
256
|
except Exception:
|
|
218
257
|
return None
|
|
219
258
|
|
|
220
|
-
async def drain_trailers(self, reader:
|
|
259
|
+
async def drain_trailers(self, reader: StreamReader) -> None:
|
|
221
260
|
"""Consume trailer header lines until an empty line is encountered."""
|
|
222
261
|
while True:
|
|
223
262
|
trailer = await reader.readline()
|
|
224
263
|
if trailer in (b"\r\n", b"\n", b""):
|
|
225
264
|
break
|
|
226
265
|
|
|
227
|
-
async def stream_response_chunked(self, reader:
|
|
266
|
+
async def stream_response_chunked(self, reader: StreamReader, send: ASGISend) -> None:
|
|
228
267
|
"""Read chunked-encoded response from reader, decode and forward to ASGI send."""
|
|
229
268
|
while True:
|
|
230
269
|
size_line = await reader.readline()
|
|
@@ -253,7 +292,7 @@ class ForwardAppBase(abc.ABC):
|
|
|
253
292
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
254
293
|
|
|
255
294
|
async def stream_response_with_content_length(
|
|
256
|
-
self, reader:
|
|
295
|
+
self, reader: StreamReader, send: ASGISend, content_length: int
|
|
257
296
|
) -> None:
|
|
258
297
|
"""Read exactly content_length bytes and forward to ASGI send events."""
|
|
259
298
|
remaining = content_length
|
|
@@ -272,7 +311,7 @@ class ForwardAppBase(abc.ABC):
|
|
|
272
311
|
if not sent_final:
|
|
273
312
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
274
313
|
|
|
275
|
-
async def stream_response_until_eof(self, reader:
|
|
314
|
+
async def stream_response_until_eof(self, reader: StreamReader, send: ASGISend) -> None:
|
|
276
315
|
"""Read from reader until EOF and forward chunks to ASGI send events."""
|
|
277
316
|
while True:
|
|
278
317
|
chunk = await reader.read(self._read_chunk_size)
|
|
@@ -282,7 +321,7 @@ class ForwardAppBase(abc.ABC):
|
|
|
282
321
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
283
322
|
|
|
284
323
|
async def stream_response_body(
|
|
285
|
-
self, reader:
|
|
324
|
+
self, reader: StreamReader, send: ASGISend, raw_headers: dict[bytes, bytes]
|
|
286
325
|
) -> None:
|
|
287
326
|
te = raw_headers.get(b"transfer-encoding", b"").lower()
|
|
288
327
|
cl = raw_headers.get(b"content-length")
|
mrok/http/lifespan.py
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from collections.abc import Awaitable, Callable
|
|
2
3
|
|
|
3
4
|
from uvicorn.config import Config
|
|
4
5
|
from uvicorn.lifespan.on import LifespanOn
|
|
5
6
|
|
|
7
|
+
AsyncCallback = Callable[[], Awaitable[None]]
|
|
8
|
+
|
|
6
9
|
|
|
7
10
|
class MrokLifespan(LifespanOn):
|
|
8
11
|
def __init__(self, config: Config) -> None:
|
|
9
12
|
super().__init__(config)
|
|
10
13
|
self.logger = logging.getLogger("mrok.proxy")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LifespanWrapper:
|
|
17
|
+
def __init__(
|
|
18
|
+
self, app, on_startup: AsyncCallback | None = None, on_shutdown: AsyncCallback | None = None
|
|
19
|
+
):
|
|
20
|
+
self.app = app
|
|
21
|
+
self.on_startup = on_startup
|
|
22
|
+
self.on_shutdown = on_shutdown
|
|
23
|
+
|
|
24
|
+
async def __call__(self, scope, receive, send):
|
|
25
|
+
if scope["type"] == "lifespan":
|
|
26
|
+
while True:
|
|
27
|
+
event = await receive()
|
|
28
|
+
if event["type"] == "lifespan.startup":
|
|
29
|
+
if self.on_startup:
|
|
30
|
+
await self.on_startup()
|
|
31
|
+
await send({"type": "lifespan.startup.complete"})
|
|
32
|
+
|
|
33
|
+
elif event["type"] == "lifespan.shutdown":
|
|
34
|
+
if self.on_shutdown:
|
|
35
|
+
await self.on_shutdown()
|
|
36
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
37
|
+
break
|
|
38
|
+
else:
|
|
39
|
+
await self.app(scope, receive, send)
|
mrok/http/middlewares.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable, Coroutine
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mrok.datastructures import FixedSizeByteBuffer, HTTPHeaders, HTTPRequest, HTTPResponse
|
|
9
|
+
from mrok.http.constants import MAX_REQUEST_BODY_BYTES, MAX_RESPONSE_BODY_BYTES
|
|
10
|
+
from mrok.http.types import ASGIApp, ASGIReceive, ASGISend, Message, Scope
|
|
11
|
+
from mrok.http.utils import must_capture_request, must_capture_response
|
|
12
|
+
from mrok.metrics import WorkerMetricsCollector
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("mrok.proxy")
|
|
15
|
+
|
|
16
|
+
ResponseCompleteCallback = Callable[[HTTPResponse], Coroutine[Any, Any, None] | None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CaptureMiddleware:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
app: ASGIApp,
|
|
23
|
+
on_response_complete: ResponseCompleteCallback,
|
|
24
|
+
):
|
|
25
|
+
self.app = app
|
|
26
|
+
self._on_response_complete = on_response_complete
|
|
27
|
+
|
|
28
|
+
async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend):
|
|
29
|
+
if scope["type"] != "http":
|
|
30
|
+
await self.app(scope, receive, send)
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
start_time = time.time()
|
|
34
|
+
method = scope["method"]
|
|
35
|
+
path = scope["path"]
|
|
36
|
+
query_string = scope.get("query_string", b"")
|
|
37
|
+
req_headers_raw = scope.get("headers", [])
|
|
38
|
+
req_headers = HTTPHeaders.from_asgi(req_headers_raw)
|
|
39
|
+
|
|
40
|
+
state = {}
|
|
41
|
+
|
|
42
|
+
req_buf = FixedSizeByteBuffer(MAX_REQUEST_BODY_BYTES)
|
|
43
|
+
capture_req_body = must_capture_request(method, req_headers)
|
|
44
|
+
|
|
45
|
+
request = HTTPRequest(
|
|
46
|
+
method=method,
|
|
47
|
+
url=path,
|
|
48
|
+
headers=req_headers,
|
|
49
|
+
query_string=query_string,
|
|
50
|
+
start_time=start_time,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Response capture
|
|
54
|
+
resp_buf = FixedSizeByteBuffer(MAX_RESPONSE_BODY_BYTES)
|
|
55
|
+
|
|
56
|
+
async def receive_wrapper() -> Message:
|
|
57
|
+
msg = await receive()
|
|
58
|
+
if capture_req_body and msg["type"] == "http.request":
|
|
59
|
+
body = msg.get("body", b"")
|
|
60
|
+
req_buf.write(body)
|
|
61
|
+
return msg
|
|
62
|
+
|
|
63
|
+
async def send_wrapper(msg: Message):
|
|
64
|
+
if msg["type"] == "http.response.start":
|
|
65
|
+
state["status"] = msg["status"]
|
|
66
|
+
resp_headers = HTTPHeaders.from_asgi(msg.get("headers", []))
|
|
67
|
+
state["resp_headers_raw"] = resp_headers
|
|
68
|
+
|
|
69
|
+
state["capture_resp_body"] = must_capture_response(resp_headers)
|
|
70
|
+
|
|
71
|
+
if state["capture_resp_body"] and msg["type"] == "http.response.body":
|
|
72
|
+
body = msg.get("body", b"")
|
|
73
|
+
resp_buf.write(body)
|
|
74
|
+
|
|
75
|
+
await send(msg)
|
|
76
|
+
|
|
77
|
+
await self.app(scope, receive_wrapper, send_wrapper)
|
|
78
|
+
|
|
79
|
+
# Finalise request
|
|
80
|
+
request.body = req_buf.getvalue() if capture_req_body else None
|
|
81
|
+
request.body_truncated = req_buf.overflow if capture_req_body else None
|
|
82
|
+
|
|
83
|
+
# Finalise response
|
|
84
|
+
end_time = time.time()
|
|
85
|
+
duration = end_time - start_time
|
|
86
|
+
|
|
87
|
+
response = HTTPResponse(
|
|
88
|
+
request=request,
|
|
89
|
+
status=state["status"] or 0,
|
|
90
|
+
headers=state["resp_headers_raw"],
|
|
91
|
+
duration=duration,
|
|
92
|
+
body=resp_buf.getvalue() if state["capture_resp_body"] else None,
|
|
93
|
+
body_truncated=resp_buf.overflow if state["capture_resp_body"] else None,
|
|
94
|
+
)
|
|
95
|
+
await asyncio.create_task(self.handle_callback(response))
|
|
96
|
+
|
|
97
|
+
async def handle_callback(self, response: HTTPResponse):
|
|
98
|
+
try:
|
|
99
|
+
if inspect.iscoroutinefunction(self._on_response_complete):
|
|
100
|
+
await self._on_response_complete(response)
|
|
101
|
+
else:
|
|
102
|
+
await asyncio.get_running_loop().run_in_executor(
|
|
103
|
+
None, self._on_response_complete, response
|
|
104
|
+
)
|
|
105
|
+
except Exception:
|
|
106
|
+
logger.exception("Error invoking callback")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class MetricsMiddleware:
|
|
110
|
+
def __init__(self, app, metrics: WorkerMetricsCollector):
|
|
111
|
+
self.app = app
|
|
112
|
+
self.metrics = metrics
|
|
113
|
+
|
|
114
|
+
async def __call__(self, scope, receive, send):
|
|
115
|
+
if scope["type"] != "http":
|
|
116
|
+
return await self.app(scope, receive, send)
|
|
117
|
+
|
|
118
|
+
start_time = await self.metrics.on_request_start(scope)
|
|
119
|
+
status_code = 500 # default if app errors early
|
|
120
|
+
|
|
121
|
+
async def wrapped_receive():
|
|
122
|
+
msg = await receive()
|
|
123
|
+
if msg["type"] == "http.request" and msg.get("body"):
|
|
124
|
+
await self.metrics.on_request_body(len(msg["body"]))
|
|
125
|
+
return msg
|
|
126
|
+
|
|
127
|
+
async def wrapped_send(msg):
|
|
128
|
+
nonlocal status_code
|
|
129
|
+
|
|
130
|
+
if msg["type"] == "http.response.start":
|
|
131
|
+
status_code = msg["status"]
|
|
132
|
+
await self.metrics.on_response_start(status_code)
|
|
133
|
+
|
|
134
|
+
elif msg["type"] == "http.response.body":
|
|
135
|
+
body = msg.get("body", b"")
|
|
136
|
+
await self.metrics.on_response_chunk(len(body))
|
|
137
|
+
|
|
138
|
+
return await send(msg)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
await self.app(scope, wrapped_receive, wrapped_send)
|
|
142
|
+
finally:
|
|
143
|
+
await self.metrics.on_request_end(start_time, status_code)
|
mrok/http/types.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable, MutableMapping
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
from mrok.datastructures import HTTPRequest, HTTPResponse
|
|
8
|
+
|
|
9
|
+
Scope = MutableMapping[str, Any]
|
|
10
|
+
Message = MutableMapping[str, Any]
|
|
11
|
+
|
|
12
|
+
ASGIReceive = Callable[[], Awaitable[Message]]
|
|
13
|
+
ASGISend = Callable[[Message], Awaitable[None]]
|
|
14
|
+
ASGIApp = Callable[[Scope, ASGIReceive, ASGISend], Awaitable[None]]
|
|
15
|
+
RequestCompleteCallback = Callable[[HTTPRequest], Awaitable | None]
|
|
16
|
+
ResponseCompleteCallback = Callable[[HTTPResponse], Awaitable | None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StreamReaderWrapper(Protocol):
|
|
20
|
+
async def read(self, n: int = -1) -> bytes: ...
|
|
21
|
+
async def readexactly(self, n: int) -> bytes: ...
|
|
22
|
+
async def readline(self) -> bytes: ...
|
|
23
|
+
def at_eof(self) -> bool: ...
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def underlying(self) -> asyncio.StreamReader: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StreamWriterWrapper(Protocol):
|
|
30
|
+
def write(self, data: bytes) -> None: ...
|
|
31
|
+
async def drain(self) -> None: ...
|
|
32
|
+
def close(self) -> None: ...
|
|
33
|
+
async def wait_closed(self) -> None: ...
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def transport(self): ...
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def underlying(self) -> asyncio.StreamWriter: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
StreamReader = StreamReaderWrapper | asyncio.StreamReader
|
|
43
|
+
StreamWriter = StreamWriterWrapper | asyncio.StreamWriter
|
mrok/http/utils.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
|
|
3
|
+
from mrok.http.constants import (
|
|
4
|
+
BINARY_CONTENT_TYPES,
|
|
5
|
+
BINARY_PREFIXES,
|
|
6
|
+
MAX_REQUEST_BODY_BYTES,
|
|
7
|
+
MAX_RESPONSE_BODY_BYTES,
|
|
8
|
+
TEXTUAL_CONTENT_TYPES,
|
|
9
|
+
TEXTUAL_PREFIXES,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_binary(content_type: str) -> bool:
|
|
14
|
+
ct = content_type.lower()
|
|
15
|
+
if ct in BINARY_CONTENT_TYPES:
|
|
16
|
+
return True
|
|
17
|
+
if any(ct.startswith(p) for p in BINARY_PREFIXES):
|
|
18
|
+
return True
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_textual(content_type: str) -> bool:
|
|
23
|
+
ct = content_type.lower()
|
|
24
|
+
if ct in TEXTUAL_CONTENT_TYPES:
|
|
25
|
+
return True
|
|
26
|
+
if any(ct.startswith(p) for p in TEXTUAL_PREFIXES):
|
|
27
|
+
return True
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def must_capture_request(
|
|
32
|
+
method: str,
|
|
33
|
+
headers: Mapping,
|
|
34
|
+
) -> bool:
|
|
35
|
+
method = method.upper()
|
|
36
|
+
|
|
37
|
+
# No body expected
|
|
38
|
+
if method in ("GET", "HEAD", "OPTIONS", "TRACE"):
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
content_type = headers.get("content-type", "").lower()
|
|
42
|
+
|
|
43
|
+
content_length = None
|
|
44
|
+
if "content-length" in headers:
|
|
45
|
+
content_length = int(headers["content-length"])
|
|
46
|
+
|
|
47
|
+
if is_binary(content_type):
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
if content_type.startswith("multipart/form-data"):
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
if content_length is not None and content_length > MAX_REQUEST_BODY_BYTES:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
if is_textual(content_type):
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
if content_length is None:
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
return content_length <= MAX_REQUEST_BODY_BYTES
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def must_capture_response(
|
|
66
|
+
headers: Mapping,
|
|
67
|
+
) -> bool:
|
|
68
|
+
content_type = headers.get("content-type", "").lower()
|
|
69
|
+
disposition = headers.get("content-disposition", "").lower()
|
|
70
|
+
|
|
71
|
+
content_length = None
|
|
72
|
+
if "content-length" in headers:
|
|
73
|
+
content_length = int(headers["content-length"])
|
|
74
|
+
|
|
75
|
+
if "attachment" in disposition:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
if is_binary(content_type):
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
if content_length is not None and content_length > MAX_RESPONSE_BODY_BYTES:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
if is_textual(content_type):
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
if content_length is None:
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
return content_length <= MAX_RESPONSE_BODY_BYTES
|
mrok/logging.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import logging.config
|
|
2
2
|
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.logging import RichHandler
|
|
5
|
+
from textual_serve.server import LogHighlighter
|
|
6
|
+
|
|
3
7
|
from mrok.conf import Settings
|
|
4
8
|
|
|
5
9
|
|
|
@@ -74,3 +78,21 @@ def get_logging_config(settings: Settings, cli_mode: bool = False) -> dict:
|
|
|
74
78
|
def setup_logging(settings: Settings, cli_mode: bool = False) -> None:
|
|
75
79
|
logging_config = get_logging_config(settings, cli_mode)
|
|
76
80
|
logging.config.dictConfig(logging_config)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def setup_inspector_logging(console: Console) -> None:
|
|
84
|
+
logging.basicConfig(
|
|
85
|
+
level="WARNING",
|
|
86
|
+
format="%(message)s",
|
|
87
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
88
|
+
handlers=[
|
|
89
|
+
RichHandler(
|
|
90
|
+
show_path=False,
|
|
91
|
+
show_time=True,
|
|
92
|
+
rich_tracebacks=True,
|
|
93
|
+
tracebacks_show_locals=True,
|
|
94
|
+
highlighter=LogHighlighter(),
|
|
95
|
+
console=console,
|
|
96
|
+
)
|
|
97
|
+
],
|
|
98
|
+
)
|