slick-framework 0.1.0__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.
- slick/__init__.py +52 -0
- slick/app.py +203 -0
- slick/datastructures.py +132 -0
- slick/exceptions.py +39 -0
- slick/py.typed +0 -0
- slick/requests.py +132 -0
- slick/responses.py +192 -0
- slick/routing.py +179 -0
- slick/server.py +147 -0
- slick/testclient.py +276 -0
- slick/types.py +13 -0
- slick_framework-0.1.0.dist-info/METADATA +232 -0
- slick_framework-0.1.0.dist-info/RECORD +16 -0
- slick_framework-0.1.0.dist-info/WHEEL +5 -0
- slick_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
- slick_framework-0.1.0.dist-info/top_level.txt +1 -0
slick/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""slick - a slick, minimal ASGI web framework built on the standard library.
|
|
2
|
+
|
|
3
|
+
Quick taste::
|
|
4
|
+
|
|
5
|
+
from slick import Slick
|
|
6
|
+
|
|
7
|
+
app = Slick()
|
|
8
|
+
|
|
9
|
+
@app.get("/hello/{name}")
|
|
10
|
+
async def hello(request, name: str):
|
|
11
|
+
return {"message": f"Hello, {name}!"}
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .app import CallNext, Slick
|
|
15
|
+
from .datastructures import Headers, QueryParams, State
|
|
16
|
+
from .exceptions import HTTPException
|
|
17
|
+
from .requests import Request
|
|
18
|
+
from .responses import (
|
|
19
|
+
HTMLResponse,
|
|
20
|
+
JSONResponse,
|
|
21
|
+
PlainTextResponse,
|
|
22
|
+
RedirectResponse,
|
|
23
|
+
Response,
|
|
24
|
+
to_response,
|
|
25
|
+
)
|
|
26
|
+
from .routing import Route, Router
|
|
27
|
+
from .server import serve
|
|
28
|
+
from .testclient import TestClient, TestResponse
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Slick",
|
|
34
|
+
"Router",
|
|
35
|
+
"Route",
|
|
36
|
+
"Request",
|
|
37
|
+
"Response",
|
|
38
|
+
"JSONResponse",
|
|
39
|
+
"PlainTextResponse",
|
|
40
|
+
"HTMLResponse",
|
|
41
|
+
"RedirectResponse",
|
|
42
|
+
"HTTPException",
|
|
43
|
+
"Headers",
|
|
44
|
+
"QueryParams",
|
|
45
|
+
"State",
|
|
46
|
+
"CallNext",
|
|
47
|
+
"TestClient",
|
|
48
|
+
"TestResponse",
|
|
49
|
+
"serve",
|
|
50
|
+
"to_response",
|
|
51
|
+
"__version__",
|
|
52
|
+
]
|
slick/app.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""The :class:`Slick` ASGI application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
import traceback
|
|
9
|
+
from typing import Any, Awaitable, Callable, Optional, TypeVar
|
|
10
|
+
|
|
11
|
+
from .datastructures import State
|
|
12
|
+
from .exceptions import HTTPException
|
|
13
|
+
from .requests import Request
|
|
14
|
+
from .responses import JSONResponse, PlainTextResponse, Response, to_response
|
|
15
|
+
from .routing import Handler, Router
|
|
16
|
+
from .types import Receive, Scope, Send
|
|
17
|
+
|
|
18
|
+
__all__ = ["Slick", "CallNext", "Middleware", "ExceptionHandler"]
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("slick")
|
|
21
|
+
|
|
22
|
+
CallNext = Callable[[Request], Awaitable[Response]]
|
|
23
|
+
Middleware = Callable[[Request, CallNext], Awaitable[Response]]
|
|
24
|
+
ExceptionHandler = Callable[[Request, Exception], Any]
|
|
25
|
+
LifespanHook = Callable[[], Any]
|
|
26
|
+
|
|
27
|
+
H = TypeVar("H", bound=Handler)
|
|
28
|
+
M = TypeVar("M", bound=Middleware)
|
|
29
|
+
E = TypeVar("E", bound=ExceptionHandler)
|
|
30
|
+
L = TypeVar("L", bound=LifespanHook)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _maybe_await(value: Any) -> Any:
|
|
34
|
+
if inspect.isawaitable(value):
|
|
35
|
+
return await value
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Slick(Router):
|
|
40
|
+
"""A minimal ASGI 3 application.
|
|
41
|
+
|
|
42
|
+
``Slick`` *is* a :class:`~slick.routing.Router`, so routes are added with
|
|
43
|
+
``@app.get(...)``, ``@app.post(...)``, ``@app.route(...)`` etc. An
|
|
44
|
+
instance is an ASGI callable: run it with any ASGI server, the built-in
|
|
45
|
+
:func:`slick.serve` development server, or :class:`slick.TestClient`.
|
|
46
|
+
|
|
47
|
+
:param debug: when ``True`` unhandled errors return the traceback as the
|
|
48
|
+
``500`` response body instead of a generic JSON message.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, *, debug: bool = False) -> None:
|
|
52
|
+
super().__init__()
|
|
53
|
+
self.debug = debug
|
|
54
|
+
self.state = State()
|
|
55
|
+
self._middleware: list[Middleware] = []
|
|
56
|
+
self._exception_handlers: dict[type[BaseException], ExceptionHandler] = {}
|
|
57
|
+
self._startup: list[LifespanHook] = []
|
|
58
|
+
self._shutdown: list[LifespanHook] = []
|
|
59
|
+
self._param_cache: dict[Handler, Optional[set[str]]] = {}
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------ hooks
|
|
62
|
+
|
|
63
|
+
def middleware(self, func: M) -> M:
|
|
64
|
+
"""Register an ``async def mw(request, call_next) -> Response``.
|
|
65
|
+
|
|
66
|
+
The first middleware registered is the outermost one: it sees the
|
|
67
|
+
request first and the response last.
|
|
68
|
+
"""
|
|
69
|
+
self._middleware.append(func)
|
|
70
|
+
return func
|
|
71
|
+
|
|
72
|
+
def exception_handler(self, exc_class: type[Exception]) -> Callable[[E], E]:
|
|
73
|
+
"""Register ``handler(request, exc)`` for ``exc_class`` and subclasses.
|
|
74
|
+
|
|
75
|
+
The handler may be sync or async and may return anything a route
|
|
76
|
+
handler can return. Registering for :class:`HTTPException` overrides
|
|
77
|
+
the default ``{"detail": ...}`` JSON error body.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def decorator(handler: E) -> E:
|
|
81
|
+
self._exception_handlers[exc_class] = handler
|
|
82
|
+
return handler
|
|
83
|
+
|
|
84
|
+
return decorator
|
|
85
|
+
|
|
86
|
+
def on_startup(self, func: L) -> L:
|
|
87
|
+
"""Run ``func()`` (sync or async) when the ASGI lifespan starts."""
|
|
88
|
+
self._startup.append(func)
|
|
89
|
+
return func
|
|
90
|
+
|
|
91
|
+
def on_shutdown(self, func: L) -> L:
|
|
92
|
+
"""Run ``func()`` (sync or async) when the ASGI lifespan ends."""
|
|
93
|
+
self._shutdown.append(func)
|
|
94
|
+
return func
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------- ASGI
|
|
97
|
+
|
|
98
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
99
|
+
"""ASGI 3 entry point."""
|
|
100
|
+
if scope["type"] == "lifespan":
|
|
101
|
+
await self._lifespan(receive, send)
|
|
102
|
+
return
|
|
103
|
+
if scope["type"] != "http":
|
|
104
|
+
raise RuntimeError(f"Slick does not support {scope['type']!r} connections")
|
|
105
|
+
|
|
106
|
+
scope["app"] = self
|
|
107
|
+
request = Request(scope, receive)
|
|
108
|
+
try:
|
|
109
|
+
response = await self._build_chain()(request)
|
|
110
|
+
except Exception:
|
|
111
|
+
logger.exception("Unhandled error while handling %s %s", request.method, request.path)
|
|
112
|
+
if self.debug:
|
|
113
|
+
response = PlainTextResponse(traceback.format_exc(), 500)
|
|
114
|
+
else:
|
|
115
|
+
response = JSONResponse({"detail": "Internal Server Error"}, 500)
|
|
116
|
+
await response(send)
|
|
117
|
+
# Re-raise so servers can log it and TestClient can surface it.
|
|
118
|
+
raise
|
|
119
|
+
await response(send, include_body=request.method != "HEAD")
|
|
120
|
+
|
|
121
|
+
async def _lifespan(self, receive: Receive, send: Send) -> None:
|
|
122
|
+
while True:
|
|
123
|
+
message = await receive()
|
|
124
|
+
if message["type"] == "lifespan.startup":
|
|
125
|
+
try:
|
|
126
|
+
for hook in self._startup:
|
|
127
|
+
await _maybe_await(hook())
|
|
128
|
+
except Exception:
|
|
129
|
+
await send({"type": "lifespan.startup.failed", "message": traceback.format_exc()})
|
|
130
|
+
raise
|
|
131
|
+
await send({"type": "lifespan.startup.complete"})
|
|
132
|
+
elif message["type"] == "lifespan.shutdown":
|
|
133
|
+
try:
|
|
134
|
+
for hook in self._shutdown:
|
|
135
|
+
await _maybe_await(hook())
|
|
136
|
+
except Exception:
|
|
137
|
+
await send({"type": "lifespan.shutdown.failed", "message": traceback.format_exc()})
|
|
138
|
+
raise
|
|
139
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
# --------------------------------------------------------------- dispatch
|
|
143
|
+
|
|
144
|
+
def _build_chain(self) -> CallNext:
|
|
145
|
+
call_next: CallNext = self._dispatch
|
|
146
|
+
for mw in reversed(self._middleware):
|
|
147
|
+
call_next = self._wrap(mw, call_next)
|
|
148
|
+
return call_next
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _wrap(mw: Middleware, inner: CallNext) -> CallNext:
|
|
152
|
+
async def call(request: Request) -> Response:
|
|
153
|
+
return to_response(await mw(request, inner))
|
|
154
|
+
|
|
155
|
+
return call
|
|
156
|
+
|
|
157
|
+
async def _dispatch(self, request: Request) -> Response:
|
|
158
|
+
"""Route the request, call the handler and apply exception handlers."""
|
|
159
|
+
try:
|
|
160
|
+
route, params = self.resolve(request.method, request.path)
|
|
161
|
+
request.scope["path_params"] = params
|
|
162
|
+
request.scope["route"] = route
|
|
163
|
+
result = await self._call_handler(route.handler, request, params)
|
|
164
|
+
return to_response(result)
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
handler = self._lookup_exception_handler(exc)
|
|
167
|
+
if handler is not None:
|
|
168
|
+
return to_response(await _maybe_await(handler(request, exc)))
|
|
169
|
+
if isinstance(exc, HTTPException):
|
|
170
|
+
return JSONResponse({"detail": exc.detail}, exc.status_code, exc.headers)
|
|
171
|
+
raise
|
|
172
|
+
|
|
173
|
+
def _lookup_exception_handler(self, exc: Exception) -> Optional[ExceptionHandler]:
|
|
174
|
+
for cls in type(exc).__mro__:
|
|
175
|
+
if cls in self._exception_handlers:
|
|
176
|
+
return self._exception_handlers[cls]
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
async def _call_handler(self, handler: Handler, request: Request, params: dict[str, Any]) -> Any:
|
|
180
|
+
"""Call ``handler(request, **matching_path_params)``.
|
|
181
|
+
|
|
182
|
+
Path params are passed as keyword arguments only when the handler's
|
|
183
|
+
signature names them (or accepts ``**kwargs``). Sync handlers run in a
|
|
184
|
+
worker thread so they don't block the event loop.
|
|
185
|
+
"""
|
|
186
|
+
accepted = self._accepted_params(handler)
|
|
187
|
+
kwargs = params if accepted is None else {k: v for k, v in params.items() if k in accepted}
|
|
188
|
+
if inspect.iscoroutinefunction(handler):
|
|
189
|
+
return await handler(request, **kwargs)
|
|
190
|
+
return await _maybe_await(await asyncio.to_thread(handler, request, **kwargs))
|
|
191
|
+
|
|
192
|
+
def _accepted_params(self, handler: Handler) -> Optional[set[str]]:
|
|
193
|
+
"""Names of keyword params ``handler`` takes; ``None`` means "any"."""
|
|
194
|
+
if handler not in self._param_cache:
|
|
195
|
+
# Skip the first parameter: it always receives the request.
|
|
196
|
+
parameters = list(inspect.signature(handler).parameters.values())[1:]
|
|
197
|
+
if any(p.kind is p.VAR_KEYWORD for p in parameters):
|
|
198
|
+
self._param_cache[handler] = None
|
|
199
|
+
else:
|
|
200
|
+
self._param_cache[handler] = {
|
|
201
|
+
p.name for p in parameters if p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)
|
|
202
|
+
}
|
|
203
|
+
return self._param_cache[handler]
|
slick/datastructures.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Small immutable/mutable mapping types used by requests and responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping
|
|
6
|
+
from typing import Iterable, Optional, Union
|
|
7
|
+
from urllib.parse import parse_qsl
|
|
8
|
+
|
|
9
|
+
__all__ = ["Headers", "QueryParams", "State"]
|
|
10
|
+
|
|
11
|
+
HeaderInput = Union[Mapping[str, str], Iterable[tuple[str, str]], None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Headers(Mapping[str, str]):
|
|
15
|
+
"""A case-insensitive, multi-valued HTTP header collection.
|
|
16
|
+
|
|
17
|
+
Lookups via ``headers["Content-Type"]`` return the *first* value for that
|
|
18
|
+
name; use :meth:`getlist` to get all of them. Keys are stored lower-cased.
|
|
19
|
+
``Headers`` is mutable through :meth:`__setitem__`, :meth:`append` and
|
|
20
|
+
:meth:`__delitem__` so that middleware can adjust response headers.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, headers: HeaderInput = None) -> None:
|
|
24
|
+
self._items: list[tuple[str, str]] = []
|
|
25
|
+
if headers is None:
|
|
26
|
+
return
|
|
27
|
+
pairs = headers.items() if isinstance(headers, Mapping) else headers
|
|
28
|
+
for key, value in pairs:
|
|
29
|
+
self._items.append((key.lower(), value))
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_raw(cls, raw: Iterable[tuple[bytes, bytes]]) -> "Headers":
|
|
33
|
+
"""Build headers from an ASGI ``[(name_bytes, value_bytes), ...]`` list."""
|
|
34
|
+
return cls((k.decode("latin-1"), v.decode("latin-1")) for k, v in raw)
|
|
35
|
+
|
|
36
|
+
def raw(self) -> list[tuple[bytes, bytes]]:
|
|
37
|
+
"""Return the headers in ASGI wire format."""
|
|
38
|
+
return [(k.encode("latin-1"), v.encode("latin-1")) for k, v in self._items]
|
|
39
|
+
|
|
40
|
+
def __getitem__(self, key: str) -> str:
|
|
41
|
+
key = key.lower()
|
|
42
|
+
for k, v in self._items:
|
|
43
|
+
if k == key:
|
|
44
|
+
return v
|
|
45
|
+
raise KeyError(key)
|
|
46
|
+
|
|
47
|
+
def __setitem__(self, key: str, value: str) -> None:
|
|
48
|
+
"""Replace every value of ``key`` with the single ``value``."""
|
|
49
|
+
key = key.lower()
|
|
50
|
+
self._items = [(k, v) for k, v in self._items if k != key]
|
|
51
|
+
self._items.append((key, value))
|
|
52
|
+
|
|
53
|
+
def __delitem__(self, key: str) -> None:
|
|
54
|
+
key = key.lower()
|
|
55
|
+
if key not in self:
|
|
56
|
+
raise KeyError(key)
|
|
57
|
+
self._items = [(k, v) for k, v in self._items if k != key]
|
|
58
|
+
|
|
59
|
+
def __contains__(self, key: object) -> bool:
|
|
60
|
+
return isinstance(key, str) and any(k == key.lower() for k, _ in self._items)
|
|
61
|
+
|
|
62
|
+
def __iter__(self) -> Iterator[str]:
|
|
63
|
+
seen: list[str] = []
|
|
64
|
+
for k, _ in self._items:
|
|
65
|
+
if k not in seen:
|
|
66
|
+
seen.append(k)
|
|
67
|
+
return iter(seen)
|
|
68
|
+
|
|
69
|
+
def __len__(self) -> int:
|
|
70
|
+
return len(set(k for k, _ in self._items))
|
|
71
|
+
|
|
72
|
+
def __repr__(self) -> str:
|
|
73
|
+
return f"Headers({self._items!r})"
|
|
74
|
+
|
|
75
|
+
def append(self, key: str, value: str) -> None:
|
|
76
|
+
"""Add a value without removing existing values (e.g. ``Set-Cookie``)."""
|
|
77
|
+
self._items.append((key.lower(), value))
|
|
78
|
+
|
|
79
|
+
def getlist(self, key: str) -> list[str]:
|
|
80
|
+
"""Return every value for ``key`` in order (possibly empty)."""
|
|
81
|
+
key = key.lower()
|
|
82
|
+
return [v for k, v in self._items if k == key]
|
|
83
|
+
|
|
84
|
+
def multi_items(self) -> list[tuple[str, str]]:
|
|
85
|
+
"""Return all ``(name, value)`` pairs, including repeated names."""
|
|
86
|
+
return list(self._items)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class QueryParams(Mapping[str, str]):
|
|
90
|
+
"""Parsed URL query string. ``params["x"]`` returns the first value."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, query_string: Union[str, bytes] = "") -> None:
|
|
93
|
+
if isinstance(query_string, bytes):
|
|
94
|
+
query_string = query_string.decode("latin-1")
|
|
95
|
+
self._items: list[tuple[str, str]] = parse_qsl(query_string, keep_blank_values=True)
|
|
96
|
+
|
|
97
|
+
def __getitem__(self, key: str) -> str:
|
|
98
|
+
for k, v in self._items:
|
|
99
|
+
if k == key:
|
|
100
|
+
return v
|
|
101
|
+
raise KeyError(key)
|
|
102
|
+
|
|
103
|
+
def __iter__(self) -> Iterator[str]:
|
|
104
|
+
return iter(dict.fromkeys(k for k, _ in self._items))
|
|
105
|
+
|
|
106
|
+
def __len__(self) -> int:
|
|
107
|
+
return len(dict.fromkeys(k for k, _ in self._items))
|
|
108
|
+
|
|
109
|
+
def __repr__(self) -> str:
|
|
110
|
+
return f"QueryParams({self._items!r})"
|
|
111
|
+
|
|
112
|
+
def getlist(self, key: str) -> list[str]:
|
|
113
|
+
"""Return every value for ``key`` (e.g. ``?tag=a&tag=b``)."""
|
|
114
|
+
return [v for k, v in self._items if k == key]
|
|
115
|
+
|
|
116
|
+
def multi_items(self) -> list[tuple[str, str]]:
|
|
117
|
+
"""Return all ``(key, value)`` pairs, including repeated keys."""
|
|
118
|
+
return list(self._items)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class State:
|
|
122
|
+
"""A plain attribute bag (``app.state.db = ...``, ``request.state.user = ...``)."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, **values: object) -> None:
|
|
125
|
+
self.__dict__.update(values)
|
|
126
|
+
|
|
127
|
+
def __repr__(self) -> str:
|
|
128
|
+
return f"State({self.__dict__!r})"
|
|
129
|
+
|
|
130
|
+
def get(self, name: str, default: Optional[object] = None) -> object:
|
|
131
|
+
"""Return attribute ``name`` or ``default`` if it is unset."""
|
|
132
|
+
return self.__dict__.get(name, default)
|
slick/exceptions.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Exceptions that the framework turns into HTTP responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
from typing import Any, Mapping, Optional
|
|
7
|
+
|
|
8
|
+
__all__ = ["HTTPException"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HTTPException(Exception):
|
|
12
|
+
"""Raise from a handler or middleware to abort with an HTTP error.
|
|
13
|
+
|
|
14
|
+
By default it is rendered as ``{"detail": <detail>}`` JSON with the given
|
|
15
|
+
status code. ``detail`` defaults to the standard reason phrase.
|
|
16
|
+
|
|
17
|
+
Example::
|
|
18
|
+
|
|
19
|
+
raise HTTPException(404, "No such user")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
status_code: int,
|
|
25
|
+
detail: Any = None,
|
|
26
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
if detail is None:
|
|
29
|
+
try:
|
|
30
|
+
detail = HTTPStatus(status_code).phrase
|
|
31
|
+
except ValueError:
|
|
32
|
+
detail = "Error"
|
|
33
|
+
super().__init__(status_code, detail)
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
self.detail = detail
|
|
36
|
+
self.headers: dict[str, str] = dict(headers or {})
|
|
37
|
+
|
|
38
|
+
def __repr__(self) -> str:
|
|
39
|
+
return f"HTTPException(status_code={self.status_code!r}, detail={self.detail!r})"
|
slick/py.typed
ADDED
|
File without changes
|
slick/requests.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The :class:`Request` object passed to every handler and middleware."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
from urllib.parse import parse_qsl
|
|
8
|
+
|
|
9
|
+
from .datastructures import Headers, QueryParams, State
|
|
10
|
+
from .exceptions import HTTPException
|
|
11
|
+
from .types import Receive, Scope
|
|
12
|
+
|
|
13
|
+
__all__ = ["Request"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Request:
|
|
17
|
+
"""A read-only view over an ASGI HTTP ``scope`` plus its body stream.
|
|
18
|
+
|
|
19
|
+
The body is read lazily the first time :meth:`body`, :meth:`text`,
|
|
20
|
+
:meth:`json` or :meth:`form` is awaited and cached afterwards.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, scope: Scope, receive: Optional[Receive] = None) -> None:
|
|
24
|
+
if scope.get("type") != "http":
|
|
25
|
+
raise ValueError("Request requires an ASGI 'http' scope")
|
|
26
|
+
self.scope = scope
|
|
27
|
+
self._receive = receive
|
|
28
|
+
self._body: Optional[bytes] = None
|
|
29
|
+
self._headers: Optional[Headers] = None
|
|
30
|
+
self._query: Optional[QueryParams] = None
|
|
31
|
+
# Per-request attribute bag, backed by the scope so it survives
|
|
32
|
+
# being wrapped in a new Request object.
|
|
33
|
+
self.state = State()
|
|
34
|
+
self.state.__dict__ = scope.setdefault("state", {})
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def method(self) -> str:
|
|
38
|
+
"""Upper-case HTTP method, e.g. ``"GET"``."""
|
|
39
|
+
return self.scope["method"].upper()
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def path(self) -> str:
|
|
43
|
+
"""The decoded request path, without query string."""
|
|
44
|
+
return self.scope["path"]
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def url(self) -> str:
|
|
48
|
+
"""Reconstructed absolute URL (``scheme://host/path?query``)."""
|
|
49
|
+
scheme = self.scope.get("scheme", "http")
|
|
50
|
+
host = self.headers.get("host")
|
|
51
|
+
if host is None:
|
|
52
|
+
server = self.scope.get("server") or ("localhost", None)
|
|
53
|
+
host = server[0] if server[1] in (None, 80, 443) else f"{server[0]}:{server[1]}"
|
|
54
|
+
qs = self.scope.get("query_string", b"").decode("latin-1")
|
|
55
|
+
return f"{scheme}://{host}{self.path}" + (f"?{qs}" if qs else "")
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def headers(self) -> Headers:
|
|
59
|
+
"""Case-insensitive request headers."""
|
|
60
|
+
if self._headers is None:
|
|
61
|
+
self._headers = Headers.from_raw(self.scope.get("headers", []))
|
|
62
|
+
return self._headers
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def query_params(self) -> QueryParams:
|
|
66
|
+
"""Parsed query string (``request.query_params.get("page", "1")``)."""
|
|
67
|
+
if self._query is None:
|
|
68
|
+
self._query = QueryParams(self.scope.get("query_string", b""))
|
|
69
|
+
return self._query
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def path_params(self) -> dict[str, Any]:
|
|
73
|
+
"""Values captured from the route pattern, already converted."""
|
|
74
|
+
return self.scope.setdefault("path_params", {})
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def cookies(self) -> dict[str, str]:
|
|
78
|
+
"""Cookies parsed from the ``Cookie`` header."""
|
|
79
|
+
result: dict[str, str] = {}
|
|
80
|
+
for chunk in self.headers.get("cookie", "").split(";"):
|
|
81
|
+
if "=" in chunk:
|
|
82
|
+
key, _, value = chunk.strip().partition("=")
|
|
83
|
+
result[key] = value
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def client(self) -> Optional[tuple[str, int]]:
|
|
88
|
+
"""``(host, port)`` of the peer, if the server provided it."""
|
|
89
|
+
client = self.scope.get("client")
|
|
90
|
+
return tuple(client) if client else None # type: ignore[return-value]
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def app(self) -> Any:
|
|
94
|
+
"""The :class:`~slick.Slick` application handling this request."""
|
|
95
|
+
return self.scope.get("app")
|
|
96
|
+
|
|
97
|
+
async def body(self) -> bytes:
|
|
98
|
+
"""Return the full request body (read once, then cached)."""
|
|
99
|
+
if self._body is None:
|
|
100
|
+
chunks: list[bytes] = []
|
|
101
|
+
if self._receive is not None:
|
|
102
|
+
while True:
|
|
103
|
+
message = await self._receive()
|
|
104
|
+
if message["type"] == "http.disconnect":
|
|
105
|
+
break
|
|
106
|
+
chunks.append(message.get("body", b""))
|
|
107
|
+
if not message.get("more_body", False):
|
|
108
|
+
break
|
|
109
|
+
self._body = b"".join(chunks)
|
|
110
|
+
return self._body
|
|
111
|
+
|
|
112
|
+
async def text(self) -> str:
|
|
113
|
+
"""Return the body decoded as UTF-8."""
|
|
114
|
+
return (await self.body()).decode("utf-8")
|
|
115
|
+
|
|
116
|
+
async def json(self) -> Any:
|
|
117
|
+
"""Parse the body as JSON.
|
|
118
|
+
|
|
119
|
+
Raises :class:`HTTPException` ``400`` if the body is not valid JSON.
|
|
120
|
+
"""
|
|
121
|
+
raw = await self.body()
|
|
122
|
+
try:
|
|
123
|
+
return json.loads(raw)
|
|
124
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
125
|
+
raise HTTPException(400, "Invalid JSON body") from exc
|
|
126
|
+
|
|
127
|
+
async def form(self) -> dict[str, str]:
|
|
128
|
+
"""Parse an ``application/x-www-form-urlencoded`` body."""
|
|
129
|
+
return dict(parse_qsl((await self.body()).decode("latin-1"), keep_blank_values=True))
|
|
130
|
+
|
|
131
|
+
def __repr__(self) -> str:
|
|
132
|
+
return f"Request({self.method} {self.path})"
|