matchapi 0.1.0__tar.gz

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.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ coverage.xml
12
+ htmlcov/
13
+ .DS_Store
14
+ .idea/
15
+ .vscode/
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and the project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-12
10
+
11
+ ### Added
12
+
13
+ - `Request`: method, path, `route` as `(method, *segments)`, query, headers, body and `json()`; built by hand in
14
+ tests.
15
+ - `Response`, `HTTPError` and `respond()`: the rules that turn a handler's return value into a response.
16
+ - `app(handle)`: the handler as an ASGI application; sync handlers run in the thread pool, async ones in the loop.
17
+ - The `matchapi` command: `matchapi app` serves the module's `handle` with uvicorn; `--host`, `--port`,
18
+ `--workers`, `--reload`.
19
+
20
+ [Unreleased]: https://github.com/troyan-dy/matchapi/compare/v0.1.0...HEAD
21
+ [0.1.0]: https://github.com/troyan-dy/matchapi/releases/tag/v0.1.0
matchapi-0.1.0/LICENSE ADDED
@@ -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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/matchapi)](https://pypi.org/project/matchapi/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/matchapi)](https://pypi.org/project/matchapi/)
34
+ [![CI](https://github.com/troyan-dy/matchapi/actions/workflows/ci.yml/badge.svg)](https://github.com/troyan-dy/matchapi/actions/workflows/ci.yml)
35
+ [![License](https://img.shields.io/pypi/l/matchapi)](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,302 @@
1
+ # matchapi
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/matchapi)](https://pypi.org/project/matchapi/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/matchapi)](https://pypi.org/project/matchapi/)
5
+ [![CI](https://github.com/troyan-dy/matchapi/actions/workflows/ci.yml/badge.svg)](https://github.com/troyan-dy/matchapi/actions/workflows/ci.yml)
6
+ [![License](https://img.shields.io/pypi/l/matchapi)](LICENSE)
7
+
8
+ An HTTP service is one function. `match` replaces the router.
9
+
10
+ ```python
11
+ # app.py
12
+ async def handle(req):
13
+ match req.route:
14
+ case "GET", "ping":
15
+ return "pong"
16
+ case "GET", "users", user_id:
17
+ return {"id": int(user_id), "name": "ann"}
18
+ case "POST", "users":
19
+ body = req.json()
20
+ return 201, {"id": 7, "name": body["name"]}
21
+ case _:
22
+ return 404
23
+ ```
24
+
25
+ ```console
26
+ $ pip install matchapi
27
+ $ matchapi app
28
+ INFO: Started server process [62579]
29
+ INFO: Waiting for application startup.
30
+ INFO: Application startup complete.
31
+ INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
32
+ ```
33
+
34
+ ```console
35
+ $ curl localhost:8000/ping
36
+ pong
37
+ $ curl localhost:8000/users/42
38
+ {"id":42,"name":"ann"}
39
+ $ curl -i -X POST localhost:8000/users -d '{"name": "bob"}'
40
+ HTTP/1.1 201 Created
41
+ date: Mon, 12 Oct 2026 00:10:08 GMT
42
+ server: uvicorn
43
+ content-type: application/json
44
+ content-length: 21
45
+
46
+ {"id":7,"name":"bob"}
47
+ $ curl -i localhost:8000/nothing
48
+ HTTP/1.1 404 Not Found
49
+ date: Mon, 12 Oct 2026 00:10:08 GMT
50
+ server: uvicorn
51
+ content-type: text/plain; charset=utf-8
52
+ content-length: 9
53
+
54
+ Not Found
55
+ ```
56
+
57
+ No decorators, no route table, no schema classes, no dependency markers. The file above does not even import
58
+ `matchapi`: it is a Python function that takes a request and returns a value, and it can be called like one.
59
+
60
+ - [Why](#why)
61
+ - [The model](#the-model)
62
+ - [One function](#one-function)
63
+ - [`req.route` is the subject of `match`](#reqroute-is-the-subject-of-match)
64
+ - [The request is plain data](#the-request-is-plain-data)
65
+ - [The return value is the response](#the-return-value-is-the-response)
66
+ - [Errors](#errors)
67
+ - [Sync or async, the same way](#sync-or-async-the-same-way)
68
+ - [Running](#running)
69
+ - [Testing](#testing)
70
+ - [Recipes](#recipes)
71
+ - [What matchapi does not do](#what-matchapi-does-not-do)
72
+ - [Design notes](#design-notes)
73
+ - [Development](#development)
74
+
75
+ ## Why
76
+
77
+ Every Python web framework, from Flask to FastAPI, is a router with a decorator in front of it and a growing set of
78
+ concepts behind it: path templates, parameter sources, dependency markers, routers, middleware stacks, response
79
+ models. A service with three endpoints pays for all of it.
80
+
81
+ Python has had structural pattern matching since 3.10. A `match` over `(method, *segments)` is a router already:
82
+ it binds path parameters, it falls through to a default, it nests, and the language, not a library, defines how it
83
+ reads. matchapi is what is left of a framework once the router is gone: a `Request`, a `Response`, the rules that
84
+ turn a return value into one, and a command that serves a module.
85
+
86
+ ## The model
87
+
88
+ ### One function
89
+
90
+ The service is a function that takes a `Request` and returns something. That is the whole contract. A function is
91
+ easy to test, easy to compose, easy to move: two services in one process are a function that calls two others.
92
+
93
+ ### `req.route` is the subject of `match`
94
+
95
+ `req.route` is a tuple of the method followed by the non-empty path segments: `GET /users/42` is
96
+ `("GET", "users", "42")`, and `GET /` is `("GET",)`. A flat tuple matches without nesting:
97
+
98
+ ```python
99
+ match req.route:
100
+ case "GET", "users", user_id: # binds the segment
101
+ ...
102
+ case "GET" | "HEAD", "health": # several methods
103
+ ...
104
+ case _, "api", *rest: # a prefix: delegate the rest
105
+ return api(req, rest)
106
+ case _:
107
+ return 404
108
+ ```
109
+
110
+ Segments are strings, so a numeric id is `int(user_id)` in the handler; a `ValueError` there is a 500, so raise
111
+ `HTTPError(400)` yourself when the input is the client's fault. `req.method` and `req.path` stay available as
112
+ strings for the cases where a plain comparison reads better.
113
+
114
+ ### The request is plain data
115
+
116
+ | Attribute | Type | Example for `POST /users/42?q=x` |
117
+ |---|---|---|
118
+ | `method` | `str`, upper case | `"POST"` |
119
+ | `path` | `str` | `"/users/42"` |
120
+ | `route` | `tuple[str, ...]` | `("POST", "users", "42")` |
121
+ | `query` | `dict[str, str]`, last value wins | `{"q": "x"}` |
122
+ | `headers` | `dict[str, str]`, lower-case names | `{"content-type": "application/json"}` |
123
+ | `body` | `bytes`, read in full before the call | `b'{"name": "bob"}'` |
124
+ | `json()` | the body as JSON; a broken body is a 400 | `{"name": "bob"}` |
125
+
126
+ There is no `scope` or stream underneath: the body is read before the handler runs, so a sync handler needs no
127
+ `await`. `Request("POST", "/users", body=b"...")`
128
+ builds one by hand, which is how a test calls the handler; `query` takes the raw string or a dict.
129
+
130
+ ### The return value is the response
131
+
132
+ | Return | Response |
133
+ |---|---|
134
+ | `dict` or `list` | 200, `application/json` |
135
+ | `str` | 200, `text/plain; charset=utf-8` |
136
+ | `bytes` | 200, `application/octet-stream` |
137
+ | `int` (not `bool`) | that status, the standard phrase as text: `return 404` is `Not Found`; 1xx, 204 and 304 have no body |
138
+ | `None` | 204, no body |
139
+ | `(status, body)` | the body as above with that status: `return 201, {"id": 7}` |
140
+ | `(status, body, headers)` | the same with extra headers: `return 302, None, {"location": "/"}` |
141
+ | `Response(status, body, headers)` | as given; a `str` body is text, an iterable or async iterable of `bytes` streams |
142
+
143
+ Anything else, a status outside 100-999 or a header value outside latin-1 is a 500 with the reason in the log.
144
+ JSON is compact and keeps non-ASCII text as is: exactly what
145
+ `json.dumps(value, separators=(",", ":"), ensure_ascii=False)` gives. A `Response` passed in a tuple is copied,
146
+ so a module-level `NOT_FOUND = Response(404, "no")` is safe to reuse.
147
+
148
+ ### Errors
149
+
150
+ `HTTPError(status, detail=None)` raised anywhere in the handler becomes a JSON response
151
+ `{"detail": "..."}` with that status; `detail` defaults to the standard phrase. `req.json()` raises it as a 400.
152
+
153
+ Any other exception is a 500 with the body `Internal Server Error`; the traceback goes to the `matchapi` logger
154
+ together with the method and path. Nothing of the exception reaches the client.
155
+
156
+ ### Sync or async, the same way
157
+
158
+ `def handle(req)` and `async def handle(req)` are both fine. A sync handler runs in a thread pool, an async one in
159
+ the event loop; both are served by the same uvicorn. The kind of handler is read from the result of a call, not
160
+ from the function, so a `functools.partial`, a callable object or a compiled function all work, and a sync
161
+ function that returns a coroutine from some branches is awaited on those.
162
+
163
+ ## Running
164
+
165
+ ```console
166
+ $ matchapi app # app.py in the working directory, its function `handle`
167
+ $ matchapi app:serve # another function
168
+ $ matchapi api.users # a module in a package
169
+ $ matchapi app --host 0.0.0.0 --port 80 --workers 4
170
+ $ matchapi app --reload # development
171
+ $ python -m matchapi app # the same command
172
+ ```
173
+
174
+ Without `:function`, the command takes `handle`, and if there is none, the only public function the module defines.
175
+ The server is uvicorn, the one dependency of the package. `--workers` and `--reload` make every process load the
176
+ module itself.
177
+
178
+ For a server of your own, `matchapi.app(handle)` is an ASGI application:
179
+
180
+ ```python
181
+ # asgi.py
182
+ import matchapi
183
+ from app import handle
184
+
185
+ app = matchapi.app(handle)
186
+ ```
187
+
188
+ ```console
189
+ $ uvicorn asgi:app
190
+ $ granian --interface asgi asgi:app
191
+ ```
192
+
193
+ ## Testing
194
+
195
+ The handler is a function and `Request` is a constructor, so a test needs no client and no server:
196
+
197
+ ```python
198
+ import asyncio
199
+
200
+ from matchapi import Request
201
+
202
+ from app import handle
203
+
204
+
205
+ def test_user() -> None:
206
+ assert asyncio.run(handle(Request("GET", "/users/42"))) == {"id": 42, "name": "ann"}
207
+
208
+
209
+ def test_create() -> None:
210
+ result = asyncio.run(handle(Request("POST", "/users", body=b'{"name": "bob"}')))
211
+ assert result == (201, {"id": 7, "name": "bob"})
212
+
213
+
214
+ def test_unknown() -> None:
215
+ assert asyncio.run(handle(Request("DELETE", "/users/42"))) == 404
216
+ ```
217
+
218
+ With `pytest-asyncio` the tests are `async def` and await the handler directly. When the headers or the status
219
+ line matter, `matchapi.respond(value)` turns a return value into the `Response` the server would send.
220
+
221
+ ## Recipes
222
+
223
+ **Authorization** is a line before the `match`:
224
+
225
+ ```python
226
+ async def handle(req):
227
+ if req.headers.get("authorization") != f"Bearer {TOKEN}":
228
+ return 401
229
+ match req.route:
230
+ ...
231
+ ```
232
+
233
+ **Two services in one process** are two functions and a prefix:
234
+
235
+ ```python
236
+ async def handle(req):
237
+ match req.route:
238
+ case _, "users", *rest:
239
+ return await users(req, rest)
240
+ case _, "orders", *rest:
241
+ return await orders(req, rest)
242
+ case _:
243
+ return 404
244
+ ```
245
+
246
+ **Validation** is whatever you like; a dataclass is enough for a simple body:
247
+
248
+ ```python
249
+ from dataclasses import dataclass
250
+
251
+ from matchapi import HTTPError
252
+
253
+
254
+ @dataclass
255
+ class NewUser:
256
+ name: str
257
+ age: int = 0
258
+
259
+
260
+ async def handle(req):
261
+ match req.route:
262
+ case "POST", "users":
263
+ try:
264
+ user = NewUser(**req.json())
265
+ except TypeError as error:
266
+ raise HTTPError(400, str(error)) from error
267
+ return 201, {"name": user.name}
268
+ ```
269
+
270
+ **A redirect, a custom content type, a stream:**
271
+
272
+ ```python
273
+ return 302, None, {"location": "/login"}
274
+ return Response(200, "<h1>hi</h1>", {"content-type": "text/html"})
275
+ return Response(200, (chunk for chunk in lines()))
276
+ ```
277
+
278
+ ## What matchapi does not do
279
+
280
+ No router or decorators, no middleware stack, no parameter conversion from type hints, no OpenAPI, no cookies or
281
+ sessions, no templates, no static files, no websockets, no streaming request bodies, no WSGI. Each of these is
282
+ either a few lines in the handler or a job for an ASGI middleware wrapped around `matchapi.app(handle)`.
283
+
284
+ The line is deliberate. The framework fits in one file of about 300 lines that reads in half an hour, and the
285
+ handler stays a function that works without it.
286
+
287
+ ## Design notes
288
+
289
+ The decisions and the alternatives that were rejected are in [docs/design.md](docs/design.md): why the router
290
+ went instead of the signature magic, why there is a command rather than an `app = ...` line, why the body is read
291
+ before the call, why there is only ASGI, and why there is no compiled core.
292
+
293
+ ## Development
294
+
295
+ ```console
296
+ $ uv sync
297
+ $ uv run pytest --cov
298
+ $ uv run ruff check . && uv run ruff format --check . && uv run mypy
299
+ ```
300
+
301
+ Coverage is kept at 100%. A pull request bumps the version in `pyproject.toml` and adds a `CHANGELOG.md` entry;
302
+ a merge into `master` publishes that version to PyPI and tags it.