frontage-api 0.14.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.
- frontage_api/__init__.py +697 -0
- frontage_api/_binary.py +105 -0
- frontage_api/client.py +252 -0
- frontage_api/cors.py +52 -0
- frontage_api/depends.py +92 -0
- frontage_api/docs.py +196 -0
- frontage_api/openapi.py +232 -0
- frontage_api/routing.py +115 -0
- frontage_api/streaming.py +156 -0
- frontage_api/testing.py +85 -0
- frontage_api-0.14.0.dist-info/METADATA +90 -0
- frontage_api-0.14.0.dist-info/RECORD +16 -0
- frontage_api-0.14.0.dist-info/WHEEL +4 -0
- frontage_api-0.14.0.dist-info/entry_points.txt +2 -0
- frontage_api-0.14.0.dist-info/licenses/LICENSE +202 -0
- frontage_api-0.14.0.dist-info/licenses/NOTICE +11 -0
frontage_api/__init__.py
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
"""frontage-api: FastAPI's shape, on axum, on frontage's own Python runtime.
|
|
2
|
+
|
|
3
|
+
`API.md` at the repository root is the design. This is §6.2, the surface: an `App`, method
|
|
4
|
+
decorators, arguments built from a route's spec, `HTTPError`, and the response rules.
|
|
5
|
+
|
|
6
|
+
from frontage_api import App, HTTPError
|
|
7
|
+
from frontage.schema import record, text, integer
|
|
8
|
+
|
|
9
|
+
app = App()
|
|
10
|
+
Trip = record(("id", integer(ge=0)), ("note", text()))
|
|
11
|
+
|
|
12
|
+
@app.get("/trips/{trip_id}", path={"trip_id": int})
|
|
13
|
+
async def trip(trip_id):
|
|
14
|
+
row = TRIPS.get(trip_id)
|
|
15
|
+
if row is None:
|
|
16
|
+
raise HTTPError(404, "no such trip")
|
|
17
|
+
return row # a dict answers as JSON
|
|
18
|
+
|
|
19
|
+
@app.post("/trips", body=Trip)
|
|
20
|
+
async def create(body): # validated, coerced, 422 with the field errors
|
|
21
|
+
return {"id": store(body)}
|
|
22
|
+
|
|
23
|
+
**A route declares its types in the decorator, and that is a runtime constraint, not a
|
|
24
|
+
preference.** This runtime parses annotations and discards them — `def f(x: Undefined)` does
|
|
25
|
+
not even raise — so there is no `__annotations__` to read and nothing to build a contract
|
|
26
|
+
from. `API.md` §6.2 has the compiler change that adds them; when it lands, an annotation
|
|
27
|
+
becomes the preferred spelling and fills in exactly the same spec this file already takes, so
|
|
28
|
+
nothing here changes shape.
|
|
29
|
+
|
|
30
|
+
`frontage_api.client` is the other direction: `_http` over reqwest, for a handler that has to
|
|
31
|
+
talk to something else — a `Client`, and a response whose body can arrive in pieces.
|
|
32
|
+
|
|
33
|
+
**One entry, one crossing.** `App.handle` is the whole of what the server calls per request
|
|
34
|
+
(§4.4). Everything above — matching, conversion, validation, the response rules — is Python
|
|
35
|
+
on this side of that one call.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
|
|
40
|
+
from . import depends as _depends
|
|
41
|
+
from .cors import Cors
|
|
42
|
+
from .depends import Depends
|
|
43
|
+
from .routing import Route, Router
|
|
44
|
+
from .streaming import SSE_DONE, Stream, sse
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"SSE_DONE",
|
|
48
|
+
"App",
|
|
49
|
+
"Cors",
|
|
50
|
+
"Depends",
|
|
51
|
+
"HTTPError",
|
|
52
|
+
"Response",
|
|
53
|
+
"Stream",
|
|
54
|
+
"json_response",
|
|
55
|
+
"sse",
|
|
56
|
+
"text_response",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
|
|
60
|
+
|
|
61
|
+
#: Names the request itself supplies, which an annotation must not turn into a query
|
|
62
|
+
#: parameter. `headers: Headers` should read as documentation, not as `?headers=`.
|
|
63
|
+
RESERVED = ("headers", "scope", "path_params")
|
|
64
|
+
|
|
65
|
+
JSON_TYPE = "application/json"
|
|
66
|
+
TEXT_TYPE = "text/plain; charset=utf-8"
|
|
67
|
+
BYTES_TYPE = "application/octet-stream"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HTTPError(Exception):
|
|
71
|
+
"""An answer, raised. `detail` is what the body's `detail` field says."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, status, detail=None, headers=None):
|
|
74
|
+
Exception.__init__(self, detail or ("HTTP " + str(status)))
|
|
75
|
+
self.status = status
|
|
76
|
+
self.detail = detail if detail is not None else _reason(status)
|
|
77
|
+
self.headers = headers or []
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
REASONS = {
|
|
81
|
+
400: "bad request",
|
|
82
|
+
401: "unauthorized",
|
|
83
|
+
403: "forbidden",
|
|
84
|
+
404: "not found",
|
|
85
|
+
405: "method not allowed",
|
|
86
|
+
409: "conflict",
|
|
87
|
+
413: "payload too large",
|
|
88
|
+
415: "unsupported media type",
|
|
89
|
+
422: "unprocessable entity",
|
|
90
|
+
429: "too many requests",
|
|
91
|
+
500: "internal server error",
|
|
92
|
+
503: "service unavailable",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _reason(status):
|
|
97
|
+
return REASONS.get(status, "error")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Response:
|
|
101
|
+
"""A body with a status, headers and a content type, when the shorthands are not enough."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, body, status=200, headers=None, media_type=None):
|
|
104
|
+
if isinstance(body, str):
|
|
105
|
+
self.body = body.encode("utf-8")
|
|
106
|
+
media_type = media_type or TEXT_TYPE
|
|
107
|
+
elif isinstance(body, bytes):
|
|
108
|
+
self.body = body
|
|
109
|
+
media_type = media_type or BYTES_TYPE
|
|
110
|
+
else:
|
|
111
|
+
self.body = json.dumps(body).encode("utf-8")
|
|
112
|
+
media_type = media_type or JSON_TYPE
|
|
113
|
+
self.status = status
|
|
114
|
+
self.headers = list(headers or [])
|
|
115
|
+
self.media_type = media_type
|
|
116
|
+
|
|
117
|
+
def parts(self):
|
|
118
|
+
headers = [("content-type", self.media_type)]
|
|
119
|
+
headers.extend(self.headers)
|
|
120
|
+
return self.status, headers, self.body
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def json_response(data, status=200, headers=None):
|
|
124
|
+
return Response(data, status=status, headers=headers, media_type=JSON_TYPE)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def text_response(body, status=200, headers=None):
|
|
128
|
+
return Response(body, status=status, headers=headers, media_type=TEXT_TYPE)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _awaitable(value):
|
|
132
|
+
"""Is this a thing to `await`?
|
|
133
|
+
|
|
134
|
+
⚠ **`hasattr(x, "send")` is not the test**, though it reads like one: a plain generator
|
|
135
|
+
has `send` too, so a generator dependency was being awaited instead of being stepped, and
|
|
136
|
+
a handler that returned a generator would have gone the same way. `__await__` is on a
|
|
137
|
+
coroutine and not on a generator, on CPython and on this runtime alike.
|
|
138
|
+
"""
|
|
139
|
+
return hasattr(value, "__await__")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _params_of(handler):
|
|
143
|
+
"""The names a handler takes. `co_varnames` is parameters first, `argcount` of them —
|
|
144
|
+
the runtime has no `inspect`, and this is the whole of what it would be used for."""
|
|
145
|
+
code = getattr(handler, "__code__", None)
|
|
146
|
+
if code is None:
|
|
147
|
+
return ()
|
|
148
|
+
names = getattr(code, "co_varnames", ())
|
|
149
|
+
count = getattr(code, "co_argcount", len(names))
|
|
150
|
+
return tuple(names[:count])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
#: What an annotation's text may name, when the route does not say otherwise. Deliberately
|
|
154
|
+
#: short: this is a router, not a type system, and anything else has to be declared.
|
|
155
|
+
BUILTIN_TYPES = {
|
|
156
|
+
"int": int,
|
|
157
|
+
"float": float,
|
|
158
|
+
"str": str,
|
|
159
|
+
"bool": bool,
|
|
160
|
+
"bytes": bytes,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _annotations_of(handler, namespace):
|
|
165
|
+
"""A route's spec, read from the handler's signature.
|
|
166
|
+
|
|
167
|
+
⚠ **The values are source text, not objects.** This runtime stores an annotation's
|
|
168
|
+
spelling and never evaluates it (`rust/README.md`), so `int` arrives as `"int"` and a
|
|
169
|
+
schema arrives as the name it was bound to — which is why `namespace` is the module the
|
|
170
|
+
handler came from. That also means an annotation this cannot resolve is *not* an error:
|
|
171
|
+
it is simply a parameter the route does not bind, exactly as before annotations existed.
|
|
172
|
+
"""
|
|
173
|
+
out = {}
|
|
174
|
+
for name, value in getattr(handler, "__annotations__", {}).items():
|
|
175
|
+
if name == "return":
|
|
176
|
+
continue
|
|
177
|
+
kind = _resolve(value, namespace)
|
|
178
|
+
if kind is not None:
|
|
179
|
+
out[name] = kind
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _resolve(value, namespace):
|
|
184
|
+
"""One annotation to a converter, whichever runtime wrote it. `None` in, `None` out: a
|
|
185
|
+
handler with no return annotation is the ordinary case, not a missing one.
|
|
186
|
+
|
|
187
|
+
⚠ **Both shapes have to work.** On frontage's runtime an annotation is its *source text*
|
|
188
|
+
(`rust/README.md`), so `int` arrives as `"int"`; on CPython it is the object itself,
|
|
189
|
+
because this package is written and tested there. Handling only strings would crash every
|
|
190
|
+
annotated handler under pytest, which is exactly where they are first written.
|
|
191
|
+
"""
|
|
192
|
+
if value is None:
|
|
193
|
+
return None
|
|
194
|
+
if not isinstance(value, str):
|
|
195
|
+
return value if (callable(value) or hasattr(value, "parse")) else None
|
|
196
|
+
text = value.strip()
|
|
197
|
+
if len(text) > 1 and text[0] in "\"'" and text[-1] == text[0]:
|
|
198
|
+
text = text[1:-1].strip() # a string annotation: what it says, not its quotes
|
|
199
|
+
kind = BUILTIN_TYPES.get(text)
|
|
200
|
+
if kind is None:
|
|
201
|
+
kind = namespace.get(text)
|
|
202
|
+
return kind
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _namespace_of(handler):
|
|
206
|
+
globals_ = getattr(handler, "__globals__", None)
|
|
207
|
+
if isinstance(globals_, dict):
|
|
208
|
+
return globals_
|
|
209
|
+
return {}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _convert(value, kind, where, name, errors):
|
|
213
|
+
"""One raw string through one converter. A `frontage.schema` type validates and coerces;
|
|
214
|
+
a plain callable (`int`, `float`, `str`) converts; anything else is passed through."""
|
|
215
|
+
parse = getattr(kind, "parse", None)
|
|
216
|
+
if parse is not None:
|
|
217
|
+
try:
|
|
218
|
+
return parse(value, coerce=True)
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
for path, message in getattr(exc, "errors", [("$", str(exc))]):
|
|
221
|
+
errors.append((where + "." + name + path.lstrip("$"), message))
|
|
222
|
+
return None
|
|
223
|
+
if kind is bool:
|
|
224
|
+
low = value.lower() if isinstance(value, str) else value
|
|
225
|
+
if low in (True, "1", "true", "yes", "on"):
|
|
226
|
+
return True
|
|
227
|
+
if low in (False, "0", "false", "no", "off", ""):
|
|
228
|
+
return False
|
|
229
|
+
errors.append((where + "." + name, "not a boolean"))
|
|
230
|
+
return None
|
|
231
|
+
if callable(kind):
|
|
232
|
+
try:
|
|
233
|
+
return kind(value)
|
|
234
|
+
except Exception:
|
|
235
|
+
errors.append((where + "." + name, "not a " + getattr(kind, "__name__", str(kind))))
|
|
236
|
+
return None
|
|
237
|
+
return value
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _path_names(path):
|
|
241
|
+
from .routing import compile_path
|
|
242
|
+
|
|
243
|
+
return compile_path(path)[1]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class Headers:
|
|
247
|
+
"""The request's headers, read by lowercase name. A list of pairs underneath, because a
|
|
248
|
+
header may legitimately repeat and `get_all` is what a `set-cookie` reader needs."""
|
|
249
|
+
|
|
250
|
+
def __init__(self, pairs):
|
|
251
|
+
self.pairs = list(pairs or [])
|
|
252
|
+
|
|
253
|
+
def get(self, name, default=None):
|
|
254
|
+
name = name.lower()
|
|
255
|
+
for key, value in self.pairs:
|
|
256
|
+
if key.lower() == name:
|
|
257
|
+
return value
|
|
258
|
+
return default
|
|
259
|
+
|
|
260
|
+
def get_all(self, name):
|
|
261
|
+
name = name.lower()
|
|
262
|
+
return [value for key, value in self.pairs if key.lower() == name]
|
|
263
|
+
|
|
264
|
+
def __contains__(self, name):
|
|
265
|
+
return self.get(name) is not None
|
|
266
|
+
|
|
267
|
+
def __iter__(self):
|
|
268
|
+
return iter(self.pairs)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def parse_query(query_string):
|
|
272
|
+
"""`a=1&b=two` to a dict. Last one wins, which is what a form does; `%` escapes and `+`
|
|
273
|
+
are decoded, because a query parameter that is a sentence is normal."""
|
|
274
|
+
out = {}
|
|
275
|
+
if not query_string:
|
|
276
|
+
return out
|
|
277
|
+
for pair in query_string.split("&"):
|
|
278
|
+
if not pair:
|
|
279
|
+
continue
|
|
280
|
+
if "=" in pair:
|
|
281
|
+
key, _, value = pair.partition("=")
|
|
282
|
+
else:
|
|
283
|
+
key, value = pair, ""
|
|
284
|
+
out[_unquote(key)] = _unquote(value)
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _unquote(s):
|
|
289
|
+
s = s.replace("+", " ")
|
|
290
|
+
if "%" not in s:
|
|
291
|
+
return s
|
|
292
|
+
out = []
|
|
293
|
+
i = 0
|
|
294
|
+
while i < len(s):
|
|
295
|
+
if s[i] == "%" and i + 2 < len(s) + 1:
|
|
296
|
+
try:
|
|
297
|
+
out.append(chr(int(s[i + 1 : i + 3], 16)))
|
|
298
|
+
i += 3
|
|
299
|
+
continue
|
|
300
|
+
except ValueError:
|
|
301
|
+
pass
|
|
302
|
+
out.append(s[i])
|
|
303
|
+
i += 1
|
|
304
|
+
return "".join(out)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class App:
|
|
308
|
+
"""The routes, and the one entry the server calls."""
|
|
309
|
+
|
|
310
|
+
def __init__(
|
|
311
|
+
self,
|
|
312
|
+
title="frontage-api",
|
|
313
|
+
cors=None,
|
|
314
|
+
static=None,
|
|
315
|
+
version="0.1.0",
|
|
316
|
+
description=None,
|
|
317
|
+
docs="/docs",
|
|
318
|
+
openapi_url="/openapi.json",
|
|
319
|
+
):
|
|
320
|
+
self.title = title
|
|
321
|
+
self.version = version
|
|
322
|
+
self.description = description
|
|
323
|
+
self.router = Router()
|
|
324
|
+
self.cors = cors if isinstance(cors, Cors) or cors is None else Cors(cors)
|
|
325
|
+
# Read by the server at load: files are served by Rust, not by walking a directory
|
|
326
|
+
# through an interpreter. A page and its API from one process is the simple
|
|
327
|
+
# deployment and the one with no CORS in it.
|
|
328
|
+
self.static = static
|
|
329
|
+
self._startup = []
|
|
330
|
+
self._shutdown = []
|
|
331
|
+
self._document = None
|
|
332
|
+
self.docs_url = docs
|
|
333
|
+
self.openapi_url = openapi_url
|
|
334
|
+
if openapi_url:
|
|
335
|
+
self._serve_docs()
|
|
336
|
+
|
|
337
|
+
def openapi(self):
|
|
338
|
+
"""The document (`API.md` §6.3), built once and kept.
|
|
339
|
+
|
|
340
|
+
Once, because it is derived from routes that cannot change after startup — and
|
|
341
|
+
because building it walks every schema, which is not work to repeat per request.
|
|
342
|
+
"""
|
|
343
|
+
if self._document is None:
|
|
344
|
+
from .openapi import document
|
|
345
|
+
|
|
346
|
+
self._document = document(self, version=self.version, description=self.description)
|
|
347
|
+
return self._document
|
|
348
|
+
|
|
349
|
+
def _serve_docs(self):
|
|
350
|
+
"""`/openapi.json` and `/docs`.
|
|
351
|
+
|
|
352
|
+
⚠ These are declared **first**, and routes match in declaration order, so a route of
|
|
353
|
+
your own at either path would never run. `App(docs=None, openapi_url=None)` gives the
|
|
354
|
+
paths back; that is a clearer rule than quietly stepping aside, and it is the one
|
|
355
|
+
FastAPI has.
|
|
356
|
+
"""
|
|
357
|
+
spec = self.openapi_url
|
|
358
|
+
|
|
359
|
+
@self.route("GET", spec, schema=False)
|
|
360
|
+
async def openapi_json():
|
|
361
|
+
return json_response(self.openapi())
|
|
362
|
+
|
|
363
|
+
if self.docs_url:
|
|
364
|
+
from .docs import page
|
|
365
|
+
|
|
366
|
+
html = page(spec, self.title)
|
|
367
|
+
|
|
368
|
+
@self.route("GET", self.docs_url, schema=False)
|
|
369
|
+
async def docs_page():
|
|
370
|
+
return Response(html, media_type="text/html; charset=utf-8")
|
|
371
|
+
|
|
372
|
+
def on_startup(self, fn):
|
|
373
|
+
"""Run once per **worker**, which is the part to hold on to: there is one interpreter
|
|
374
|
+
per thread, so this runs N times per process. Anything that must happen once for the
|
|
375
|
+
process belongs outside the app."""
|
|
376
|
+
self._startup.append(fn)
|
|
377
|
+
return fn
|
|
378
|
+
|
|
379
|
+
def on_shutdown(self, fn):
|
|
380
|
+
self._shutdown.append(fn)
|
|
381
|
+
return fn
|
|
382
|
+
|
|
383
|
+
async def startup(self):
|
|
384
|
+
for fn in self._startup:
|
|
385
|
+
result = fn()
|
|
386
|
+
if _awaitable(result):
|
|
387
|
+
await result
|
|
388
|
+
|
|
389
|
+
async def shutdown(self):
|
|
390
|
+
for fn in reversed(self._shutdown):
|
|
391
|
+
result = fn()
|
|
392
|
+
if _awaitable(result):
|
|
393
|
+
await result
|
|
394
|
+
|
|
395
|
+
def route(
|
|
396
|
+
self,
|
|
397
|
+
method,
|
|
398
|
+
path,
|
|
399
|
+
path_types=None,
|
|
400
|
+
query=None,
|
|
401
|
+
body=None,
|
|
402
|
+
needs=None,
|
|
403
|
+
summary=None,
|
|
404
|
+
description=None,
|
|
405
|
+
tags=None,
|
|
406
|
+
schema=True,
|
|
407
|
+
):
|
|
408
|
+
"""One route. `summary`, `description` and `tags` are what `/docs` shows, and
|
|
409
|
+
`schema=False` keeps the route out of the document altogether.
|
|
410
|
+
|
|
411
|
+
With neither, the handler's docstring is the prose: its first line is the summary
|
|
412
|
+
and the rest is the description. `summary=` is for a route whose docstring says
|
|
413
|
+
something to the next maintainer rather than to a reader of the API.
|
|
414
|
+
"""
|
|
415
|
+
method = method.upper()
|
|
416
|
+
if method not in METHODS:
|
|
417
|
+
raise ValueError("not a method: " + repr(method))
|
|
418
|
+
|
|
419
|
+
def decorate(handler):
|
|
420
|
+
params = _params_of(handler)
|
|
421
|
+
namespace = _namespace_of(handler)
|
|
422
|
+
declared = _annotations_of(handler, namespace)
|
|
423
|
+
# The decorator wins where both speak, so a route can always override what a
|
|
424
|
+
# signature says without editing the signature.
|
|
425
|
+
names = _path_names(path)
|
|
426
|
+
spec = {
|
|
427
|
+
"path": {n: declared[n] for n in names if n in declared},
|
|
428
|
+
"query": {},
|
|
429
|
+
"body": body,
|
|
430
|
+
"needs": needs or {},
|
|
431
|
+
"params": params,
|
|
432
|
+
"summary": summary,
|
|
433
|
+
"description": description,
|
|
434
|
+
"tags": tags,
|
|
435
|
+
"schema": schema,
|
|
436
|
+
"returns": _resolve(getattr(handler, "__annotations__", {}).get("return"), namespace),
|
|
437
|
+
}
|
|
438
|
+
for name, kind in declared.items():
|
|
439
|
+
if name in names or name in spec["needs"] or name in RESERVED:
|
|
440
|
+
continue
|
|
441
|
+
if name == "body":
|
|
442
|
+
if spec["body"] is None:
|
|
443
|
+
spec["body"] = kind
|
|
444
|
+
continue
|
|
445
|
+
spec["query"][name] = kind
|
|
446
|
+
spec["path"].update(path_types or {})
|
|
447
|
+
spec["query"].update(query or {})
|
|
448
|
+
self.router.add(Route(method, path, handler, spec))
|
|
449
|
+
return handler
|
|
450
|
+
|
|
451
|
+
return decorate
|
|
452
|
+
|
|
453
|
+
# -- one decorator per method ---------------------------------------------------------
|
|
454
|
+
#
|
|
455
|
+
# Written out rather than installed with `setattr` in a loop, which is what they were
|
|
456
|
+
# until the arguments started to matter. A dynamic attribute is invisible to a type
|
|
457
|
+
# checker and to an editor alike: `ty` reported every `@app.get` in every test as an
|
|
458
|
+
# error — 34 of the repository's 40 diagnostics — and nobody could see that `summary`
|
|
459
|
+
# and `tags` existed, which is exactly the half of `/docs` that has to be discoverable.
|
|
460
|
+
#
|
|
461
|
+
# The duplication is real and it is guarded: `test_api.py` asserts every one of these
|
|
462
|
+
# takes precisely `route`'s arguments, so adding one to `route` and forgetting these
|
|
463
|
+
# fails a test rather than silently dropping it on seven decorators.
|
|
464
|
+
|
|
465
|
+
def get(
|
|
466
|
+
self,
|
|
467
|
+
path,
|
|
468
|
+
path_types=None,
|
|
469
|
+
query=None,
|
|
470
|
+
body=None,
|
|
471
|
+
needs=None,
|
|
472
|
+
summary=None,
|
|
473
|
+
description=None,
|
|
474
|
+
tags=None,
|
|
475
|
+
schema=True,
|
|
476
|
+
):
|
|
477
|
+
"""`@app.get(path)`. The arguments are `route`'s, and a test says so."""
|
|
478
|
+
return self.route("GET", path, path_types, query, body, needs, summary, description, tags, schema)
|
|
479
|
+
|
|
480
|
+
def post(
|
|
481
|
+
self,
|
|
482
|
+
path,
|
|
483
|
+
path_types=None,
|
|
484
|
+
query=None,
|
|
485
|
+
body=None,
|
|
486
|
+
needs=None,
|
|
487
|
+
summary=None,
|
|
488
|
+
description=None,
|
|
489
|
+
tags=None,
|
|
490
|
+
schema=True,
|
|
491
|
+
):
|
|
492
|
+
"""`@app.post(path)`. The arguments are `route`'s, and a test says so."""
|
|
493
|
+
return self.route("POST", path, path_types, query, body, needs, summary, description, tags, schema)
|
|
494
|
+
|
|
495
|
+
def put(
|
|
496
|
+
self,
|
|
497
|
+
path,
|
|
498
|
+
path_types=None,
|
|
499
|
+
query=None,
|
|
500
|
+
body=None,
|
|
501
|
+
needs=None,
|
|
502
|
+
summary=None,
|
|
503
|
+
description=None,
|
|
504
|
+
tags=None,
|
|
505
|
+
schema=True,
|
|
506
|
+
):
|
|
507
|
+
"""`@app.put(path)`. The arguments are `route`'s, and a test says so."""
|
|
508
|
+
return self.route("PUT", path, path_types, query, body, needs, summary, description, tags, schema)
|
|
509
|
+
|
|
510
|
+
def patch(
|
|
511
|
+
self,
|
|
512
|
+
path,
|
|
513
|
+
path_types=None,
|
|
514
|
+
query=None,
|
|
515
|
+
body=None,
|
|
516
|
+
needs=None,
|
|
517
|
+
summary=None,
|
|
518
|
+
description=None,
|
|
519
|
+
tags=None,
|
|
520
|
+
schema=True,
|
|
521
|
+
):
|
|
522
|
+
"""`@app.patch(path)`. The arguments are `route`'s, and a test says so."""
|
|
523
|
+
return self.route("PATCH", path, path_types, query, body, needs, summary, description, tags, schema)
|
|
524
|
+
|
|
525
|
+
def delete(
|
|
526
|
+
self,
|
|
527
|
+
path,
|
|
528
|
+
path_types=None,
|
|
529
|
+
query=None,
|
|
530
|
+
body=None,
|
|
531
|
+
needs=None,
|
|
532
|
+
summary=None,
|
|
533
|
+
description=None,
|
|
534
|
+
tags=None,
|
|
535
|
+
schema=True,
|
|
536
|
+
):
|
|
537
|
+
"""`@app.delete(path)`. The arguments are `route`'s, and a test says so."""
|
|
538
|
+
return self.route("DELETE", path, path_types, query, body, needs, summary, description, tags, schema)
|
|
539
|
+
|
|
540
|
+
def head(
|
|
541
|
+
self,
|
|
542
|
+
path,
|
|
543
|
+
path_types=None,
|
|
544
|
+
query=None,
|
|
545
|
+
body=None,
|
|
546
|
+
needs=None,
|
|
547
|
+
summary=None,
|
|
548
|
+
description=None,
|
|
549
|
+
tags=None,
|
|
550
|
+
schema=True,
|
|
551
|
+
):
|
|
552
|
+
"""`@app.head(path)`. The arguments are `route`'s, and a test says so."""
|
|
553
|
+
return self.route("HEAD", path, path_types, query, body, needs, summary, description, tags, schema)
|
|
554
|
+
|
|
555
|
+
def options(
|
|
556
|
+
self,
|
|
557
|
+
path,
|
|
558
|
+
path_types=None,
|
|
559
|
+
query=None,
|
|
560
|
+
body=None,
|
|
561
|
+
needs=None,
|
|
562
|
+
summary=None,
|
|
563
|
+
description=None,
|
|
564
|
+
tags=None,
|
|
565
|
+
schema=True,
|
|
566
|
+
):
|
|
567
|
+
"""`@app.options(path)`. The arguments are `route`'s, and a test says so."""
|
|
568
|
+
return self.route("OPTIONS", path, path_types, query, body, needs, summary, description, tags, schema)
|
|
569
|
+
|
|
570
|
+
def cors_headers(self, origin):
|
|
571
|
+
"""What a *file* response should carry, asked for by the server.
|
|
572
|
+
|
|
573
|
+
A file is served by Rust and never reaches `handle`, so it would otherwise answer a
|
|
574
|
+
cross-origin `fetch` with no headers at all — which matters here, because the docs'
|
|
575
|
+
sandboxed runner sits in an opaque origin and frontage's own `_headers` file exists
|
|
576
|
+
for exactly this. Asked only when the request carried an `Origin`, so a same-origin
|
|
577
|
+
page pays nothing.
|
|
578
|
+
"""
|
|
579
|
+
if self.cors is None:
|
|
580
|
+
return []
|
|
581
|
+
return self.cors.headers_for(origin)
|
|
582
|
+
|
|
583
|
+
async def handle(self, scope):
|
|
584
|
+
"""One request in, `(status, headers, body)` out. The only thing the server calls."""
|
|
585
|
+
headers = Headers(scope.get("headers"))
|
|
586
|
+
origin = headers.get("origin")
|
|
587
|
+
finalizers = []
|
|
588
|
+
try:
|
|
589
|
+
answer = await self._handle(scope, headers, finalizers)
|
|
590
|
+
except HTTPError as exc:
|
|
591
|
+
answer = _problem(exc.status, exc.detail, exc.headers)
|
|
592
|
+
except Exception as exc: # a handler's own failure, not the caller's
|
|
593
|
+
answer = _problem(500, _reason(500) + ": " + str(exc))
|
|
594
|
+
problems = _depends.finish(finalizers)
|
|
595
|
+
for problem in problems:
|
|
596
|
+
_warn("a dependency failed while closing: " + str(problem))
|
|
597
|
+
if self.cors is not None:
|
|
598
|
+
status, out, body = answer
|
|
599
|
+
answer = (status, out + self.cors.headers_for(origin), body)
|
|
600
|
+
return answer
|
|
601
|
+
|
|
602
|
+
async def _handle(self, scope, headers, finalizers):
|
|
603
|
+
method = scope.get("method", "GET")
|
|
604
|
+
path = scope.get("path", "/")
|
|
605
|
+
route, params = self.router.find(method, path)
|
|
606
|
+
if route is None:
|
|
607
|
+
# A preflight asks about a route that exists under another method, so answer it
|
|
608
|
+
# from what that path *does* accept rather than from a fixed list.
|
|
609
|
+
if method == "OPTIONS" and self.cors is not None and params:
|
|
610
|
+
allowed = self.cors.headers_for(headers.get("origin"), preflight=True)
|
|
611
|
+
return 204, allowed + [("allow", ", ".join(sorted(params)))], b""
|
|
612
|
+
if params:
|
|
613
|
+
return _problem(405, "method not allowed", [("allow", ", ".join(sorted(params)))])
|
|
614
|
+
return _problem(404, "not found")
|
|
615
|
+
kwargs = await _bind(route, params, scope, headers, finalizers)
|
|
616
|
+
result = route.handler(**kwargs)
|
|
617
|
+
if _awaitable(result):
|
|
618
|
+
result = await result
|
|
619
|
+
return _respond(result)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
async def _bind(route, params, scope, headers, finalizers):
|
|
623
|
+
"""Path, query, body, headers and dependencies into the handler's parameter names."""
|
|
624
|
+
spec = route.spec
|
|
625
|
+
wanted = spec["params"]
|
|
626
|
+
errors = []
|
|
627
|
+
values = {"headers": headers, "scope": scope, "path_params": params}
|
|
628
|
+
for name, raw in params.items():
|
|
629
|
+
values[name] = _convert(raw, spec["path"].get(name, str), "path", name, errors)
|
|
630
|
+
if spec["query"]:
|
|
631
|
+
query = parse_query(scope.get("query_string", ""))
|
|
632
|
+
for name, kind in spec["query"].items():
|
|
633
|
+
if name in query:
|
|
634
|
+
values[name] = _convert(query[name], kind, "query", name, errors)
|
|
635
|
+
if spec["body"] is not None:
|
|
636
|
+
values["body"] = _body(scope, spec["body"], errors)
|
|
637
|
+
if errors:
|
|
638
|
+
raise HTTPError(422, [{"loc": where, "msg": message} for where, message in errors])
|
|
639
|
+
if spec["needs"]:
|
|
640
|
+
values.update(await _depends.resolve(spec["needs"], values, {}, finalizers))
|
|
641
|
+
return {name: values[name] for name in wanted if name in values}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _body(scope, kind, errors):
|
|
645
|
+
raw = scope.get("body", b"")
|
|
646
|
+
if kind is bytes:
|
|
647
|
+
# The body untouched, for a route that is not JSON: an upload, a webhook signature,
|
|
648
|
+
# a proxy. Declared rather than inferred, so nothing is parsed by accident.
|
|
649
|
+
return raw
|
|
650
|
+
if not raw:
|
|
651
|
+
payload = None
|
|
652
|
+
else:
|
|
653
|
+
try:
|
|
654
|
+
payload = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
|
|
655
|
+
except Exception:
|
|
656
|
+
raise HTTPError(400, "the body is not JSON") from None
|
|
657
|
+
if kind is True:
|
|
658
|
+
return payload
|
|
659
|
+
parse = getattr(kind, "parse", None)
|
|
660
|
+
if parse is None:
|
|
661
|
+
return payload
|
|
662
|
+
try:
|
|
663
|
+
return parse(payload)
|
|
664
|
+
except Exception as exc:
|
|
665
|
+
for path, message in getattr(exc, "errors", [("$", str(exc))]):
|
|
666
|
+
errors.append(("body" + path.lstrip("$"), message))
|
|
667
|
+
return None
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _respond(result):
|
|
671
|
+
if isinstance(result, Stream):
|
|
672
|
+
# The third element is not bytes, and that is the signal: the server pulls from it
|
|
673
|
+
# instead of writing it. `API.md` §6.2.
|
|
674
|
+
status, headers = result.parts()
|
|
675
|
+
return status, headers, result.chunks()
|
|
676
|
+
if isinstance(result, Response):
|
|
677
|
+
return result.parts()
|
|
678
|
+
if result is None:
|
|
679
|
+
return 204, [], b""
|
|
680
|
+
if isinstance(result, bytes):
|
|
681
|
+
return 200, [("content-type", BYTES_TYPE)], result
|
|
682
|
+
if isinstance(result, str):
|
|
683
|
+
return 200, [("content-type", TEXT_TYPE)], result.encode("utf-8")
|
|
684
|
+
return 200, [("content-type", JSON_TYPE)], json.dumps(result).encode("utf-8")
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _problem(status, detail, headers=None):
|
|
688
|
+
"""FastAPI's shape for an error body, because the fleet's pages already read it."""
|
|
689
|
+
body = json.dumps({"detail": detail}).encode("utf-8")
|
|
690
|
+
out = [("content-type", JSON_TYPE)]
|
|
691
|
+
out.extend(headers or [])
|
|
692
|
+
return status, out, body
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _warn(message):
|
|
696
|
+
"""Something worth saying that must not replace a correct answer."""
|
|
697
|
+
print("frontage-api: " + message)
|