matchapi 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.
- matchapi/__init__.py +306 -0
- matchapi/__main__.py +3 -0
- matchapi/py.typed +0 -0
- matchapi-0.1.0.dist-info/METADATA +331 -0
- matchapi-0.1.0.dist-info/RECORD +8 -0
- matchapi-0.1.0.dist-info/WHEEL +4 -0
- matchapi-0.1.0.dist-info/entry_points.txt +2 -0
- matchapi-0.1.0.dist-info/licenses/LICENSE +21 -0
matchapi/__init__.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""matchapi: an HTTP service is one function, and `match` replaces the router.
|
|
2
|
+
|
|
3
|
+
async def handle(req):
|
|
4
|
+
match req.route:
|
|
5
|
+
case "GET", "ping":
|
|
6
|
+
return "pong"
|
|
7
|
+
case "GET", "users", user_id:
|
|
8
|
+
return {"id": int(user_id)}
|
|
9
|
+
case _:
|
|
10
|
+
return 404
|
|
11
|
+
|
|
12
|
+
$ matchapi app
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
import importlib
|
|
20
|
+
import inspect
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from collections.abc import AsyncIterable, Awaitable, Callable, Iterable, Mapping
|
|
26
|
+
from http import HTTPStatus
|
|
27
|
+
from typing import Any
|
|
28
|
+
from urllib.parse import parse_qsl
|
|
29
|
+
|
|
30
|
+
__all__ = ["App", "HTTPError", "Request", "Response", "app", "main", "respond"]
|
|
31
|
+
|
|
32
|
+
TEXT = "text/plain; charset=utf-8"
|
|
33
|
+
JSON = "application/json"
|
|
34
|
+
OCTETS = "application/octet-stream"
|
|
35
|
+
BODYLESS = frozenset(range(100, 200)) | {204, 304} # the statuses HTTP forbids a body for
|
|
36
|
+
log = logging.getLogger("matchapi")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _phrase(status: int) -> str:
|
|
40
|
+
try:
|
|
41
|
+
return HTTPStatus(status).phrase
|
|
42
|
+
except ValueError:
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _headers(headers: Mapping[str, str] | None) -> dict[str, str]:
|
|
47
|
+
"""Lower-case names; a value HTTP cannot carry fails here, inside the handler's error handling."""
|
|
48
|
+
lowered = {}
|
|
49
|
+
for key, value in (headers or {}).items():
|
|
50
|
+
try:
|
|
51
|
+
key.encode("latin-1"), value.encode("latin-1")
|
|
52
|
+
except UnicodeEncodeError as error:
|
|
53
|
+
raise ValueError(f"header {key!r}: {value!r} is not latin-1; percent-encode it") from error
|
|
54
|
+
lowered[key.lower()] = value
|
|
55
|
+
return lowered
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _json(value: object) -> bytes:
|
|
59
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class HTTPError(Exception):
|
|
63
|
+
"""Raised anywhere in the handler; becomes a JSON `{"detail": ...}` response with the status."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, status: int, detail: str | None = None) -> None:
|
|
66
|
+
self.status = status
|
|
67
|
+
self.detail = _phrase(status) if detail is None else detail
|
|
68
|
+
super().__init__(f"{status} {self.detail}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Request:
|
|
72
|
+
"""The request as plain data: no transport underneath, so a test builds one by hand."""
|
|
73
|
+
|
|
74
|
+
__slots__ = ("body", "headers", "method", "path", "query")
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
method: str,
|
|
79
|
+
path: str,
|
|
80
|
+
*,
|
|
81
|
+
query: str | Mapping[str, str] = "",
|
|
82
|
+
headers: Mapping[str, str] | None = None,
|
|
83
|
+
body: bytes = b"",
|
|
84
|
+
) -> None:
|
|
85
|
+
self.method = method.upper()
|
|
86
|
+
self.path = path
|
|
87
|
+
self.query = dict(parse_qsl(query, keep_blank_values=True)) if isinstance(query, str) else dict(query)
|
|
88
|
+
self.headers = {key.lower(): value for key, value in (headers or {}).items()}
|
|
89
|
+
self.body = body
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def route(self) -> tuple[str, ...]:
|
|
93
|
+
"""`(method, *segments)`, the subject of the handler's `match`: `/users/42` is `("GET", "users", "42")`."""
|
|
94
|
+
return (self.method, *(segment for segment in self.path.split("/") if segment))
|
|
95
|
+
|
|
96
|
+
def json(self) -> Any:
|
|
97
|
+
try:
|
|
98
|
+
return json.loads(self.body)
|
|
99
|
+
except ValueError as error:
|
|
100
|
+
raise HTTPError(400, f"invalid JSON body: {error}") from error
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Response:
|
|
104
|
+
"""The response as plain data; a `str` body is text, an iterable or async iterable of `bytes` streams."""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
status: int = 200,
|
|
109
|
+
body: bytes | str | Iterable[bytes] | AsyncIterable[bytes] = b"",
|
|
110
|
+
headers: Mapping[str, str] | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self.status = status
|
|
113
|
+
self.headers = _headers(headers)
|
|
114
|
+
if isinstance(body, str):
|
|
115
|
+
body = body.encode()
|
|
116
|
+
self.headers.setdefault("content-type", TEXT)
|
|
117
|
+
if isinstance(body, bytes) and status not in BODYLESS:
|
|
118
|
+
self.headers.setdefault("content-length", str(len(body)))
|
|
119
|
+
self.body: bytes | Iterable[bytes] | AsyncIterable[bytes] = body
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _status(value: int) -> int:
|
|
123
|
+
if not 100 <= value <= 999:
|
|
124
|
+
raise ValueError(f"{value} is not an HTTP status: a status is a three-digit number")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def respond(value: object) -> Response:
|
|
129
|
+
"""The rules that turn a handler's return value into a response."""
|
|
130
|
+
match value:
|
|
131
|
+
case Response():
|
|
132
|
+
_status(value.status)
|
|
133
|
+
return value
|
|
134
|
+
case None:
|
|
135
|
+
return Response(204)
|
|
136
|
+
case int() if type(value) is int:
|
|
137
|
+
return Response(_status(value)) if value in BODYLESS else Response(_status(value), _phrase(value))
|
|
138
|
+
case str():
|
|
139
|
+
return Response(200, value)
|
|
140
|
+
case bytes():
|
|
141
|
+
return Response(200, value, {"content-type": OCTETS})
|
|
142
|
+
case dict() | list():
|
|
143
|
+
return Response(200, _json(value), {"content-type": JSON})
|
|
144
|
+
case (int() as status, body) if type(status) is int:
|
|
145
|
+
return _with_status(status, body)
|
|
146
|
+
case (int() as status, body, Mapping() as extra) if type(status) is int:
|
|
147
|
+
return _with_status(status, body, extra)
|
|
148
|
+
raise TypeError(
|
|
149
|
+
f"cannot turn {type(value).__name__} into a response: return a dict, list, str, bytes, int status, None, "
|
|
150
|
+
"a (status, body) or (status, body, headers) tuple, or a Response"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _with_status(status: int, body: object, extra: Mapping[str, str] | None = None) -> Response:
|
|
155
|
+
inner = respond(body) # copied, so a Response shared between requests is not changed
|
|
156
|
+
response = Response(_status(status), inner.body, inner.headers)
|
|
157
|
+
response.headers.update(_headers(extra))
|
|
158
|
+
return response
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _failed(req: Request, error: BaseException) -> Response:
|
|
162
|
+
if isinstance(error, HTTPError):
|
|
163
|
+
if error.status in BODYLESS:
|
|
164
|
+
return Response(error.status)
|
|
165
|
+
return Response(error.status, _json({"detail": error.detail}), {"content-type": JSON})
|
|
166
|
+
log.error("%s %s failed", req.method, req.path, exc_info=error)
|
|
167
|
+
return Response(500, "Internal Server Error")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class App:
|
|
171
|
+
"""The handler as an ASGI application."""
|
|
172
|
+
|
|
173
|
+
def __init__(self, handler: Callable[[Request], Any]) -> None:
|
|
174
|
+
self.handler = handler
|
|
175
|
+
# True once a call has returned an awaitable: the next call then skips the thread pool
|
|
176
|
+
self._async = inspect.iscoroutinefunction(handler) or None
|
|
177
|
+
|
|
178
|
+
async def _respond(self, req: Request) -> Response:
|
|
179
|
+
try:
|
|
180
|
+
result = self.handler(req) if self._async else await asyncio.to_thread(self.handler, req)
|
|
181
|
+
if inspect.isawaitable(result):
|
|
182
|
+
self._async = True
|
|
183
|
+
result = await result
|
|
184
|
+
return respond(result)
|
|
185
|
+
except Exception as error:
|
|
186
|
+
return _failed(req, error)
|
|
187
|
+
|
|
188
|
+
async def __call__(
|
|
189
|
+
self, scope: Mapping[str, Any], receive: Callable[[], Awaitable[Any]], send: Callable[[Any], Awaitable[None]]
|
|
190
|
+
) -> None:
|
|
191
|
+
match scope["type"]:
|
|
192
|
+
case "lifespan":
|
|
193
|
+
while (await receive())["type"] != "lifespan.shutdown":
|
|
194
|
+
await send({"type": "lifespan.startup.complete"})
|
|
195
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
196
|
+
case "http":
|
|
197
|
+
body = bytearray()
|
|
198
|
+
while True:
|
|
199
|
+
message = await receive()
|
|
200
|
+
if message["type"] == "http.disconnect":
|
|
201
|
+
return # nobody is listening: the handler does not run on a truncated body
|
|
202
|
+
body += message.get("body", b"")
|
|
203
|
+
if not message.get("more_body"):
|
|
204
|
+
break
|
|
205
|
+
req = Request(
|
|
206
|
+
scope["method"],
|
|
207
|
+
scope["path"],
|
|
208
|
+
query=scope.get("query_string", b"").decode("latin-1"),
|
|
209
|
+
headers={key.decode("latin-1"): value.decode("latin-1") for key, value in scope.get("headers", [])},
|
|
210
|
+
body=bytes(body),
|
|
211
|
+
)
|
|
212
|
+
response = await self._respond(req)
|
|
213
|
+
headers = [(key.encode("latin-1"), value.encode("latin-1")) for key, value in response.headers.items()]
|
|
214
|
+
await send({"type": "http.response.start", "status": response.status, "headers": headers})
|
|
215
|
+
if isinstance(response.body, bytes):
|
|
216
|
+
await send({"type": "http.response.body", "body": response.body})
|
|
217
|
+
elif isinstance(response.body, AsyncIterable):
|
|
218
|
+
async for chunk in response.body:
|
|
219
|
+
await send({"type": "http.response.body", "body": chunk, "more_body": True})
|
|
220
|
+
await send({"type": "http.response.body", "body": b""})
|
|
221
|
+
else:
|
|
222
|
+
for chunk in response.body:
|
|
223
|
+
await send({"type": "http.response.body", "body": chunk, "more_body": True})
|
|
224
|
+
await send({"type": "http.response.body", "body": b""})
|
|
225
|
+
case "websocket":
|
|
226
|
+
await send({"type": "websocket.close"})
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def app(handler: Callable[[Request], Any]) -> App:
|
|
230
|
+
"""Wrap the handler for a server of your own: `app = matchapi.app(handle)`, then `uvicorn asgi:app`."""
|
|
231
|
+
return App(handler)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _load(target: str) -> Callable[[Request], Any]:
|
|
235
|
+
module_name, _, attribute = target.partition(":")
|
|
236
|
+
if not module_name:
|
|
237
|
+
raise SystemExit(
|
|
238
|
+
f"{target!r}: the target is module or module:function, e.g. `matchapi app` or `matchapi app:handle`"
|
|
239
|
+
)
|
|
240
|
+
try:
|
|
241
|
+
module = importlib.import_module(module_name)
|
|
242
|
+
except ModuleNotFoundError as error:
|
|
243
|
+
if error.name != module_name and not module_name.startswith(f"{error.name}."):
|
|
244
|
+
raise # the module was found; something it imports was not, and that is the user's traceback
|
|
245
|
+
hint = f"; drop the .py: `matchapi {module_name[:-3]}`" if module_name.endswith(".py") else ""
|
|
246
|
+
raise SystemExit(f"{error}{hint}") from error
|
|
247
|
+
if attribute:
|
|
248
|
+
if not hasattr(module, attribute):
|
|
249
|
+
raise SystemExit(f"{module_name} has no `{attribute}`")
|
|
250
|
+
elif hasattr(module, "handle"):
|
|
251
|
+
attribute = "handle"
|
|
252
|
+
else:
|
|
253
|
+
public = [
|
|
254
|
+
name
|
|
255
|
+
for name, value in vars(module).items()
|
|
256
|
+
if not name.startswith("_") and inspect.isfunction(value) and value.__module__ == module.__name__
|
|
257
|
+
]
|
|
258
|
+
if not public:
|
|
259
|
+
raise SystemExit(f"{module_name} has no public function to serve")
|
|
260
|
+
if len(public) > 1:
|
|
261
|
+
raise SystemExit(
|
|
262
|
+
f"{module_name} has no `handle` and several public functions: {', '.join(public)}; "
|
|
263
|
+
f"run `matchapi {module_name}:<function>`"
|
|
264
|
+
)
|
|
265
|
+
attribute = public[0]
|
|
266
|
+
handler: Callable[[Request], Any] = getattr(module, attribute)
|
|
267
|
+
if not callable(handler):
|
|
268
|
+
raise SystemExit(f"{module_name}: `{attribute}` is not callable")
|
|
269
|
+
return handler
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _factory() -> App:
|
|
273
|
+
"""The import string of `--workers` and `--reload`: every worker process loads the handler itself."""
|
|
274
|
+
return app(_load(os.environ["MATCHAPI_TARGET"]))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def main(argv: list[str] | None = None) -> None:
|
|
278
|
+
parser = argparse.ArgumentParser(
|
|
279
|
+
prog="matchapi", description="Serve a module whose function `handle(req)` is the HTTP service."
|
|
280
|
+
)
|
|
281
|
+
parser.add_argument(
|
|
282
|
+
"target", help="the module, or module:function; without a function, `handle` or the only public one"
|
|
283
|
+
)
|
|
284
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
285
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
286
|
+
parser.add_argument("--workers", type=int, help="processes; the handler is loaded in each of them")
|
|
287
|
+
parser.add_argument("--reload", action="store_true", help="restart on a code change")
|
|
288
|
+
args = parser.parse_args(argv)
|
|
289
|
+
if (cwd := os.getcwd()) not in sys.path:
|
|
290
|
+
sys.path.insert(0, cwd)
|
|
291
|
+
handler = _load(args.target) # fail here, with the message, rather than in a worker
|
|
292
|
+
|
|
293
|
+
import uvicorn
|
|
294
|
+
|
|
295
|
+
if args.workers or args.reload:
|
|
296
|
+
os.environ["MATCHAPI_TARGET"] = args.target
|
|
297
|
+
uvicorn.run(
|
|
298
|
+
f"{__name__}:_factory",
|
|
299
|
+
host=args.host,
|
|
300
|
+
port=args.port,
|
|
301
|
+
workers=args.workers,
|
|
302
|
+
reload=args.reload,
|
|
303
|
+
factory=True,
|
|
304
|
+
)
|
|
305
|
+
else:
|
|
306
|
+
uvicorn.run(app(handler), host=args.host, port=args.port)
|
matchapi/__main__.py
ADDED
matchapi/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: matchapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An HTTP service is one function: match replaces the router
|
|
5
|
+
Project-URL: Homepage, https://github.com/troyan-dy/matchapi
|
|
6
|
+
Project-URL: Repository, https://github.com/troyan-dy/matchapi
|
|
7
|
+
Project-URL: Issues, https://github.com/troyan-dy/matchapi/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/troyan-dy/matchapi/blob/master/CHANGELOG.md
|
|
9
|
+
Author: Dmitriy Troyan
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: asgi,framework,http,match,microservice,pattern-matching,web,webhook
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Requires-Dist: uvicorn>=0.30
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# matchapi
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/matchapi/)
|
|
33
|
+
[](https://pypi.org/project/matchapi/)
|
|
34
|
+
[](https://github.com/troyan-dy/matchapi/actions/workflows/ci.yml)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
An HTTP service is one function. `match` replaces the router.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
# app.py
|
|
41
|
+
async def handle(req):
|
|
42
|
+
match req.route:
|
|
43
|
+
case "GET", "ping":
|
|
44
|
+
return "pong"
|
|
45
|
+
case "GET", "users", user_id:
|
|
46
|
+
return {"id": int(user_id), "name": "ann"}
|
|
47
|
+
case "POST", "users":
|
|
48
|
+
body = req.json()
|
|
49
|
+
return 201, {"id": 7, "name": body["name"]}
|
|
50
|
+
case _:
|
|
51
|
+
return 404
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```console
|
|
55
|
+
$ pip install matchapi
|
|
56
|
+
$ matchapi app
|
|
57
|
+
INFO: Started server process [62579]
|
|
58
|
+
INFO: Waiting for application startup.
|
|
59
|
+
INFO: Application startup complete.
|
|
60
|
+
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```console
|
|
64
|
+
$ curl localhost:8000/ping
|
|
65
|
+
pong
|
|
66
|
+
$ curl localhost:8000/users/42
|
|
67
|
+
{"id":42,"name":"ann"}
|
|
68
|
+
$ curl -i -X POST localhost:8000/users -d '{"name": "bob"}'
|
|
69
|
+
HTTP/1.1 201 Created
|
|
70
|
+
date: Mon, 12 Oct 2026 00:10:08 GMT
|
|
71
|
+
server: uvicorn
|
|
72
|
+
content-type: application/json
|
|
73
|
+
content-length: 21
|
|
74
|
+
|
|
75
|
+
{"id":7,"name":"bob"}
|
|
76
|
+
$ curl -i localhost:8000/nothing
|
|
77
|
+
HTTP/1.1 404 Not Found
|
|
78
|
+
date: Mon, 12 Oct 2026 00:10:08 GMT
|
|
79
|
+
server: uvicorn
|
|
80
|
+
content-type: text/plain; charset=utf-8
|
|
81
|
+
content-length: 9
|
|
82
|
+
|
|
83
|
+
Not Found
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
No decorators, no route table, no schema classes, no dependency markers. The file above does not even import
|
|
87
|
+
`matchapi`: it is a Python function that takes a request and returns a value, and it can be called like one.
|
|
88
|
+
|
|
89
|
+
- [Why](#why)
|
|
90
|
+
- [The model](#the-model)
|
|
91
|
+
- [One function](#one-function)
|
|
92
|
+
- [`req.route` is the subject of `match`](#reqroute-is-the-subject-of-match)
|
|
93
|
+
- [The request is plain data](#the-request-is-plain-data)
|
|
94
|
+
- [The return value is the response](#the-return-value-is-the-response)
|
|
95
|
+
- [Errors](#errors)
|
|
96
|
+
- [Sync or async, the same way](#sync-or-async-the-same-way)
|
|
97
|
+
- [Running](#running)
|
|
98
|
+
- [Testing](#testing)
|
|
99
|
+
- [Recipes](#recipes)
|
|
100
|
+
- [What matchapi does not do](#what-matchapi-does-not-do)
|
|
101
|
+
- [Design notes](#design-notes)
|
|
102
|
+
- [Development](#development)
|
|
103
|
+
|
|
104
|
+
## Why
|
|
105
|
+
|
|
106
|
+
Every Python web framework, from Flask to FastAPI, is a router with a decorator in front of it and a growing set of
|
|
107
|
+
concepts behind it: path templates, parameter sources, dependency markers, routers, middleware stacks, response
|
|
108
|
+
models. A service with three endpoints pays for all of it.
|
|
109
|
+
|
|
110
|
+
Python has had structural pattern matching since 3.10. A `match` over `(method, *segments)` is a router already:
|
|
111
|
+
it binds path parameters, it falls through to a default, it nests, and the language, not a library, defines how it
|
|
112
|
+
reads. matchapi is what is left of a framework once the router is gone: a `Request`, a `Response`, the rules that
|
|
113
|
+
turn a return value into one, and a command that serves a module.
|
|
114
|
+
|
|
115
|
+
## The model
|
|
116
|
+
|
|
117
|
+
### One function
|
|
118
|
+
|
|
119
|
+
The service is a function that takes a `Request` and returns something. That is the whole contract. A function is
|
|
120
|
+
easy to test, easy to compose, easy to move: two services in one process are a function that calls two others.
|
|
121
|
+
|
|
122
|
+
### `req.route` is the subject of `match`
|
|
123
|
+
|
|
124
|
+
`req.route` is a tuple of the method followed by the non-empty path segments: `GET /users/42` is
|
|
125
|
+
`("GET", "users", "42")`, and `GET /` is `("GET",)`. A flat tuple matches without nesting:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
match req.route:
|
|
129
|
+
case "GET", "users", user_id: # binds the segment
|
|
130
|
+
...
|
|
131
|
+
case "GET" | "HEAD", "health": # several methods
|
|
132
|
+
...
|
|
133
|
+
case _, "api", *rest: # a prefix: delegate the rest
|
|
134
|
+
return api(req, rest)
|
|
135
|
+
case _:
|
|
136
|
+
return 404
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Segments are strings, so a numeric id is `int(user_id)` in the handler; a `ValueError` there is a 500, so raise
|
|
140
|
+
`HTTPError(400)` yourself when the input is the client's fault. `req.method` and `req.path` stay available as
|
|
141
|
+
strings for the cases where a plain comparison reads better.
|
|
142
|
+
|
|
143
|
+
### The request is plain data
|
|
144
|
+
|
|
145
|
+
| Attribute | Type | Example for `POST /users/42?q=x` |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `method` | `str`, upper case | `"POST"` |
|
|
148
|
+
| `path` | `str` | `"/users/42"` |
|
|
149
|
+
| `route` | `tuple[str, ...]` | `("POST", "users", "42")` |
|
|
150
|
+
| `query` | `dict[str, str]`, last value wins | `{"q": "x"}` |
|
|
151
|
+
| `headers` | `dict[str, str]`, lower-case names | `{"content-type": "application/json"}` |
|
|
152
|
+
| `body` | `bytes`, read in full before the call | `b'{"name": "bob"}'` |
|
|
153
|
+
| `json()` | the body as JSON; a broken body is a 400 | `{"name": "bob"}` |
|
|
154
|
+
|
|
155
|
+
There is no `scope` or stream underneath: the body is read before the handler runs, so a sync handler needs no
|
|
156
|
+
`await`. `Request("POST", "/users", body=b"...")`
|
|
157
|
+
builds one by hand, which is how a test calls the handler; `query` takes the raw string or a dict.
|
|
158
|
+
|
|
159
|
+
### The return value is the response
|
|
160
|
+
|
|
161
|
+
| Return | Response |
|
|
162
|
+
|---|---|
|
|
163
|
+
| `dict` or `list` | 200, `application/json` |
|
|
164
|
+
| `str` | 200, `text/plain; charset=utf-8` |
|
|
165
|
+
| `bytes` | 200, `application/octet-stream` |
|
|
166
|
+
| `int` (not `bool`) | that status, the standard phrase as text: `return 404` is `Not Found`; 1xx, 204 and 304 have no body |
|
|
167
|
+
| `None` | 204, no body |
|
|
168
|
+
| `(status, body)` | the body as above with that status: `return 201, {"id": 7}` |
|
|
169
|
+
| `(status, body, headers)` | the same with extra headers: `return 302, None, {"location": "/"}` |
|
|
170
|
+
| `Response(status, body, headers)` | as given; a `str` body is text, an iterable or async iterable of `bytes` streams |
|
|
171
|
+
|
|
172
|
+
Anything else, a status outside 100-999 or a header value outside latin-1 is a 500 with the reason in the log.
|
|
173
|
+
JSON is compact and keeps non-ASCII text as is: exactly what
|
|
174
|
+
`json.dumps(value, separators=(",", ":"), ensure_ascii=False)` gives. A `Response` passed in a tuple is copied,
|
|
175
|
+
so a module-level `NOT_FOUND = Response(404, "no")` is safe to reuse.
|
|
176
|
+
|
|
177
|
+
### Errors
|
|
178
|
+
|
|
179
|
+
`HTTPError(status, detail=None)` raised anywhere in the handler becomes a JSON response
|
|
180
|
+
`{"detail": "..."}` with that status; `detail` defaults to the standard phrase. `req.json()` raises it as a 400.
|
|
181
|
+
|
|
182
|
+
Any other exception is a 500 with the body `Internal Server Error`; the traceback goes to the `matchapi` logger
|
|
183
|
+
together with the method and path. Nothing of the exception reaches the client.
|
|
184
|
+
|
|
185
|
+
### Sync or async, the same way
|
|
186
|
+
|
|
187
|
+
`def handle(req)` and `async def handle(req)` are both fine. A sync handler runs in a thread pool, an async one in
|
|
188
|
+
the event loop; both are served by the same uvicorn. The kind of handler is read from the result of a call, not
|
|
189
|
+
from the function, so a `functools.partial`, a callable object or a compiled function all work, and a sync
|
|
190
|
+
function that returns a coroutine from some branches is awaited on those.
|
|
191
|
+
|
|
192
|
+
## Running
|
|
193
|
+
|
|
194
|
+
```console
|
|
195
|
+
$ matchapi app # app.py in the working directory, its function `handle`
|
|
196
|
+
$ matchapi app:serve # another function
|
|
197
|
+
$ matchapi api.users # a module in a package
|
|
198
|
+
$ matchapi app --host 0.0.0.0 --port 80 --workers 4
|
|
199
|
+
$ matchapi app --reload # development
|
|
200
|
+
$ python -m matchapi app # the same command
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Without `:function`, the command takes `handle`, and if there is none, the only public function the module defines.
|
|
204
|
+
The server is uvicorn, the one dependency of the package. `--workers` and `--reload` make every process load the
|
|
205
|
+
module itself.
|
|
206
|
+
|
|
207
|
+
For a server of your own, `matchapi.app(handle)` is an ASGI application:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
# asgi.py
|
|
211
|
+
import matchapi
|
|
212
|
+
from app import handle
|
|
213
|
+
|
|
214
|
+
app = matchapi.app(handle)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
```console
|
|
218
|
+
$ uvicorn asgi:app
|
|
219
|
+
$ granian --interface asgi asgi:app
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Testing
|
|
223
|
+
|
|
224
|
+
The handler is a function and `Request` is a constructor, so a test needs no client and no server:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
import asyncio
|
|
228
|
+
|
|
229
|
+
from matchapi import Request
|
|
230
|
+
|
|
231
|
+
from app import handle
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_user() -> None:
|
|
235
|
+
assert asyncio.run(handle(Request("GET", "/users/42"))) == {"id": 42, "name": "ann"}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_create() -> None:
|
|
239
|
+
result = asyncio.run(handle(Request("POST", "/users", body=b'{"name": "bob"}')))
|
|
240
|
+
assert result == (201, {"id": 7, "name": "bob"})
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_unknown() -> None:
|
|
244
|
+
assert asyncio.run(handle(Request("DELETE", "/users/42"))) == 404
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
With `pytest-asyncio` the tests are `async def` and await the handler directly. When the headers or the status
|
|
248
|
+
line matter, `matchapi.respond(value)` turns a return value into the `Response` the server would send.
|
|
249
|
+
|
|
250
|
+
## Recipes
|
|
251
|
+
|
|
252
|
+
**Authorization** is a line before the `match`:
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
async def handle(req):
|
|
256
|
+
if req.headers.get("authorization") != f"Bearer {TOKEN}":
|
|
257
|
+
return 401
|
|
258
|
+
match req.route:
|
|
259
|
+
...
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
**Two services in one process** are two functions and a prefix:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
async def handle(req):
|
|
266
|
+
match req.route:
|
|
267
|
+
case _, "users", *rest:
|
|
268
|
+
return await users(req, rest)
|
|
269
|
+
case _, "orders", *rest:
|
|
270
|
+
return await orders(req, rest)
|
|
271
|
+
case _:
|
|
272
|
+
return 404
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Validation** is whatever you like; a dataclass is enough for a simple body:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from dataclasses import dataclass
|
|
279
|
+
|
|
280
|
+
from matchapi import HTTPError
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass
|
|
284
|
+
class NewUser:
|
|
285
|
+
name: str
|
|
286
|
+
age: int = 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def handle(req):
|
|
290
|
+
match req.route:
|
|
291
|
+
case "POST", "users":
|
|
292
|
+
try:
|
|
293
|
+
user = NewUser(**req.json())
|
|
294
|
+
except TypeError as error:
|
|
295
|
+
raise HTTPError(400, str(error)) from error
|
|
296
|
+
return 201, {"name": user.name}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**A redirect, a custom content type, a stream:**
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
return 302, None, {"location": "/login"}
|
|
303
|
+
return Response(200, "<h1>hi</h1>", {"content-type": "text/html"})
|
|
304
|
+
return Response(200, (chunk for chunk in lines()))
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## What matchapi does not do
|
|
308
|
+
|
|
309
|
+
No router or decorators, no middleware stack, no parameter conversion from type hints, no OpenAPI, no cookies or
|
|
310
|
+
sessions, no templates, no static files, no websockets, no streaming request bodies, no WSGI. Each of these is
|
|
311
|
+
either a few lines in the handler or a job for an ASGI middleware wrapped around `matchapi.app(handle)`.
|
|
312
|
+
|
|
313
|
+
The line is deliberate. The framework fits in one file of about 300 lines that reads in half an hour, and the
|
|
314
|
+
handler stays a function that works without it.
|
|
315
|
+
|
|
316
|
+
## Design notes
|
|
317
|
+
|
|
318
|
+
The decisions and the alternatives that were rejected are in [docs/design.md](docs/design.md): why the router
|
|
319
|
+
went instead of the signature magic, why there is a command rather than an `app = ...` line, why the body is read
|
|
320
|
+
before the call, why there is only ASGI, and why there is no compiled core.
|
|
321
|
+
|
|
322
|
+
## Development
|
|
323
|
+
|
|
324
|
+
```console
|
|
325
|
+
$ uv sync
|
|
326
|
+
$ uv run pytest --cov
|
|
327
|
+
$ uv run ruff check . && uv run ruff format --check . && uv run mypy
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Coverage is kept at 100%. A pull request bumps the version in `pyproject.toml` and adds a `CHANGELOG.md` entry;
|
|
331
|
+
a merge into `master` publishes that version to PyPI and tags it.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
matchapi/__init__.py,sha256=4x8kouFApWkvKaqZ6qNs4spkQ0B0lP9n9NuHp_1v-EI,12213
|
|
2
|
+
matchapi/__main__.py,sha256=qukUn1fbAwg4IBxehKxx_tqJfAq5vyylgnRuKuXV2fY,34
|
|
3
|
+
matchapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
matchapi-0.1.0.dist-info/METADATA,sha256=kfF_XJKX4iC3ZYyDOPhM6JqgL3EYRoQ1dnVD2gJu7Yg,12137
|
|
5
|
+
matchapi-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
matchapi-0.1.0.dist-info/entry_points.txt,sha256=KvkgH0Bj32cT_j41aVrrU8Eo2hcPdWNq41Ui3aGSSt8,43
|
|
7
|
+
matchapi-0.1.0.dist-info/licenses/LICENSE,sha256=kYDh-xs92donPCS9wWWttVjpfOUekM4zXqYqnYTykI0,1071
|
|
8
|
+
matchapi-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmitriy Troyan
|
|
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.
|