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.
@@ -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)