htbp-sdk 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,10 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
4
+ .venv/
5
+ __pycache__/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ uv.lock
10
+ .DS_Store
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: htbp-sdk
3
+ Version: 0.1.0
4
+ Summary: HTBP (HTTP Tool Bridge Protocol) server SDK: expose functions as self-describing HTTP tool endpoints, FastAPI-style.
5
+ Project-URL: Repository, https://github.com/TokenRollAI/http-tool-bridge
6
+ Author: TokenRoll AI
7
+ License-Expression: MIT
8
+ Keywords: agent,asgi,htbp,http,sdk,tool
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: pydantic>=2.7
11
+ Provides-Extra: remote
12
+ Requires-Dist: httpx>=0.27; extra == 'remote'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # htbp-sdk
16
+
17
+ HTBP (HTTP Tool Bridge Protocol) server SDK for Python. Expose functions as
18
+ self-describing HTTP tool endpoints, FastAPI-style. Pure ASGI — runs under
19
+ uvicorn/hypercorn and mounts inside FastAPI or Starlette.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ pip install htbp-sdk # import name: htbp
25
+ pip install 'htbp-sdk[remote]' # + httpx, needed only for app.remote()
26
+ ```
27
+
28
+ ## Quickstart
29
+
30
+ ```python
31
+ from htbp import Htbp
32
+
33
+ app = Htbp(title="Math tools")
34
+
35
+ @app.tool(effect="read")
36
+ def add(a: int, b: int = 0) -> int:
37
+ """Add two integers."""
38
+ return a + b
39
+ ```
40
+
41
+ ```sh
42
+ uvicorn main:app
43
+ ```
44
+
45
+ Input schemas are derived from type hints via pydantic (FastAPI-style); a
46
+ single parameter annotated with a `BaseModel` subclass uses that model as the
47
+ body schema directly. Async functions are awaited; sync functions run in a
48
+ thread. A `ToolContext`-annotated parameter receives request context.
49
+
50
+ Every node answers `GET {path}/~help` (JSON by default, RFC-0001 text DSL with
51
+ `Accept: text/plain`); tools are invoked with `POST {path}` and respond with
52
+ `{"resource", "result"}`.
53
+
54
+ ## Cascading
55
+
56
+ ```python
57
+ docs = Htbp(title="Docs tools")
58
+
59
+ @docs.tool()
60
+ def search(q: str) -> dict:
61
+ return {"hits": [q]}
62
+
63
+ app.mount("/docs", docs) # local nesting
64
+ app.remote("/ext", "https://other.example.com/~help") # federate a remote HTBP server
65
+ ```
66
+
67
+ Remote nodes pass through `~help` for any sub-path and proxy calls; the inbound
68
+ `Authorization` header is never forwarded upstream.
69
+
70
+ Mount inside FastAPI:
71
+
72
+ ```python
73
+ fastapi_app.mount("/tools", app)
74
+ ```
75
+
76
+ ## Options
77
+
78
+ ```python
79
+ Htbp(
80
+ title="My tools",
81
+ description="...",
82
+ auth={"bearer_token": "..."}, # 401 + WWW-Authenticate without it
83
+ skill="# Markdown served at /~skill",
84
+ base_path="/htbp", # serve under a path prefix
85
+ cors=False, # CORS is on by default
86
+ )
87
+ ```
@@ -0,0 +1,73 @@
1
+ # htbp-sdk
2
+
3
+ HTBP (HTTP Tool Bridge Protocol) server SDK for Python. Expose functions as
4
+ self-describing HTTP tool endpoints, FastAPI-style. Pure ASGI — runs under
5
+ uvicorn/hypercorn and mounts inside FastAPI or Starlette.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pip install htbp-sdk # import name: htbp
11
+ pip install 'htbp-sdk[remote]' # + httpx, needed only for app.remote()
12
+ ```
13
+
14
+ ## Quickstart
15
+
16
+ ```python
17
+ from htbp import Htbp
18
+
19
+ app = Htbp(title="Math tools")
20
+
21
+ @app.tool(effect="read")
22
+ def add(a: int, b: int = 0) -> int:
23
+ """Add two integers."""
24
+ return a + b
25
+ ```
26
+
27
+ ```sh
28
+ uvicorn main:app
29
+ ```
30
+
31
+ Input schemas are derived from type hints via pydantic (FastAPI-style); a
32
+ single parameter annotated with a `BaseModel` subclass uses that model as the
33
+ body schema directly. Async functions are awaited; sync functions run in a
34
+ thread. A `ToolContext`-annotated parameter receives request context.
35
+
36
+ Every node answers `GET {path}/~help` (JSON by default, RFC-0001 text DSL with
37
+ `Accept: text/plain`); tools are invoked with `POST {path}` and respond with
38
+ `{"resource", "result"}`.
39
+
40
+ ## Cascading
41
+
42
+ ```python
43
+ docs = Htbp(title="Docs tools")
44
+
45
+ @docs.tool()
46
+ def search(q: str) -> dict:
47
+ return {"hits": [q]}
48
+
49
+ app.mount("/docs", docs) # local nesting
50
+ app.remote("/ext", "https://other.example.com/~help") # federate a remote HTBP server
51
+ ```
52
+
53
+ Remote nodes pass through `~help` for any sub-path and proxy calls; the inbound
54
+ `Authorization` header is never forwarded upstream.
55
+
56
+ Mount inside FastAPI:
57
+
58
+ ```python
59
+ fastapi_app.mount("/tools", app)
60
+ ```
61
+
62
+ ## Options
63
+
64
+ ```python
65
+ Htbp(
66
+ title="My tools",
67
+ description="...",
68
+ auth={"bearer_token": "..."}, # 401 + WWW-Authenticate without it
69
+ skill="# Markdown served at /~skill",
70
+ base_path="/htbp", # serve under a path prefix
71
+ cors=False, # CORS is on by default
72
+ )
73
+ ```
@@ -0,0 +1,32 @@
1
+ """Runnable example: `uv run uvicorn examples.server:app --port 8788`.
2
+
3
+ Serves an HTBP tree on http://127.0.0.1:8788.
4
+ """
5
+
6
+ from htbp import Htbp
7
+
8
+ app = Htbp(
9
+ title="Example tools",
10
+ description="HTBP example server (Python SDK).",
11
+ skill="# Example tools\n\nCall `add` with `{\"a\": 1, \"b\": 2}`.\n",
12
+ )
13
+
14
+
15
+ @app.tool(effect="read")
16
+ def add(a: int, b: int = 0) -> int:
17
+ """Add two integers."""
18
+ return a + b
19
+
20
+
21
+ docs = Htbp(title="Docs tools", description="Documentation helpers.")
22
+
23
+
24
+ @docs.tool(description="Search documents.")
25
+ def search(q: str) -> dict:
26
+ return {"hits": [f"result for {q}"]}
27
+
28
+
29
+ app.mount("/docs", docs)
30
+
31
+ # Federate to another HTBP server (TS or Python) — cascading across processes:
32
+ # app.remote("/ext", "https://other.example.com/~help")
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "htbp-sdk"
7
+ version = "0.1.0"
8
+ description = "HTBP (HTTP Tool Bridge Protocol) server SDK: expose functions as self-describing HTTP tool endpoints, FastAPI-style."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "TokenRoll AI" }]
13
+ keywords = ["htbp", "tool", "agent", "http", "sdk", "asgi"]
14
+ dependencies = ["pydantic>=2.7"]
15
+
16
+ [project.optional-dependencies]
17
+ remote = ["httpx>=0.27"]
18
+
19
+ [project.urls]
20
+ Repository = "https://github.com/TokenRollAI/http-tool-bridge"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "pytest>=8",
25
+ "pytest-asyncio>=0.24",
26
+ "httpx>=0.27",
27
+ "uvicorn>=0.30",
28
+ ]
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/htbp"]
32
+
33
+ [tool.pytest.ini_options]
34
+ asyncio_mode = "auto"
35
+ testpaths = ["tests"]
@@ -0,0 +1,37 @@
1
+ """htbp — HTBP (HTTP Tool Bridge Protocol) server SDK.
2
+
3
+ Expose functions as self-describing HTTP tool endpoints, FastAPI-style::
4
+
5
+ from htbp import Htbp
6
+
7
+ app = Htbp(title="Math tools")
8
+
9
+ @app.tool()
10
+ def add(a: int, b: int = 0) -> int:
11
+ \"\"\"Add two integers.\"\"\"
12
+ return a + b
13
+
14
+ # uvicorn main:app
15
+ """
16
+
17
+ from .app import Htbp
18
+ from .errors import (
19
+ BadRequestError,
20
+ HtbpError,
21
+ MethodNotAllowedError,
22
+ NotFoundError,
23
+ UpstreamError,
24
+ )
25
+ from .schema import ToolContext
26
+ from .text_help import build_text_help
27
+
28
+ __all__ = [
29
+ "Htbp",
30
+ "ToolContext",
31
+ "HtbpError",
32
+ "NotFoundError",
33
+ "BadRequestError",
34
+ "MethodNotAllowedError",
35
+ "UpstreamError",
36
+ "build_text_help",
37
+ ]
@@ -0,0 +1,402 @@
1
+ """The Htbp application: FastAPI-style tool registration on a self-describing
2
+ HTBP tree, served as a pure-ASGI app — mirror of the TypeScript SDK's
3
+ core/app.ts dispatch."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import inspect
9
+ import json
10
+ from typing import Any, Callable
11
+ from urllib.parse import quote, unquote
12
+
13
+ from .describe import describe_directory, describe_tool
14
+ from .errors import BadRequestError, HtbpError, MethodNotAllowedError, NotFoundError
15
+ from .http import (
16
+ CORS_HEADERS,
17
+ MAX_JSON_BYTES,
18
+ constant_time_equal,
19
+ dump_json,
20
+ error_body,
21
+ etag_of,
22
+ get_bearer_token,
23
+ if_none_match_matches,
24
+ )
25
+ from .schema import CompiledTool, ToolContext, compile_function, output_json_schema
26
+ from .text_help import build_text_help
27
+ from .tree import (
28
+ EFFECTS,
29
+ DirectoryNode,
30
+ RemoteNode,
31
+ ToolNode,
32
+ add_child,
33
+ ensure_directory,
34
+ path_segments,
35
+ resolve,
36
+ )
37
+
38
+
39
+ class Htbp:
40
+ def __init__(
41
+ self,
42
+ title: str,
43
+ description: str | None = None,
44
+ *,
45
+ auth: dict[str, str] | None = None,
46
+ skill: str | None = None,
47
+ cors: bool = True,
48
+ base_path: str | None = None,
49
+ ) -> None:
50
+ if auth is not None and "bearer_token" not in auth:
51
+ raise ValueError("auth must be {'bearer_token': '...'}.")
52
+ self._auth = auth
53
+ self._cors = cors
54
+ self._base_path = _normalize_base_path(base_path)
55
+ self.root = DirectoryNode(id="", title=title, description=description, skill=skill)
56
+
57
+ # -- registration -----------------------------------------------------
58
+
59
+ def tool(
60
+ self,
61
+ fn: Callable[..., Any] | None = None,
62
+ *,
63
+ name: str | None = None,
64
+ description: str | None = None,
65
+ effect: str = "unknown",
66
+ example: Any = None,
67
+ skill: str | None = None,
68
+ ):
69
+ """Decorator: register a plain function as an HTBP tool.
70
+
71
+ Usable bare (``@app.tool``) or with options (``@app.tool(...)``).
72
+ """
73
+ if effect not in EFFECTS:
74
+ raise ValueError(f"effect must be one of {EFFECTS}.")
75
+
76
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
77
+ compiled = compile_function(func)
78
+ tool_name = name or func.__name__
79
+ doc = inspect.getdoc(func)
80
+ tool_description = description or (doc.split("\n\n")[0].strip() if doc else None)
81
+ return_annotation = inspect.signature(func).return_annotation
82
+ add_child(
83
+ self.root,
84
+ ToolNode(
85
+ id=tool_name,
86
+ title=tool_name,
87
+ description=tool_description,
88
+ skill=skill,
89
+ effect=effect,
90
+ example=example,
91
+ input_schema=compiled.json_schema,
92
+ output_schema=output_json_schema(return_annotation),
93
+ validate=compiled.validate,
94
+ call=_make_caller(func, compiled),
95
+ ),
96
+ )
97
+ return func
98
+
99
+ if fn is not None:
100
+ return decorator(fn)
101
+ return decorator
102
+
103
+ def mount(self, path: str, sub: "Htbp") -> "Htbp":
104
+ """Graft a sub-app's tree under ``path`` (cascading)."""
105
+ if sub is self:
106
+ raise ValueError("Cannot mount an app into itself.")
107
+ segments = path_segments(path)
108
+ parent = ensure_directory(self.root, segments[:-1])
109
+ child = DirectoryNode(
110
+ id=segments[-1],
111
+ title=sub.root.title,
112
+ description=sub.root.description,
113
+ skill=sub.root.skill,
114
+ children=sub.root.children,
115
+ )
116
+ add_child(parent, child)
117
+ return self
118
+
119
+ def remote(
120
+ self,
121
+ path: str,
122
+ help_url: str,
123
+ *,
124
+ headers: dict[str, str] | None = None,
125
+ title: str | None = None,
126
+ description: str | None = None,
127
+ allow_insecure: bool = False,
128
+ ) -> "Htbp":
129
+ segments = path_segments(path)
130
+ parent = ensure_directory(self.root, segments[:-1])
131
+ add_child(
132
+ parent,
133
+ RemoteNode(
134
+ id=segments[-1],
135
+ title=title or segments[-1],
136
+ description=description,
137
+ help_url=help_url,
138
+ headers=headers,
139
+ allow_insecure=allow_insecure,
140
+ ),
141
+ )
142
+ return self
143
+
144
+ # -- ASGI -------------------------------------------------------------
145
+
146
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
147
+ if scope["type"] == "lifespan":
148
+ await _lifespan(receive, send)
149
+ return
150
+ if scope["type"] != "http":
151
+ raise RuntimeError(f"Unsupported ASGI scope type '{scope['type']}'.")
152
+ try:
153
+ status, headers, body = await self._dispatch(scope, receive)
154
+ except HtbpError as error:
155
+ status, headers, body = self._error_response(error)
156
+ except Exception as error: # noqa: BLE001 - handler errors become 500 envelopes
157
+ status, headers, body = self._error_response(HtbpError(500, "internal_error", str(error)))
158
+ if self._cors:
159
+ for key, value in CORS_HEADERS.items():
160
+ headers.setdefault(key, value)
161
+ await _send_response(send, status, headers, body)
162
+
163
+ def _error_response(self, error: HtbpError) -> tuple[int, dict[str, str], bytes]:
164
+ headers = {"Content-Type": "application/json; charset=utf-8"}
165
+ if error.status == 401:
166
+ headers["WWW-Authenticate"] = "Bearer"
167
+ return error.status, headers, error_body(error.code, error.message, error.details).encode("utf-8")
168
+
169
+ async def _dispatch(self, scope: dict[str, Any], receive: Any) -> tuple[int, dict[str, str], bytes]:
170
+ method = scope["method"]
171
+ headers = {key.decode("latin-1").lower(): value.decode("latin-1") for key, value in scope.get("headers", [])}
172
+
173
+ if method == "OPTIONS" and self._cors:
174
+ return 204, {**CORS_HEADERS, "Access-Control-Max-Age": "86400"}, b""
175
+
176
+ self._authenticate(headers)
177
+
178
+ segments, base = self._request_segments(scope)
179
+ control = None
180
+ if segments and segments[-1] in ("~help", "~skill"):
181
+ control = segments.pop()
182
+
183
+ found = resolve(self.root, segments)
184
+ if found is None:
185
+ raise NotFoundError(f"No HTBP resource at '/{'/'.join(segments)}'.")
186
+ node, sub = found
187
+ resource_path = _resource_path(base, segments)
188
+
189
+ if control == "~skill":
190
+ return await self._serve_skill(method, headers, node, sub, resource_path)
191
+ if control == "~help" or (method == "GET" and not segments):
192
+ return await self._serve_help(method, headers, node, sub, resource_path)
193
+
194
+ if method == "POST":
195
+ return await self._serve_call(scope, receive, headers, node, sub, resource_path)
196
+ if method == "GET":
197
+ raise MethodNotAllowedError(f"GET is not supported here; fetch '{resource_path}/~help' for usage.")
198
+ raise MethodNotAllowedError(f"Method {method} is not supported.")
199
+
200
+ def _authenticate(self, headers: dict[str, str]) -> None:
201
+ if self._auth is None:
202
+ return
203
+ token = get_bearer_token(headers.get("authorization"))
204
+ if not token or not constant_time_equal(token, self._auth["bearer_token"]):
205
+ raise HtbpError(401, "unauthorized", "A valid bearer token is required.")
206
+
207
+ def _request_segments(self, scope: dict[str, Any]) -> tuple[list[str], str]:
208
+ # root_path covers being mounted inside FastAPI/Starlette; an explicit
209
+ # base_path is for serving under a prefix without a mounting frame.
210
+ root_path = scope.get("root_path", "") or ""
211
+ base = _join_base(root_path, self._base_path)
212
+ path = scope["path"]
213
+ rest = path
214
+ if self._base_path:
215
+ inner = path[len(root_path):] if root_path and path.startswith(root_path) else path
216
+ if inner != self._base_path and not inner.startswith(self._base_path + "/"):
217
+ raise NotFoundError(f"No HTBP resource at '{path}'.")
218
+ rest = inner[len(self._base_path):]
219
+ elif root_path and path.startswith(root_path):
220
+ rest = path[len(root_path):]
221
+ segments = [unquote(segment) for segment in rest.split("/") if segment]
222
+ return segments, base
223
+
224
+ async def _serve_help(
225
+ self, method: str, headers: dict[str, str], node: Any, sub: list[str], resource_path: str
226
+ ) -> tuple[int, dict[str, str], bytes]:
227
+ if method != "GET":
228
+ raise MethodNotAllowedError("~help only supports GET.")
229
+ has_skill = False
230
+ if isinstance(node, DirectoryNode):
231
+ payload = describe_directory(node)
232
+ await self._enrich_remote_children(node, payload)
233
+ has_skill = node.skill is not None
234
+ elif isinstance(node, ToolNode):
235
+ if sub:
236
+ raise NotFoundError(f"No HTBP resource at '{resource_path}'.")
237
+ payload = describe_tool(node)
238
+ has_skill = node.skill is not None
239
+ else:
240
+ from .remote import remote_describe
241
+
242
+ payload = await remote_describe(node, sub)
243
+
244
+ auth_label = "bearer" if self._auth else "none"
245
+ cache_control = (
246
+ "no-store"
247
+ if payload.get("cachable") is False
248
+ else f"{'private' if self._auth else 'public'}, max-age=300"
249
+ )
250
+ accept = headers.get("accept", "")
251
+ if _prefers_text(accept):
252
+ body = build_text_help(payload, resource_path, auth_label, has_skill)
253
+ content_type = "text/plain; charset=utf-8"
254
+ else:
255
+ body = dump_json(payload) + "\n"
256
+ content_type = "application/json; charset=utf-8"
257
+ if cache_control == "no-store":
258
+ return 200, {"Content-Type": content_type, "Cache-Control": cache_control}, body.encode("utf-8")
259
+ return _cached_document(body, content_type, cache_control, headers.get("if-none-match"))
260
+
261
+ async def _enrich_remote_children(self, node: DirectoryNode, payload: dict[str, Any]) -> None:
262
+ remotes = [child for child in node.children.values() if isinstance(child, RemoteNode) and not child.description]
263
+ if not remotes:
264
+ return
265
+ from .remote import fetch_remote_summary
266
+
267
+ refs = {ref["name"]: ref for ref in payload.get("resources", [])}
268
+ summaries = await asyncio.gather(*(fetch_remote_summary(child) for child in remotes))
269
+ for child, summary in zip(remotes, summaries):
270
+ ref = refs.get(child.id)
271
+ if ref is None or summary is None:
272
+ continue
273
+ enriched = summary.get("description") or (f"Remote: {summary['title']}" if summary.get("title") else None)
274
+ if enriched:
275
+ ref["description"] = enriched
276
+
277
+ async def _serve_skill(
278
+ self, method: str, headers: dict[str, str], node: Any, sub: list[str], resource_path: str
279
+ ) -> tuple[int, dict[str, str], bytes]:
280
+ if method != "GET":
281
+ raise MethodNotAllowedError("~skill only supports GET.")
282
+ skill = None if isinstance(node, RemoteNode) or sub else getattr(node, "skill", None)
283
+ if skill is None:
284
+ raise NotFoundError(f"No skill document at '{resource_path}/~skill'.")
285
+ cache_control = f"{'private' if self._auth else 'public'}, max-age=300"
286
+ return _cached_document(skill, "text/markdown; charset=utf-8", cache_control, headers.get("if-none-match"))
287
+
288
+ async def _serve_call(
289
+ self, scope: dict[str, Any], receive: Any, headers: dict[str, str], node: Any, sub: list[str], resource_path: str
290
+ ) -> tuple[int, dict[str, str], bytes]:
291
+ if isinstance(node, DirectoryNode):
292
+ raise HtbpError(405, "not_callable", f"'{resource_path}' is a directory; descend to an end-path resource.")
293
+ input_value = await _read_json_body(receive)
294
+ if isinstance(node, RemoteNode):
295
+ from .remote import remote_call
296
+
297
+ result = await remote_call(node, sub, input_value)
298
+ return _json_response({"resource": resource_path, "result": result})
299
+ if sub:
300
+ raise NotFoundError(f"No HTBP resource at '{resource_path}'.")
301
+ ok, value = node.validate(input_value)
302
+ if not ok:
303
+ raise BadRequestError("Input validation failed.", value)
304
+ ctx = ToolContext(path=resource_path, headers=headers, scope=scope)
305
+ result = await node.call(value, ctx)
306
+ return _json_response({"resource": resource_path, "result": result})
307
+
308
+
309
+ def _make_caller(fn: Callable[..., Any], compiled: CompiledTool):
310
+ is_async = inspect.iscoroutinefunction(fn)
311
+
312
+ async def call(value: Any, ctx: ToolContext) -> Any:
313
+ kwargs = compiled.bind(value, ctx)
314
+ if is_async:
315
+ return await fn(**kwargs)
316
+ return await asyncio.to_thread(fn, **kwargs)
317
+
318
+ return call
319
+
320
+
321
+ def _prefers_text(accept: str) -> bool:
322
+ value = accept.lower()
323
+ if "application/json" in value:
324
+ return False
325
+ return "text/plain" in value or "text/markdown" in value
326
+
327
+
328
+ def _normalize_base_path(base_path: str | None) -> str:
329
+ if not base_path or base_path == "/":
330
+ return ""
331
+ trimmed = base_path.rstrip("/")
332
+ return trimmed if trimmed.startswith("/") else f"/{trimmed}"
333
+
334
+
335
+ def _join_base(root_path: str, base_path: str) -> str:
336
+ combined = f"{root_path.rstrip('/')}{base_path}"
337
+ return combined if combined else ""
338
+
339
+
340
+ def _resource_path(base: str, segments: list[str]) -> str:
341
+ joined = "/".join(quote(segment, safe="") for segment in segments)
342
+ if not joined:
343
+ return base or "/"
344
+ return f"{base}/{joined}"
345
+
346
+
347
+ def _cached_document(
348
+ body: str, content_type: str, cache_control: str, if_none_match: str | None
349
+ ) -> tuple[int, dict[str, str], bytes]:
350
+ etag = etag_of(body)
351
+ headers = {"ETag": etag, "Cache-Control": cache_control}
352
+ if if_none_match and if_none_match_matches(if_none_match, etag):
353
+ return 304, headers, b""
354
+ return 200, {**headers, "Content-Type": content_type}, body.encode("utf-8")
355
+
356
+
357
+ def _json_response(data: Any) -> tuple[int, dict[str, str], bytes]:
358
+ return 200, {"Content-Type": "application/json; charset=utf-8"}, dump_json(data).encode("utf-8")
359
+
360
+
361
+ async def _read_json_body(receive: Any):
362
+ chunks: list[bytes] = []
363
+ total = 0
364
+ while True:
365
+ message = await receive()
366
+ if message["type"] != "http.request":
367
+ raise BadRequestError("Unexpected ASGI message while reading the body.")
368
+ chunk = message.get("body", b"")
369
+ total += len(chunk)
370
+ if total > MAX_JSON_BYTES:
371
+ raise BadRequestError("Request body exceeds the maximum size.")
372
+ chunks.append(chunk)
373
+ if not message.get("more_body", False):
374
+ break
375
+ body = b"".join(chunks).decode("utf-8", errors="replace")
376
+ if not body.strip():
377
+ return {}
378
+ try:
379
+ return json.loads(body)
380
+ except json.JSONDecodeError:
381
+ raise BadRequestError("Request body is not valid JSON.") from None
382
+
383
+
384
+ async def _lifespan(receive: Any, send: Any) -> None:
385
+ while True:
386
+ message = await receive()
387
+ if message["type"] == "lifespan.startup":
388
+ await send({"type": "lifespan.startup.complete"})
389
+ elif message["type"] == "lifespan.shutdown":
390
+ await send({"type": "lifespan.shutdown.complete"})
391
+ return
392
+
393
+
394
+ async def _send_response(send: Any, status: int, headers: dict[str, str], body: bytes) -> None:
395
+ await send(
396
+ {
397
+ "type": "http.response.start",
398
+ "status": status,
399
+ "headers": [(key.encode("latin-1"), value.encode("latin-1")) for key, value in headers.items()],
400
+ }
401
+ )
402
+ await send({"type": "http.response.body", "body": body})