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 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,2 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 provide.io llc
2
+ # SPDX-License-Identifier: MIT
@@ -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
+ ![routedef request flow](https://raw.githubusercontent.com/provide-io/routedef/main/docs/diagrams/routedef-flow.svg)
54
+
55
+ ![routedef package boundaries](https://raw.githubusercontent.com/provide-io/routedef/main/docs/diagrams/runtime-adapters.svg)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.