routedef 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.
- routedef/__init__.py +38 -0
- routedef/adapters/__init__.py +2 -0
- routedef/adapters/cloudflare.py +316 -0
- routedef/adapters/errors.py +17 -0
- routedef/adapters/fastapi.py +184 -0
- routedef/contracts.py +165 -0
- routedef/errors.py +10 -0
- routedef/headers.py +16 -0
- routedef/matching.py +97 -0
- routedef/py.typed +1 -0
- routedef/request.py +31 -0
- routedef/response.py +36 -0
- routedef/table.py +73 -0
- routedef/types.py +9 -0
- routedef/version.py +23 -0
- routedef-0.1.0.dist-info/METADATA +119 -0
- routedef-0.1.0.dist-info/RECORD +19 -0
- routedef-0.1.0.dist-info/WHEEL +4 -0
- routedef-0.1.0.dist-info/licenses/LICENSE +21 -0
routedef/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from routedef.contracts import RouteDef, RouteHandler, RouteRequest, RouteResponse
|
|
5
|
+
from routedef.errors import BadRequestBody, RouteConfigError
|
|
6
|
+
from routedef.headers import get_header, normalize_headers
|
|
7
|
+
from routedef.matching import CompiledPath, compile_path_template, expand_path_template, match_path
|
|
8
|
+
from routedef.request import decode_json_body, decode_text_body, parse_query
|
|
9
|
+
from routedef.response import response_body_kind, response_content_type, serialize_response_body
|
|
10
|
+
from routedef.table import CompiledRoute, RouteMatch, RouteTable
|
|
11
|
+
from routedef.types import JSONValue
|
|
12
|
+
from routedef.version import __version__
|
|
13
|
+
|
|
14
|
+
__all__ = (
|
|
15
|
+
"BadRequestBody",
|
|
16
|
+
"CompiledPath",
|
|
17
|
+
"CompiledRoute",
|
|
18
|
+
"JSONValue",
|
|
19
|
+
"RouteConfigError",
|
|
20
|
+
"RouteDef",
|
|
21
|
+
"RouteHandler",
|
|
22
|
+
"RouteMatch",
|
|
23
|
+
"RouteRequest",
|
|
24
|
+
"RouteResponse",
|
|
25
|
+
"RouteTable",
|
|
26
|
+
"__version__",
|
|
27
|
+
"compile_path_template",
|
|
28
|
+
"decode_json_body",
|
|
29
|
+
"decode_text_body",
|
|
30
|
+
"expand_path_template",
|
|
31
|
+
"get_header",
|
|
32
|
+
"match_path",
|
|
33
|
+
"normalize_headers",
|
|
34
|
+
"parse_query",
|
|
35
|
+
"response_body_kind",
|
|
36
|
+
"response_content_type",
|
|
37
|
+
"serialize_response_body",
|
|
38
|
+
)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
from typing import Generic, Protocol, SupportsBytes, TypeAlias, TypeVar, cast, runtime_checkable
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from routedef.adapters.errors import AdapterError
|
|
13
|
+
from routedef.contracts import RouteDef, RouteRequest, RouteResponse
|
|
14
|
+
from routedef.errors import BadRequestBody
|
|
15
|
+
from routedef.headers import get_header, normalize_headers
|
|
16
|
+
from routedef.request import decode_json_body, decode_text_body, parse_query
|
|
17
|
+
from routedef.response import serialize_response_body
|
|
18
|
+
from routedef.table import RouteTable
|
|
19
|
+
|
|
20
|
+
AuthT = TypeVar("AuthT")
|
|
21
|
+
ContextT = TypeVar("ContextT")
|
|
22
|
+
ValueT = TypeVar("ValueT")
|
|
23
|
+
|
|
24
|
+
MaybeAwaitable: TypeAlias = Awaitable[ValueT] | ValueT
|
|
25
|
+
ContextProvider = Callable[["CloudflareRequest"], MaybeAwaitable[ContextT]]
|
|
26
|
+
AuthProvider = Callable[[RouteDef[AuthT, ContextT], "CloudflareRequest", ContextT], MaybeAwaitable[AuthT]]
|
|
27
|
+
EnforcerResult = None | bool | RouteResponse
|
|
28
|
+
Enforcer = Callable[[RouteDef[AuthT, ContextT], "CloudflareRequest", ContextT, AuthT], MaybeAwaitable[EnforcerResult]]
|
|
29
|
+
ErrorHandler = Callable[[AdapterError, "CloudflareRequest"], MaybeAwaitable[RouteResponse]]
|
|
30
|
+
ArrayBufferBody: TypeAlias = bytes | bytearray | memoryview
|
|
31
|
+
ArrayBufferProxyBody: TypeAlias = ArrayBufferBody | Iterable[int] | SupportsBytes
|
|
32
|
+
HeaderPair: TypeAlias = tuple[str, str]
|
|
33
|
+
HeaderPairs: TypeAlias = Iterable[HeaderPair]
|
|
34
|
+
ResponseFactory: TypeAlias = Callable[..., object]
|
|
35
|
+
|
|
36
|
+
JSON_CONTENT_TYPE = "application/json"
|
|
37
|
+
JSON_SUFFIX = "+json"
|
|
38
|
+
TEXT_PREFIX = "text/"
|
|
39
|
+
LOOKUP_HEADER_NAMES: tuple[str, ...] = (
|
|
40
|
+
"accept",
|
|
41
|
+
"accept-encoding",
|
|
42
|
+
"authorization",
|
|
43
|
+
"cache-control",
|
|
44
|
+
"content-length",
|
|
45
|
+
"content-type",
|
|
46
|
+
"cookie",
|
|
47
|
+
"host",
|
|
48
|
+
"if-match",
|
|
49
|
+
"if-modified-since",
|
|
50
|
+
"if-none-match",
|
|
51
|
+
"if-unmodified-since",
|
|
52
|
+
"referer",
|
|
53
|
+
"user-agent",
|
|
54
|
+
"x-forwarded-for",
|
|
55
|
+
"x-forwarded-host",
|
|
56
|
+
"x-forwarded-proto",
|
|
57
|
+
"x-real-ip",
|
|
58
|
+
)
|
|
59
|
+
__all__ = ("AdapterError", "CloudflareDispatcher", "CloudflareRequest")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@runtime_checkable
|
|
63
|
+
class HeaderItems(Protocol):
|
|
64
|
+
def items(self) -> HeaderPairs: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@runtime_checkable
|
|
68
|
+
class HeaderGetter(Protocol):
|
|
69
|
+
def get(self, name: str) -> str | None: ...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@runtime_checkable
|
|
73
|
+
class HeaderIndexer(Protocol):
|
|
74
|
+
def __getitem__(self, name: str) -> str: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@runtime_checkable
|
|
78
|
+
class ArrayBufferProxy(Protocol):
|
|
79
|
+
def to_py(self) -> ArrayBufferProxyBody: ...
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
CloudflareHeaders: TypeAlias = Mapping[str, str] | HeaderPairs | HeaderItems | HeaderGetter | HeaderIndexer
|
|
83
|
+
ArrayBufferResult: TypeAlias = ArrayBufferBody | ArrayBufferProxy
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CloudflareRequest(Protocol):
|
|
87
|
+
method: str
|
|
88
|
+
url: str
|
|
89
|
+
headers: CloudflareHeaders
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@runtime_checkable
|
|
93
|
+
class ArrayBufferRequest(CloudflareRequest, Protocol):
|
|
94
|
+
def arrayBuffer(self) -> Awaitable[ArrayBufferResult]: ...
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@runtime_checkable
|
|
98
|
+
class TextRequest(CloudflareRequest, Protocol):
|
|
99
|
+
def text(self) -> Awaitable[str]: ...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CloudflareDispatcher(Generic[AuthT, ContextT]):
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
route_table: RouteTable[AuthT, ContextT],
|
|
106
|
+
*,
|
|
107
|
+
context_provider: ContextProvider[ContextT] | None = None,
|
|
108
|
+
auth_provider: AuthProvider[AuthT, ContextT] | None = None,
|
|
109
|
+
enforcer: Enforcer[AuthT, ContextT] | None = None,
|
|
110
|
+
error_handler: ErrorHandler | None = None,
|
|
111
|
+
max_body_bytes: int | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self._route_table = route_table
|
|
114
|
+
self._context_provider = context_provider
|
|
115
|
+
self._auth_provider = auth_provider
|
|
116
|
+
self._enforcer = enforcer
|
|
117
|
+
self._error_handler = error_handler
|
|
118
|
+
self._max_body_bytes = max_body_bytes
|
|
119
|
+
|
|
120
|
+
async def dispatch(self, request: CloudflareRequest) -> object:
|
|
121
|
+
url = urlsplit(request.url)
|
|
122
|
+
match = self._route_table.match(request.method, url.path)
|
|
123
|
+
if match is None:
|
|
124
|
+
return await self._error_response(AdapterError("not_found", 404, "not found"), request)
|
|
125
|
+
|
|
126
|
+
context: ContextT = await self._resolve_context(request)
|
|
127
|
+
auth: AuthT = await self._resolve_auth(match.route, request, context)
|
|
128
|
+
enforcement: EnforcerResult = await self._resolve_enforcement(match.route, request, context, auth)
|
|
129
|
+
if isinstance(enforcement, RouteResponse):
|
|
130
|
+
return _to_cloudflare_response(enforcement)
|
|
131
|
+
if enforcement is False:
|
|
132
|
+
return await self._error_response(AdapterError("forbidden", 403, "forbidden"), request)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
route_request = await _to_route_request(
|
|
136
|
+
request, match.route, match.path_params, context, auth, max_body_bytes=self._max_body_bytes
|
|
137
|
+
)
|
|
138
|
+
except _InvalidBody as exc:
|
|
139
|
+
return await self._error_response(AdapterError("bad_request", 400, str(exc)), request)
|
|
140
|
+
except _BodyTooLarge as exc:
|
|
141
|
+
return await self._error_response(AdapterError("body_too_large", 413, str(exc)), request)
|
|
142
|
+
try:
|
|
143
|
+
route_response = await match.route.handler(route_request)
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
return await self._error_response(AdapterError("exception", 500, str(exc), exc), request)
|
|
146
|
+
return _to_cloudflare_response(route_response)
|
|
147
|
+
|
|
148
|
+
async def _resolve_context(self, request: CloudflareRequest) -> ContextT:
|
|
149
|
+
if self._context_provider is None:
|
|
150
|
+
return cast(ContextT, None) # pragma: no mutate - cast is runtime-neutral.
|
|
151
|
+
return await _resolve(self._context_provider(request))
|
|
152
|
+
|
|
153
|
+
async def _resolve_auth(
|
|
154
|
+
self,
|
|
155
|
+
route: RouteDef[AuthT, ContextT],
|
|
156
|
+
request: CloudflareRequest,
|
|
157
|
+
context: ContextT,
|
|
158
|
+
) -> AuthT:
|
|
159
|
+
if self._auth_provider is None:
|
|
160
|
+
return cast(AuthT, None) # pragma: no mutate - cast is runtime-neutral.
|
|
161
|
+
return await _resolve(self._auth_provider(route, request, context))
|
|
162
|
+
|
|
163
|
+
async def _resolve_enforcement(
|
|
164
|
+
self,
|
|
165
|
+
route: RouteDef[AuthT, ContextT],
|
|
166
|
+
request: CloudflareRequest,
|
|
167
|
+
context: ContextT,
|
|
168
|
+
auth: AuthT,
|
|
169
|
+
) -> EnforcerResult:
|
|
170
|
+
if self._enforcer is None:
|
|
171
|
+
return None
|
|
172
|
+
return await _resolve(self._enforcer(route, request, context, auth))
|
|
173
|
+
|
|
174
|
+
async def _error_response(self, error: AdapterError, request: CloudflareRequest) -> object:
|
|
175
|
+
if self._error_handler is None:
|
|
176
|
+
return _to_cloudflare_response(RouteResponse.json({"detail": error.message}, status=error.status))
|
|
177
|
+
return _to_cloudflare_response(await _resolve(self._error_handler(error, request)))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _resolve(value: MaybeAwaitable[ValueT]) -> ValueT:
|
|
181
|
+
if inspect.isawaitable(value):
|
|
182
|
+
return await cast(Awaitable[ValueT], value) # pragma: no mutate - cast is runtime-neutral.
|
|
183
|
+
return value
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def _to_route_request(
|
|
187
|
+
request: CloudflareRequest,
|
|
188
|
+
route: RouteDef[AuthT, ContextT],
|
|
189
|
+
path_params: Mapping[str, str],
|
|
190
|
+
context: ContextT,
|
|
191
|
+
auth: AuthT,
|
|
192
|
+
*,
|
|
193
|
+
max_body_bytes: int | None,
|
|
194
|
+
) -> RouteRequest[AuthT, ContextT]:
|
|
195
|
+
url = urlsplit(request.url)
|
|
196
|
+
headers = _normalize_cloudflare_headers(request.headers)
|
|
197
|
+
raw_body = await _read_raw_body(request)
|
|
198
|
+
if max_body_bytes is not None and len(raw_body) > max_body_bytes:
|
|
199
|
+
raise _BodyTooLarge("request body is too large")
|
|
200
|
+
try:
|
|
201
|
+
body = _decode_body(raw_body, headers)
|
|
202
|
+
except BadRequestBody as exc:
|
|
203
|
+
raise _InvalidBody(str(exc)) from exc
|
|
204
|
+
|
|
205
|
+
return RouteRequest(
|
|
206
|
+
method=request.method,
|
|
207
|
+
path=url.path,
|
|
208
|
+
route_path=route.path,
|
|
209
|
+
path_params=path_params,
|
|
210
|
+
query=parse_query(url.query),
|
|
211
|
+
headers=headers,
|
|
212
|
+
body=body,
|
|
213
|
+
raw_body=raw_body,
|
|
214
|
+
auth=auth,
|
|
215
|
+
context=context,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _normalize_cloudflare_headers(headers: CloudflareHeaders) -> Mapping[str, str]:
|
|
220
|
+
if isinstance(headers, Mapping):
|
|
221
|
+
return _normalize_mapping_headers(headers) # pragma: no mutate - mapping behavior is covered via dispatch.
|
|
222
|
+
if isinstance(headers, HeaderItems):
|
|
223
|
+
return _normalize_header_pairs(headers.items())
|
|
224
|
+
if isinstance(headers, Iterable):
|
|
225
|
+
return _normalize_iterable_headers(headers)
|
|
226
|
+
return _normalize_lookup_headers(headers)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _normalize_mapping_headers(headers: CloudflareHeaders) -> Mapping[str, str]:
|
|
230
|
+
return normalize_headers(cast(Mapping[str, str], headers)) # pragma: no mutate - cast is runtime-neutral.
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _normalize_iterable_headers(headers: CloudflareHeaders) -> Mapping[str, str]:
|
|
234
|
+
return _normalize_header_pairs(cast(HeaderPairs, headers)) # pragma: no mutate - cast is runtime-neutral.
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _normalize_header_pairs(headers: HeaderPairs) -> Mapping[str, str]:
|
|
238
|
+
return {name.lower(): value for name, value in headers}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _normalize_lookup_headers(headers: HeaderGetter | HeaderIndexer) -> Mapping[str, str]:
|
|
242
|
+
normalized: dict[str, str] = {}
|
|
243
|
+
for name in LOOKUP_HEADER_NAMES:
|
|
244
|
+
value = _lookup_header(headers, name)
|
|
245
|
+
if value is not None:
|
|
246
|
+
normalized[name] = value
|
|
247
|
+
return normalized
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _lookup_header(headers: HeaderGetter | HeaderIndexer, name: str) -> str | None:
|
|
251
|
+
if isinstance(headers, HeaderGetter):
|
|
252
|
+
value = headers.get(name)
|
|
253
|
+
if value is not None:
|
|
254
|
+
return value
|
|
255
|
+
if isinstance(headers, HeaderIndexer):
|
|
256
|
+
try:
|
|
257
|
+
return headers[name]
|
|
258
|
+
except (IndexError, KeyError, TypeError):
|
|
259
|
+
return None
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def _read_raw_body(request: CloudflareRequest) -> bytes:
|
|
264
|
+
if isinstance(request, ArrayBufferRequest):
|
|
265
|
+
return _array_buffer_to_bytes(await request.arrayBuffer())
|
|
266
|
+
if isinstance(request, TextRequest):
|
|
267
|
+
return (await request.text()).encode()
|
|
268
|
+
return b""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _array_buffer_to_bytes(body: ArrayBufferResult) -> bytes:
|
|
272
|
+
if isinstance(body, ArrayBufferProxy):
|
|
273
|
+
return bytes(body.to_py())
|
|
274
|
+
return bytes(body)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _decode_body(raw_body: bytes, headers: Mapping[str, str]) -> object | None:
|
|
278
|
+
if not raw_body:
|
|
279
|
+
return None
|
|
280
|
+
if _is_json_request(headers):
|
|
281
|
+
return decode_json_body(raw_body)
|
|
282
|
+
if _is_text_request(headers):
|
|
283
|
+
return decode_text_body(raw_body)
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _is_json_request(headers: Mapping[str, str]) -> bool:
|
|
288
|
+
media_type = _media_type(headers)
|
|
289
|
+
return media_type == JSON_CONTENT_TYPE or media_type.endswith(JSON_SUFFIX)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _is_text_request(headers: Mapping[str, str]) -> bool:
|
|
293
|
+
return _media_type(headers).startswith(TEXT_PREFIX)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _media_type(headers: Mapping[str, str]) -> str:
|
|
297
|
+
content_type = get_header(headers, "content-type") or "" # pragma: no mutate
|
|
298
|
+
return content_type.partition(";")[0].strip().lower()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _to_cloudflare_response(response: RouteResponse) -> object:
|
|
302
|
+
workers = import_module("workers")
|
|
303
|
+
response_factory = cast(ResponseFactory, workers.Response) # pragma: no mutate - cast is runtime-neutral.
|
|
304
|
+
return response_factory(
|
|
305
|
+
serialize_response_body(response),
|
|
306
|
+
status=response.status,
|
|
307
|
+
headers=dict(response.headers),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class _InvalidBody(Exception):
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class _BodyTooLarge(Exception):
|
|
316
|
+
pass
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
AdapterErrorKind = Literal["not_found", "forbidden", "bad_request", "body_too_large", "exception"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class AdapterError:
|
|
14
|
+
kind: AdapterErrorKind
|
|
15
|
+
status: int
|
|
16
|
+
message: str
|
|
17
|
+
exception: Exception | None = None
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
8
|
+
from typing import TypeAlias, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Request, Response
|
|
11
|
+
|
|
12
|
+
from routedef.adapters.errors import AdapterError
|
|
13
|
+
from routedef.contracts import RouteDef, RouteRequest, RouteResponse
|
|
14
|
+
from routedef.errors import BadRequestBody
|
|
15
|
+
from routedef.headers import get_header
|
|
16
|
+
from routedef.request import decode_json_body, parse_query
|
|
17
|
+
from routedef.response import serialize_response_body
|
|
18
|
+
from routedef.table import RouteTable
|
|
19
|
+
|
|
20
|
+
AuthT = TypeVar("AuthT")
|
|
21
|
+
ContextT = TypeVar("ContextT")
|
|
22
|
+
ValueT = TypeVar("ValueT")
|
|
23
|
+
|
|
24
|
+
MaybeAwaitable: TypeAlias = Awaitable[ValueT] | ValueT
|
|
25
|
+
ContextProvider = Callable[[Request], MaybeAwaitable[ContextT]]
|
|
26
|
+
AuthProvider = Callable[[RouteDef[AuthT, ContextT], Request, ContextT], MaybeAwaitable[AuthT]]
|
|
27
|
+
EnforcerResult = None | bool | RouteResponse
|
|
28
|
+
Enforcer = Callable[[RouteDef[AuthT, ContextT], Request, ContextT, AuthT], MaybeAwaitable[EnforcerResult]]
|
|
29
|
+
ErrorHandler = Callable[[AdapterError, Request], MaybeAwaitable[RouteResponse]]
|
|
30
|
+
|
|
31
|
+
JSON_CONTENT_TYPE = "application/json"
|
|
32
|
+
JSON_SUFFIX = "+json"
|
|
33
|
+
COMMON_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD")
|
|
34
|
+
__all__ = ("AdapterError", "build_fastapi_router")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_fastapi_router(
|
|
38
|
+
routes: Iterable[RouteDef[AuthT, ContextT]],
|
|
39
|
+
*,
|
|
40
|
+
context_provider: ContextProvider[ContextT] | None = None,
|
|
41
|
+
auth_provider: AuthProvider[AuthT, ContextT] | None = None,
|
|
42
|
+
enforcer: Enforcer[AuthT, ContextT] | None = None,
|
|
43
|
+
error_handler: ErrorHandler | None = None,
|
|
44
|
+
max_body_bytes: int | None = None,
|
|
45
|
+
) -> APIRouter:
|
|
46
|
+
route_table = RouteTable(routes)
|
|
47
|
+
router = APIRouter()
|
|
48
|
+
|
|
49
|
+
async def handle(request: Request) -> Response:
|
|
50
|
+
match = route_table.match(request.method, request.url.path)
|
|
51
|
+
if match is None:
|
|
52
|
+
return await _error_response(error_handler, AdapterError("not_found", 404, "not found"), request)
|
|
53
|
+
|
|
54
|
+
context: ContextT = await _resolve_context(context_provider, request)
|
|
55
|
+
auth: AuthT = await _resolve_auth(auth_provider, match.route, request, context)
|
|
56
|
+
enforcement: EnforcerResult = await _resolve_enforcement(enforcer, match.route, request, context, auth)
|
|
57
|
+
if isinstance(enforcement, RouteResponse):
|
|
58
|
+
return _to_fastapi_response(enforcement)
|
|
59
|
+
if enforcement is False:
|
|
60
|
+
return await _error_response(error_handler, AdapterError("forbidden", 403, "forbidden"), request)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
route_request = await _to_route_request(
|
|
64
|
+
request, match.route, match.path_params, context, auth, max_body_bytes=max_body_bytes
|
|
65
|
+
)
|
|
66
|
+
except _InvalidJSON as exc:
|
|
67
|
+
return await _error_response(error_handler, AdapterError("bad_request", 400, str(exc)), request)
|
|
68
|
+
except _BodyTooLarge as exc:
|
|
69
|
+
return await _error_response(error_handler, AdapterError("body_too_large", 413, str(exc)), request)
|
|
70
|
+
try:
|
|
71
|
+
route_response = await match.route.handler(route_request)
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
return await _error_response(error_handler, AdapterError("exception", 500, str(exc), exc), request)
|
|
74
|
+
return _to_fastapi_response(route_response)
|
|
75
|
+
|
|
76
|
+
for route in route_table.routes:
|
|
77
|
+
router.add_api_route(route.route.path, handle, methods=[route.route.method])
|
|
78
|
+
if error_handler is not None:
|
|
79
|
+
router.add_api_route(
|
|
80
|
+
"/{routedef_path:path}",
|
|
81
|
+
handle,
|
|
82
|
+
methods=list(COMMON_METHODS),
|
|
83
|
+
include_in_schema=False,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return router
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _resolve_context(
|
|
90
|
+
context_provider: ContextProvider[ContextT] | None,
|
|
91
|
+
request: Request,
|
|
92
|
+
) -> ContextT:
|
|
93
|
+
if context_provider is None:
|
|
94
|
+
return cast(ContextT, None) # pragma: no mutate - cast is runtime-neutral.
|
|
95
|
+
return await _resolve(context_provider(request))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _resolve_auth(
|
|
99
|
+
auth_provider: AuthProvider[AuthT, ContextT] | None,
|
|
100
|
+
route: RouteDef[AuthT, ContextT],
|
|
101
|
+
request: Request,
|
|
102
|
+
context: ContextT,
|
|
103
|
+
) -> AuthT:
|
|
104
|
+
if auth_provider is None:
|
|
105
|
+
return cast(AuthT, None) # pragma: no mutate - cast is runtime-neutral.
|
|
106
|
+
return await _resolve(auth_provider(route, request, context))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _resolve_enforcement(
|
|
110
|
+
enforcer: Enforcer[AuthT, ContextT] | None,
|
|
111
|
+
route: RouteDef[AuthT, ContextT],
|
|
112
|
+
request: Request,
|
|
113
|
+
context: ContextT,
|
|
114
|
+
auth: AuthT,
|
|
115
|
+
) -> EnforcerResult:
|
|
116
|
+
if enforcer is None:
|
|
117
|
+
return None
|
|
118
|
+
return await _resolve(enforcer(route, request, context, auth))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _resolve(value: MaybeAwaitable[ValueT]) -> ValueT:
|
|
122
|
+
if inspect.isawaitable(value):
|
|
123
|
+
return await cast(Awaitable[ValueT], value) # pragma: no mutate - cast is runtime-neutral.
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _to_route_request(
|
|
128
|
+
request: Request,
|
|
129
|
+
route: RouteDef[AuthT, ContextT],
|
|
130
|
+
path_params: Mapping[str, str],
|
|
131
|
+
context: ContextT,
|
|
132
|
+
auth: AuthT,
|
|
133
|
+
*,
|
|
134
|
+
max_body_bytes: int | None,
|
|
135
|
+
) -> RouteRequest[AuthT, ContextT]:
|
|
136
|
+
raw_body = await request.body()
|
|
137
|
+
if max_body_bytes is not None and len(raw_body) > max_body_bytes:
|
|
138
|
+
raise _BodyTooLarge("request body is too large")
|
|
139
|
+
try:
|
|
140
|
+
body = decode_json_body(raw_body) if raw_body and _is_json_request(request) else None
|
|
141
|
+
except BadRequestBody as exc:
|
|
142
|
+
raise _InvalidJSON(str(exc)) from exc
|
|
143
|
+
|
|
144
|
+
return RouteRequest(
|
|
145
|
+
method=request.method,
|
|
146
|
+
path=request.url.path,
|
|
147
|
+
route_path=route.path,
|
|
148
|
+
path_params=path_params,
|
|
149
|
+
query=parse_query(request.url.query),
|
|
150
|
+
headers=dict(request.headers),
|
|
151
|
+
body=body,
|
|
152
|
+
raw_body=raw_body,
|
|
153
|
+
auth=auth,
|
|
154
|
+
context=context,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _is_json_request(request: Request) -> bool:
|
|
159
|
+
content_type = get_header(dict(request.headers), "content-type") or "" # pragma: no mutate
|
|
160
|
+
media_type = content_type.partition(";")[0].strip().lower()
|
|
161
|
+
return media_type == JSON_CONTENT_TYPE or media_type.endswith(JSON_SUFFIX)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _to_fastapi_response(response: RouteResponse) -> Response:
|
|
165
|
+
body = serialize_response_body(response)
|
|
166
|
+
return Response(
|
|
167
|
+
content=body,
|
|
168
|
+
status_code=response.status,
|
|
169
|
+
headers=dict(response.headers),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def _error_response(error_handler: ErrorHandler | None, error: AdapterError, request: Request) -> Response:
|
|
174
|
+
if error_handler is None:
|
|
175
|
+
return _to_fastapi_response(RouteResponse.json({"detail": error.message}, status=error.status))
|
|
176
|
+
return _to_fastapi_response(await _resolve(error_handler(error, request)))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class _InvalidJSON(Exception):
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class _BodyTooLarge(Exception):
|
|
184
|
+
pass
|
routedef/contracts.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import builtins
|
|
7
|
+
from collections.abc import Awaitable, Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import Generic, Protocol, TypeVar, cast
|
|
11
|
+
|
|
12
|
+
from routedef.errors import RouteConfigError
|
|
13
|
+
from routedef.matching import expand_path_template
|
|
14
|
+
from routedef.types import JSONValue
|
|
15
|
+
|
|
16
|
+
AuthT = TypeVar("AuthT")
|
|
17
|
+
ContextT = TypeVar("ContextT")
|
|
18
|
+
ValueT = TypeVar("ValueT")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _normalize_method(method: str) -> str:
|
|
22
|
+
normalized = method.strip().upper()
|
|
23
|
+
if not normalized:
|
|
24
|
+
raise RouteConfigError("route method must not be empty")
|
|
25
|
+
return normalized
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_path(path: str) -> str:
|
|
29
|
+
if not path.strip():
|
|
30
|
+
raise RouteConfigError("route path must not be empty")
|
|
31
|
+
if not path.startswith("/"):
|
|
32
|
+
raise RouteConfigError("route path must start with '/'")
|
|
33
|
+
return path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validate_name(name: str | None) -> str | None:
|
|
37
|
+
if name is not None and not name.strip():
|
|
38
|
+
raise RouteConfigError("route name must not be empty")
|
|
39
|
+
return name
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _readonly_mapping(mapping: Mapping[str, ValueT]) -> Mapping[str, ValueT]:
|
|
43
|
+
snapshot: dict[str, ValueT] = {key: _typed_snapshot(value) for key, value in mapping.items()}
|
|
44
|
+
return MappingProxyType(snapshot)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _typed_snapshot(value: ValueT) -> ValueT:
|
|
48
|
+
return cast(ValueT, _deep_snapshot(value)) # pragma: no mutate - cast is runtime-neutral.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _deep_snapshot(value: object) -> object:
|
|
52
|
+
if isinstance(value, Mapping):
|
|
53
|
+
return MappingProxyType({key: _deep_snapshot(item) for key, item in value.items()})
|
|
54
|
+
if isinstance(value, list | tuple):
|
|
55
|
+
return tuple(_deep_snapshot(item) for item in value)
|
|
56
|
+
if isinstance(value, set | frozenset):
|
|
57
|
+
return frozenset(_deep_snapshot(item) for item in value)
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _body_snapshot(value: object) -> object:
|
|
62
|
+
if isinstance(value, Mapping):
|
|
63
|
+
return {key: _body_snapshot(item) for key, item in value.items()}
|
|
64
|
+
if isinstance(value, list):
|
|
65
|
+
return [_body_snapshot(item) for item in value]
|
|
66
|
+
if isinstance(value, tuple):
|
|
67
|
+
return tuple(_body_snapshot(item) for item in value)
|
|
68
|
+
if isinstance(value, set | frozenset):
|
|
69
|
+
return frozenset(_body_snapshot(item) for item in value)
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _normalized_headers(headers: Mapping[str, str]) -> dict[str, str]:
|
|
74
|
+
return {name.lower(): value for name, value in headers.items()}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _headers_with_content_type(headers: Mapping[str, str], content_type: str | None) -> Mapping[str, str]:
|
|
78
|
+
normalized_headers = _normalized_headers(headers)
|
|
79
|
+
if content_type is not None and "content-type" not in normalized_headers:
|
|
80
|
+
header_name = "content-type" # pragma: no mutate - response construction normalizes header keys.
|
|
81
|
+
normalized_headers[header_name] = content_type
|
|
82
|
+
return _readonly_mapping(normalized_headers)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RouteHandler(Protocol[AuthT, ContextT]):
|
|
86
|
+
def __call__(self, request: RouteRequest[AuthT, ContextT], /) -> Awaitable[RouteResponse]: ...
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class RouteDef(Generic[AuthT, ContextT]):
|
|
91
|
+
method: str
|
|
92
|
+
path: str
|
|
93
|
+
handler: RouteHandler[AuthT, ContextT]
|
|
94
|
+
metadata: Mapping[str, object] = field(default_factory=dict)
|
|
95
|
+
name: str | None = None
|
|
96
|
+
|
|
97
|
+
def __post_init__(self) -> None:
|
|
98
|
+
object.__setattr__(self, "method", _normalize_method(self.method))
|
|
99
|
+
object.__setattr__(self, "path", _validate_path(self.path))
|
|
100
|
+
object.__setattr__(self, "metadata", _readonly_mapping(self.metadata))
|
|
101
|
+
object.__setattr__(self, "name", _validate_name(self.name))
|
|
102
|
+
|
|
103
|
+
def path_for(self, *, encode: bool = True, **path_params: object) -> str:
|
|
104
|
+
return expand_path_template(self.path, path_params, encode=encode)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
108
|
+
class RouteRequest(Generic[AuthT, ContextT]):
|
|
109
|
+
method: str
|
|
110
|
+
path: str
|
|
111
|
+
route_path: str
|
|
112
|
+
auth: AuthT
|
|
113
|
+
context: ContextT
|
|
114
|
+
path_params: Mapping[str, str] = field(default_factory=dict)
|
|
115
|
+
query: Mapping[str, str] = field(default_factory=dict)
|
|
116
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
117
|
+
body: object | None = None
|
|
118
|
+
raw_body: builtins.bytes = b""
|
|
119
|
+
|
|
120
|
+
def __post_init__(self) -> None:
|
|
121
|
+
object.__setattr__(self, "method", _normalize_method(self.method))
|
|
122
|
+
object.__setattr__(self, "path", _validate_path(self.path))
|
|
123
|
+
object.__setattr__(self, "route_path", _validate_path(self.route_path))
|
|
124
|
+
object.__setattr__(self, "path_params", _readonly_mapping(self.path_params))
|
|
125
|
+
object.__setattr__(self, "query", _readonly_mapping(self.query))
|
|
126
|
+
object.__setattr__(self, "headers", _readonly_mapping(self.headers))
|
|
127
|
+
object.__setattr__(self, "body", _body_snapshot(self.body))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
131
|
+
class RouteResponse:
|
|
132
|
+
status: int = 200
|
|
133
|
+
body: object | None = None
|
|
134
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
135
|
+
|
|
136
|
+
def __post_init__(self) -> None:
|
|
137
|
+
object.__setattr__(self, "body", _body_snapshot(self.body))
|
|
138
|
+
object.__setattr__(self, "headers", _readonly_mapping(_normalized_headers(self.headers)))
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def json(cls, body: JSONValue, *, status: int = 200, headers: Mapping[str, str] | None = None) -> RouteResponse:
|
|
142
|
+
return cls(status=status, body=body, headers=_headers_with_content_type(headers or {}, "application/json"))
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def text(cls, body: str, *, status: int = 200, headers: Mapping[str, str] | None = None) -> RouteResponse:
|
|
146
|
+
return cls(
|
|
147
|
+
status=status,
|
|
148
|
+
body=body,
|
|
149
|
+
headers=_headers_with_content_type(headers or {}, "text/plain; charset=utf-8"),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def bytes(
|
|
154
|
+
cls,
|
|
155
|
+
body: builtins.bytes,
|
|
156
|
+
*,
|
|
157
|
+
status: int = 200,
|
|
158
|
+
headers: Mapping[str, str] | None = None,
|
|
159
|
+
content_type: str = "application/octet-stream",
|
|
160
|
+
) -> RouteResponse:
|
|
161
|
+
return cls(status=status, body=body, headers=_headers_with_content_type(headers or {}, content_type))
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def empty(cls, status: int = 204, *, headers: Mapping[str, str] | None = None) -> RouteResponse:
|
|
165
|
+
return cls(status=status, body=None, headers=_headers_with_content_type(headers or {}, None))
|
routedef/errors.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RouteConfigError(ValueError):
|
|
6
|
+
"""Raised when a route definition is invalid."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BadRequestBody(ValueError):
|
|
10
|
+
"""Raised when a request body cannot be decoded."""
|
routedef/headers.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def normalize_headers(headers: Mapping[str, str]) -> Mapping[str, str]:
|
|
8
|
+
return {name.lower(): value for name, value in headers.items()}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_header(headers: Mapping[str, str], name: str) -> str | None:
|
|
12
|
+
normalized_name = name.lower()
|
|
13
|
+
for header_name, value in headers.items():
|
|
14
|
+
if header_name.lower() == normalized_name:
|
|
15
|
+
return value
|
|
16
|
+
return None
|
routedef/matching.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from re import Pattern
|
|
10
|
+
from urllib.parse import quote, unquote
|
|
11
|
+
|
|
12
|
+
from routedef.errors import RouteConfigError
|
|
13
|
+
|
|
14
|
+
_BRACE_PATTERN = re.compile(r"[{}]")
|
|
15
|
+
_PLACEHOLDER_NAME_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class CompiledPath:
|
|
20
|
+
path_template: str
|
|
21
|
+
pattern: Pattern[str]
|
|
22
|
+
param_names: tuple[str, ...]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def compile_path_template(path_template: str) -> CompiledPath:
|
|
26
|
+
_validate_path_template(path_template)
|
|
27
|
+
pattern_parts = ["^"]
|
|
28
|
+
param_names: list[str] = []
|
|
29
|
+
position = 0
|
|
30
|
+
|
|
31
|
+
for brace_match in _BRACE_PATTERN.finditer(path_template):
|
|
32
|
+
brace_position = brace_match.start()
|
|
33
|
+
if brace_position < position:
|
|
34
|
+
continue
|
|
35
|
+
if brace_match.group() == "}": # pragma: no mutate - alternate path raises the same config error.
|
|
36
|
+
raise RouteConfigError("route path placeholder name is invalid")
|
|
37
|
+
|
|
38
|
+
pattern_parts.append(re.escape(path_template[position:brace_position]))
|
|
39
|
+
placeholder_end = path_template.find("}", brace_position + 1) # pragma: no mutate - equivalent search start.
|
|
40
|
+
if placeholder_end == -1:
|
|
41
|
+
raise RouteConfigError("route path placeholder name is invalid")
|
|
42
|
+
|
|
43
|
+
placeholder_name = path_template[brace_position + 1 : placeholder_end]
|
|
44
|
+
_validate_placeholder_name(placeholder_name)
|
|
45
|
+
if placeholder_name in param_names:
|
|
46
|
+
raise RouteConfigError(f"route path placeholder {placeholder_name!r} is duplicated")
|
|
47
|
+
|
|
48
|
+
param_names.append(placeholder_name)
|
|
49
|
+
pattern_parts.append(f"(?P<{placeholder_name}>[^/]+)")
|
|
50
|
+
position = placeholder_end + 1
|
|
51
|
+
|
|
52
|
+
pattern_parts.append(re.escape(path_template[position:]))
|
|
53
|
+
pattern_parts.append("$")
|
|
54
|
+
return CompiledPath(
|
|
55
|
+
path_template=path_template, pattern=re.compile("".join(pattern_parts)), param_names=tuple(param_names)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def match_path(compiled_path: CompiledPath, path: str) -> dict[str, str] | None:
|
|
60
|
+
match = compiled_path.pattern.fullmatch(path)
|
|
61
|
+
if match is None:
|
|
62
|
+
return None
|
|
63
|
+
return {name: unquote(match.group(name)) for name in compiled_path.param_names}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def expand_path_template(path_template: str, path_params: Mapping[str, object], *, encode: bool = True) -> str:
|
|
67
|
+
compiled_path = compile_path_template(path_template)
|
|
68
|
+
expected = set(compiled_path.param_names)
|
|
69
|
+
provided = set(path_params)
|
|
70
|
+
missing = expected - provided
|
|
71
|
+
if missing:
|
|
72
|
+
raise RouteConfigError(f"missing path params: {', '.join(sorted(missing))}")
|
|
73
|
+
unknown = provided - expected
|
|
74
|
+
if unknown:
|
|
75
|
+
raise RouteConfigError(f"unknown path params: {', '.join(sorted(unknown))}")
|
|
76
|
+
|
|
77
|
+
expanded = path_template
|
|
78
|
+
for name in compiled_path.param_names:
|
|
79
|
+
value = str(path_params[name])
|
|
80
|
+
if encode:
|
|
81
|
+
value = quote(value, safe="") # pragma: no mutate - alphanumeric safe mutations are equivalent.
|
|
82
|
+
elif "/" in value:
|
|
83
|
+
raise RouteConfigError(f"path param {name!r} must not contain '/'")
|
|
84
|
+
expanded = expanded.replace(f"{{{name}}}", value)
|
|
85
|
+
return expanded
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _validate_path_template(path_template: str) -> None:
|
|
89
|
+
if not path_template.strip():
|
|
90
|
+
raise RouteConfigError("route path must not be empty")
|
|
91
|
+
if not path_template.startswith("/"):
|
|
92
|
+
raise RouteConfigError("route path must start with '/'")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _validate_placeholder_name(placeholder_name: str) -> None:
|
|
96
|
+
if _PLACEHOLDER_NAME_PATTERN.fullmatch(placeholder_name) is None:
|
|
97
|
+
raise RouteConfigError("route path placeholder name is invalid")
|
routedef/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
routedef/request.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Final, cast
|
|
7
|
+
from urllib.parse import parse_qsl
|
|
8
|
+
|
|
9
|
+
from routedef.errors import BadRequestBody
|
|
10
|
+
from routedef.types import JSONValue
|
|
11
|
+
|
|
12
|
+
DEFAULT_TEXT_ENCODING: Final = "utf-8" # pragma: no mutate - UTF-8 aliases are equivalent.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_query(query_string: str | bytes) -> Mapping[str, str]:
|
|
16
|
+
query_text = query_string.decode() if isinstance(query_string, bytes) else query_string
|
|
17
|
+
return dict(parse_qsl(query_text, keep_blank_values=True))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def decode_json_body(raw_body: bytes) -> JSONValue:
|
|
21
|
+
try:
|
|
22
|
+
return cast(JSONValue, json.loads(raw_body)) # pragma: no mutate - cast is runtime-neutral.
|
|
23
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
24
|
+
raise BadRequestBody("request body is not valid JSON") from exc
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def decode_text_body(raw_body: bytes, encoding: str = DEFAULT_TEXT_ENCODING) -> str:
|
|
28
|
+
try:
|
|
29
|
+
return raw_body.decode(encoding)
|
|
30
|
+
except UnicodeDecodeError as exc:
|
|
31
|
+
raise BadRequestBody("request body is not valid text") from exc
|
routedef/response.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Final, Literal, cast
|
|
6
|
+
|
|
7
|
+
from routedef.contracts import RouteResponse
|
|
8
|
+
from routedef.headers import get_header
|
|
9
|
+
|
|
10
|
+
ResponseBodyKind = Literal["empty", "bytes", "text", "json"]
|
|
11
|
+
CONTENT_TYPE_HEADER: Final = "content-type" # pragma: no mutate - header lookup is case-insensitive.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def response_body_kind(response: RouteResponse) -> ResponseBodyKind:
|
|
15
|
+
if response.status == 204 or response.body is None:
|
|
16
|
+
return "empty"
|
|
17
|
+
if isinstance(response.body, bytes):
|
|
18
|
+
return "bytes"
|
|
19
|
+
if isinstance(response.body, str):
|
|
20
|
+
return "text"
|
|
21
|
+
return "json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def serialize_response_body(response: RouteResponse) -> bytes:
|
|
25
|
+
body_kind = response_body_kind(response)
|
|
26
|
+
if body_kind == "empty":
|
|
27
|
+
return b""
|
|
28
|
+
if body_kind == "bytes":
|
|
29
|
+
return cast(bytes, response.body) # pragma: no mutate - cast is runtime-neutral.
|
|
30
|
+
if body_kind == "text":
|
|
31
|
+
return cast(str, response.body).encode() # pragma: no mutate - cast is runtime-neutral.
|
|
32
|
+
return json.dumps(response.body, separators=(",", ":")).encode()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def response_content_type(response: RouteResponse) -> str | None:
|
|
36
|
+
return get_header(response.headers, CONTENT_TYPE_HEADER)
|
routedef/table.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Generic, TypeVar
|
|
10
|
+
|
|
11
|
+
from routedef.contracts import RouteDef
|
|
12
|
+
from routedef.errors import RouteConfigError
|
|
13
|
+
from routedef.matching import CompiledPath, compile_path_template, match_path
|
|
14
|
+
|
|
15
|
+
AuthT = TypeVar("AuthT")
|
|
16
|
+
ContextT = TypeVar("ContextT")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class CompiledRoute(Generic[AuthT, ContextT]):
|
|
21
|
+
route: RouteDef[AuthT, ContextT]
|
|
22
|
+
compiled_path: CompiledPath
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class RouteMatch(Generic[AuthT, ContextT]):
|
|
27
|
+
route: RouteDef[AuthT, ContextT]
|
|
28
|
+
path_params: Mapping[str, str]
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
object.__setattr__(self, "path_params", MappingProxyType(dict(self.path_params)))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class RouteTable(Generic[AuthT, ContextT]):
|
|
36
|
+
routes: tuple[CompiledRoute[AuthT, ContextT], ...]
|
|
37
|
+
|
|
38
|
+
def __init__(self, routes: Iterable[RouteDef[AuthT, ContextT]]) -> None:
|
|
39
|
+
compiled_routes: list[CompiledRoute[AuthT, ContextT]] = []
|
|
40
|
+
seen_routes: set[tuple[str, str]] = set()
|
|
41
|
+
seen_names: set[str] = set()
|
|
42
|
+
|
|
43
|
+
for route in routes:
|
|
44
|
+
route_key = (route.method, route.path)
|
|
45
|
+
if route_key in seen_routes:
|
|
46
|
+
raise RouteConfigError(f"route table contains duplicate route {route.method} {route.path}")
|
|
47
|
+
seen_routes.add(route_key)
|
|
48
|
+
if route.name is not None:
|
|
49
|
+
if route.name in seen_names:
|
|
50
|
+
raise RouteConfigError(f"route table contains duplicate route name {route.name!r}")
|
|
51
|
+
seen_names.add(route.name)
|
|
52
|
+
compiled_routes.append(CompiledRoute(route=route, compiled_path=compile_path_template(route.path)))
|
|
53
|
+
|
|
54
|
+
object.__setattr__(self, "routes", tuple(compiled_routes))
|
|
55
|
+
|
|
56
|
+
def match(self, method: str, path: str) -> RouteMatch[AuthT, ContextT] | None:
|
|
57
|
+
normalized_method = _normalize_method(method)
|
|
58
|
+
for compiled_route in self.routes:
|
|
59
|
+
if compiled_route.route.method != normalized_method:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
path_params = match_path(compiled_route.compiled_path, path)
|
|
63
|
+
if path_params is not None:
|
|
64
|
+
return RouteMatch(route=compiled_route.route, path_params=path_params)
|
|
65
|
+
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _normalize_method(method: str) -> str:
|
|
70
|
+
normalized = method.strip().upper()
|
|
71
|
+
if not normalized:
|
|
72
|
+
raise RouteConfigError("route method must not be empty")
|
|
73
|
+
return normalized
|
routedef/types.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import TypeAlias
|
|
6
|
+
|
|
7
|
+
JSONValue: TypeAlias = (
|
|
8
|
+
None | bool | int | float | str | list["JSONValue"] | tuple["JSONValue", ...] | Mapping[str, "JSONValue"]
|
|
9
|
+
)
|
routedef/version.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from importlib.metadata import PackageNotFoundError
|
|
5
|
+
from importlib.metadata import version as _metadata_version
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
_PACKAGE_NAME: Final = "routedef"
|
|
10
|
+
_FALLBACK_VERSION: Final = "0.1.0"
|
|
11
|
+
_VERSION_FILE: Final = Path(__file__).resolve().parents[2] / "VERSION"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_version() -> str:
|
|
15
|
+
try:
|
|
16
|
+
return _metadata_version(_PACKAGE_NAME)
|
|
17
|
+
except PackageNotFoundError:
|
|
18
|
+
if not _VERSION_FILE.exists():
|
|
19
|
+
return _FALLBACK_VERSION
|
|
20
|
+
return _VERSION_FILE.read_text(encoding="utf-8").strip() # pragma: no mutate - UTF-8 aliases equivalent.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__version__ = load_version()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: routedef
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Route definition primitives for Python services.
|
|
5
|
+
Project-URL: Homepage, https://github.com/provide-io/routedef
|
|
6
|
+
Project-URL: Repository, https://github.com/provide-io/routedef
|
|
7
|
+
Project-URL: Documentation, https://github.com/provide-io/routedef/blob/main/docs/architecture.md
|
|
8
|
+
Project-URL: Issues, https://github.com/provide-io/routedef/issues
|
|
9
|
+
Author: provide.io llc
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Provides-Extra: fastapi
|
|
14
|
+
Requires-Dist: fastapi>=0.116.0; extra == 'fastapi'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# RouteDef ๐งญ
|
|
18
|
+
|
|
19
|
+
Runtime-neutral route definitions for Python services.
|
|
20
|
+
|
|
21
|
+
`routedef` gives applications one route contract that can be mounted into multiple runtimes. The core package has
|
|
22
|
+
no FastAPI, Cloudflare, ASGI, auth, database, or application dependency. Runtime-specific code lives in adapters.
|
|
23
|
+
|
|
24
|
+
## What It Provides โ
|
|
25
|
+
|
|
26
|
+
- `RouteDef`: method, path template, handler, and metadata.
|
|
27
|
+
- `RouteRequest`: canonical request object for handlers.
|
|
28
|
+
- `RouteResponse`: canonical response object for handlers.
|
|
29
|
+
- `RouteTable`: ordered method/path matching with `{path_param}` extraction.
|
|
30
|
+
- `build_fastapi_router`: FastAPI router integration.
|
|
31
|
+
- `CloudflareDispatcher`: direct Cloudflare Python Workers integration.
|
|
32
|
+
|
|
33
|
+
## Why Use It ๐ฏ
|
|
34
|
+
|
|
35
|
+
Use `RouteDef` when you need the same route definitions to work across more than one Python runtime, especially
|
|
36
|
+
when migrating between framework-hosted APIs and Cloudflare Python Workers.
|
|
37
|
+
|
|
38
|
+
- One handler contract instead of per-runtime handler shapes.
|
|
39
|
+
- App-owned auth and authorization through metadata, auth providers, and enforcers.
|
|
40
|
+
- Dependency-free core package with optional runtime adapters.
|
|
41
|
+
- Testable route behavior without starting a web server.
|
|
42
|
+
- Migration-friendly wrappers for legacy split-argument handlers.
|
|
43
|
+
|
|
44
|
+
## Why Not ๐ง
|
|
45
|
+
|
|
46
|
+
Do not use `RouteDef` as a full web framework, ORM, dependency injection container, auth library, or request
|
|
47
|
+
validation system. It intentionally does not own app policy, storage, schemas, background jobs, or runtime
|
|
48
|
+
lifecycle. If a service will only ever run in one framework and already has a stable route layer, the adapter
|
|
49
|
+
boundary may not be worth adding.
|
|
50
|
+
|
|
51
|
+
## Architecture ๐๏ธ
|
|
52
|
+
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
57
|
+
See [docs/architecture.md](https://github.com/provide-io/routedef/blob/main/docs/architecture.md) for package
|
|
58
|
+
boundaries and [docs/migration.md](https://github.com/provide-io/routedef/blob/main/docs/migration.md) for migration
|
|
59
|
+
examples covering undef-style roles, admin authorization callbacks, Taybols JWT auth, and uwarp split-argument
|
|
60
|
+
handlers.
|
|
61
|
+
|
|
62
|
+
## Basic Usage ๐
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from routedef import RouteDef, RouteRequest, RouteResponse, RouteTable
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def get_item(request: RouteRequest[None, dict[str, object]]) -> RouteResponse:
|
|
69
|
+
return RouteResponse.json({"id": request.path_params["id"]})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
routes = RouteTable([RouteDef("GET", "/v1/items/{id}", get_item)])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## FastAPI
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fastapi import FastAPI
|
|
79
|
+
from routedef.adapters.fastapi import build_fastapi_router
|
|
80
|
+
|
|
81
|
+
app = FastAPI()
|
|
82
|
+
app.include_router(build_fastapi_router(routes))
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Cloudflare Python Workers
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from routedef.adapters.cloudflare import CloudflareDispatcher
|
|
89
|
+
from workers import WorkerEntrypoint
|
|
90
|
+
|
|
91
|
+
dispatcher = CloudflareDispatcher(routes)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Default(WorkerEntrypoint):
|
|
95
|
+
async def fetch(self, request):
|
|
96
|
+
return await dispatcher.dispatch(request)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
A real local Cloudflare fixture lives in
|
|
100
|
+
[examples/cloudflare-worker](https://github.com/provide-io/routedef/blob/main/examples/cloudflare-worker). Run it with:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
uv run python scripts/check_cloudflare_worker.py
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The integration script vendors the local `src/routedef` package into a temporary Python Worker project, runs
|
|
107
|
+
`pywrangler sync`, starts `wrangler@latest dev`, and probes routes over HTTP.
|
|
108
|
+
|
|
109
|
+
## Quality Gates ๐งช
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
uv run pre-commit run --all-files
|
|
113
|
+
uv run pytest -q --cov=src/routedef --cov-branch --cov-report=term-missing --cov-fail-under=100
|
|
114
|
+
uv run pre-commit run mutation-sweep --hook-stage manual
|
|
115
|
+
uv run pre-commit run cloudflare-worker-integration --hook-stage manual
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The project requires 100% branch coverage, strict typing, security/dead-code/complexity checks, REUSE compliance,
|
|
119
|
+
max-LOC checks, mutation testing, and a Cloudflare Worker runtime integration gate.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
routedef/__init__.py,sha256=cw0wrUBBJm-wnc7Tga-2_aVGboGFRf7IVkff1Ye_RMQ,1234
|
|
2
|
+
routedef/contracts.py,sha256=081x0i0aSnZKjc_IZOQ4B4pCymZ_oWyKs2NvIysUd40,6362
|
|
3
|
+
routedef/errors.py,sha256=TJHDFSy5zTDN_mgUEFCEXh-078QrKl3BWJPpZBNuLrw,274
|
|
4
|
+
routedef/headers.py,sha256=faj1VwlX5sZ9sNTn-plraYytv3qZ-xCtnVlsmvmQuvk,515
|
|
5
|
+
routedef/matching.py,sha256=r5QndyzTn9OegK0W4FR-FlzBGjKrPun7IrnXF_C53Xs,3740
|
|
6
|
+
routedef/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
7
|
+
routedef/request.py,sha256=trrM_ykUKFOlD4lPAkbB_PkFKELoEcFOp1FPyjpllOQ,1147
|
|
8
|
+
routedef/response.py,sha256=k0Dyf0UJtDU5SeOZpC6UQp3WttUIuAHyWk23cKDVYYY,1287
|
|
9
|
+
routedef/table.py,sha256=VoDBSu-_dl1JEDqKOWEFXH0Ww29A9sZdbORFqRYHfmg,2673
|
|
10
|
+
routedef/types.py,sha256=F1BY4waHkpGZ4vvtFayyq9J1ZXM1EpG3HpaqB77P_d0,295
|
|
11
|
+
routedef/version.py,sha256=SGrMNmVzFdfS5OueS96awHV92QpUwWAD6QFyfknzdgk,735
|
|
12
|
+
routedef/adapters/__init__.py,sha256=AfTpA_a8cTuYHTE9chFero4BXkqswCVK4rlm8AbEdiA,91
|
|
13
|
+
routedef/adapters/cloudflare.py,sha256=0JytHp_bvA75Bn355aj7codhrocIw-YwgJedix3AIfM,11191
|
|
14
|
+
routedef/adapters/errors.py,sha256=VveydsrKsHdJWX4Wk78mkz2uf6r1_OWus5lHuRk4T00,446
|
|
15
|
+
routedef/adapters/fastapi.py,sha256=ghK03xWhIMYpyIKvqG2m7TK2T4jTj0LMeVqOe97PZzc,6741
|
|
16
|
+
routedef-0.1.0.dist-info/METADATA,sha256=t3J1GWFkhe_HCAAoawSto_Edh1AA7RLWiyRFDsDKRgc,4578
|
|
17
|
+
routedef-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
18
|
+
routedef-0.1.0.dist-info/licenses/LICENSE,sha256=IBrM7Cb7nvl0Ll4FXN98teG1rrsSxLGucsADvbqf050,1071
|
|
19
|
+
routedef-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 provide.io llc
|
|
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.
|