hayate-openapi 0.2.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hayate-openapi
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: OpenAPI 3.1 generation for hayate: routes from app.routes, schemas from your validators
5
5
  Keywords: hayate,openapi,json-schema,documentation
6
6
  Author: Yusuke Hayashi
@@ -27,12 +27,13 @@ built from what your app already knows: routes from `app.routes`, request
27
27
  schemas from your validators, response schemas from one decorator. No magic
28
28
  inference, no schema-library lock-in.
29
29
 
30
- > **Status: alpha (0.2.x).** The emitted document passes the official
30
+ > **Status: alpha (0.3.x).** The emitted document passes the official
31
31
  > `openapi-spec-validator` and feeds `openapi-typescript` for end-to-end
32
32
  > TypeScript types. The internal design memo (Japanese, per project
33
33
  > convention) lives in [DESIGN.md](DESIGN.md).
34
- > Security schemes, multipart uploads, and strict inline typing are included.
35
- > Release history is in [CHANGELOG.md](CHANGELOG.md).
34
+ > Interactive Scalar docs, security schemes, multipart uploads, and strict
35
+ > inline typing are included. Release history is in
36
+ > [CHANGELOG.md](CHANGELOG.md).
36
37
 
37
38
  ```python
38
39
  from hayate import Hayate
@@ -51,7 +52,7 @@ async def create(c):
51
52
  return c.json({"title": book.title}, status=201)
52
53
 
53
54
  OpenApi(app, title="Bookstore", version="1.0.0").register(app)
54
- # GET /openapi.json is live; or emit statically:
55
+ # GET /openapi.json and interactive GET /docs are live; or emit statically:
55
56
  # python -m hayate_openapi main:app --title Bookstore --version 1.0.0
56
57
  ```
57
58
 
@@ -93,8 +94,36 @@ python -m hayate_openapi main:app --title API --version 1.0.0 -o openapi.json
93
94
  npx openapi-typescript openapi.json -o src/api-types.ts
94
95
  ```
95
96
 
96
- Docs UI: serve the JSON and point any renderer at it — e.g. one line of
97
- [Scalar](https://github.com/scalar/scalar) or Redoc HTML. Nothing is bundled.
97
+ ## Interactive API reference
98
+
99
+ `register()` serves a [Scalar](https://github.com/scalar/scalar) API reference
100
+ at `/docs` by default. It can execute requests, render schemas and security
101
+ requirements, and generate client examples directly from the same OpenAPI 3.1
102
+ document.
103
+
104
+ The page has no inline JavaScript. It pins Scalar to an immutable version with
105
+ Subresource Integrity and sends a restrictive Content Security Policy. Scalar
106
+ is loaded from jsDelivr at browser time, while Scalar's telemetry, external
107
+ client, sharing, deployment, MCP-generation, developer-tools, and AI-agent
108
+ integrations are disabled by default. The Python package therefore keeps its
109
+ single `hayate` dependency without sending the API document to another
110
+ service. Disable the page or self-host the script when needed:
111
+
112
+ ```python
113
+ # JSON only
114
+ OpenApi(app, title="API", version="1", docs_path=None).register(app)
115
+
116
+ # Same-origin, self-hosted Scalar bundle
117
+ OpenApi(
118
+ app,
119
+ title="API",
120
+ version="1",
121
+ scalar_script_url="/assets/scalar.js",
122
+ ).register(app)
123
+ ```
124
+
125
+ The OpenAPI JSON and docs routes are intentionally excluded from the generated
126
+ application schema.
98
127
 
99
128
  ## What is documented (and what is not)
100
129
 
@@ -5,12 +5,13 @@ built from what your app already knows: routes from `app.routes`, request
5
5
  schemas from your validators, response schemas from one decorator. No magic
6
6
  inference, no schema-library lock-in.
7
7
 
8
- > **Status: alpha (0.2.x).** The emitted document passes the official
8
+ > **Status: alpha (0.3.x).** The emitted document passes the official
9
9
  > `openapi-spec-validator` and feeds `openapi-typescript` for end-to-end
10
10
  > TypeScript types. The internal design memo (Japanese, per project
11
11
  > convention) lives in [DESIGN.md](DESIGN.md).
12
- > Security schemes, multipart uploads, and strict inline typing are included.
13
- > Release history is in [CHANGELOG.md](CHANGELOG.md).
12
+ > Interactive Scalar docs, security schemes, multipart uploads, and strict
13
+ > inline typing are included. Release history is in
14
+ > [CHANGELOG.md](CHANGELOG.md).
14
15
 
15
16
  ```python
16
17
  from hayate import Hayate
@@ -29,7 +30,7 @@ async def create(c):
29
30
  return c.json({"title": book.title}, status=201)
30
31
 
31
32
  OpenApi(app, title="Bookstore", version="1.0.0").register(app)
32
- # GET /openapi.json is live; or emit statically:
33
+ # GET /openapi.json and interactive GET /docs are live; or emit statically:
33
34
  # python -m hayate_openapi main:app --title Bookstore --version 1.0.0
34
35
  ```
35
36
 
@@ -71,8 +72,36 @@ python -m hayate_openapi main:app --title API --version 1.0.0 -o openapi.json
71
72
  npx openapi-typescript openapi.json -o src/api-types.ts
72
73
  ```
73
74
 
74
- Docs UI: serve the JSON and point any renderer at it — e.g. one line of
75
- [Scalar](https://github.com/scalar/scalar) or Redoc HTML. Nothing is bundled.
75
+ ## Interactive API reference
76
+
77
+ `register()` serves a [Scalar](https://github.com/scalar/scalar) API reference
78
+ at `/docs` by default. It can execute requests, render schemas and security
79
+ requirements, and generate client examples directly from the same OpenAPI 3.1
80
+ document.
81
+
82
+ The page has no inline JavaScript. It pins Scalar to an immutable version with
83
+ Subresource Integrity and sends a restrictive Content Security Policy. Scalar
84
+ is loaded from jsDelivr at browser time, while Scalar's telemetry, external
85
+ client, sharing, deployment, MCP-generation, developer-tools, and AI-agent
86
+ integrations are disabled by default. The Python package therefore keeps its
87
+ single `hayate` dependency without sending the API document to another
88
+ service. Disable the page or self-host the script when needed:
89
+
90
+ ```python
91
+ # JSON only
92
+ OpenApi(app, title="API", version="1", docs_path=None).register(app)
93
+
94
+ # Same-origin, self-hosted Scalar bundle
95
+ OpenApi(
96
+ app,
97
+ title="API",
98
+ version="1",
99
+ scalar_script_url="/assets/scalar.js",
100
+ ).register(app)
101
+ ```
102
+
103
+ The OpenAPI JSON and docs routes are intentionally excluded from the generated
104
+ application schema.
76
105
 
77
106
  ## What is documented (and what is not)
78
107
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "hayate-openapi"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "OpenAPI 3.1 generation for hayate: routes from app.routes, schemas from your validators"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -17,7 +17,7 @@ from .security import (
17
17
  )
18
18
  from .tags import binary_file, describe, validated
19
19
 
20
- __version__ = "0.2.0"
20
+ __version__ = "0.3.0"
21
21
 
22
22
  __all__ = [
23
23
  "MsgspecProvider",
@@ -7,9 +7,12 @@ so ``generate()`` is trivially testable and the mounted endpoint is just
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import html
11
+ import json
10
12
  import re
11
13
  from collections.abc import Mapping
12
14
  from typing import Any
15
+ from urllib.parse import urlsplit
13
16
 
14
17
  from hayate import Context, Response
15
18
 
@@ -19,8 +22,12 @@ from .tags import OPENAPI_ATTR
19
22
 
20
23
  OPENAPI_VERSION = "3.1.1"
21
24
 
25
+ _SCALAR_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.63.0"
26
+ _SCALAR_SCRIPT_INTEGRITY = "sha384-bnRzGcRYqM9jbXxeIbNDWWD8mNMY0p8qvmfAyfcT5S7/I6E7bsyLprA0uIP2gUu7"
27
+ _OPENAPI_EXCLUDE_ATTR = "__hayate_openapi_exclude__"
22
28
  _PARAM_RE = re.compile(r":(\w+)(\([^)]*\))?")
23
29
  _PATH_TEMPLATE_PARAM_RE = re.compile(r"\{[^{}]+\}")
30
+ _HTTPS_HOST_RE = re.compile(r"^[A-Za-z0-9.-]+$")
24
31
  # Only real HTTP verbs become operations; hayate's websocket routes use an
25
32
  # internal marker method and are not documentable in OpenAPI.
26
33
  _HTTP_METHODS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"})
@@ -60,15 +67,25 @@ class OpenApi:
60
67
  version: str,
61
68
  description: str | None = None,
62
69
  path: str = "/openapi.json",
70
+ docs_path: str | None = "/docs",
71
+ scalar_script_url: str = _SCALAR_SCRIPT_URL,
63
72
  providers: list[SchemaProvider] | None = None,
64
73
  security_schemes: Mapping[str, SecurityScheme] | None = None,
65
74
  security: list[SecurityRequirement] | None = None,
66
75
  ) -> None:
76
+ if docs_path is not None:
77
+ if not docs_path.startswith("/") or docs_path.startswith("//"):
78
+ raise ValueError("docs_path must be an absolute application path")
79
+ if docs_path == path:
80
+ raise ValueError("docs_path and path must be different")
67
81
  self.app = app
68
82
  self.title = title
69
83
  self.version = version
70
84
  self.description = description
71
85
  self.path = path
86
+ self.docs_path = docs_path
87
+ self.scalar_script_url = scalar_script_url
88
+ self._scalar_script_source = _script_source(scalar_script_url)
72
89
  self.providers = providers if providers is not None else default_providers()
73
90
  self.security_schemes = dict(security_schemes or {})
74
91
  self.security = security
@@ -82,6 +99,8 @@ class OpenApi:
82
99
  operation_ids: set[str] = set()
83
100
 
84
101
  for route in self.app.routes:
102
+ if getattr(route.handler, _OPENAPI_EXCLUDE_ATTR, False):
103
+ continue
85
104
  if route.method not in _HTTP_METHODS:
86
105
  continue
87
106
  converted = _convert_path(route.pattern)
@@ -228,8 +247,106 @@ class OpenApi:
228
247
  async def openapi_handler(c: Context) -> Response:
229
248
  return c.json(self.generate())
230
249
 
250
+ setattr(openapi_handler, _OPENAPI_EXCLUDE_ATTR, True)
231
251
  app.get(self.path)(openapi_handler)
232
252
 
253
+ if self.docs_path is not None:
254
+
255
+ async def docs_handler(c: Context) -> Response:
256
+ return c.html(
257
+ self._docs_html(),
258
+ headers={
259
+ "cache-control": "no-store",
260
+ "content-security-policy": self._docs_csp(),
261
+ "referrer-policy": "no-referrer",
262
+ "x-content-type-options": "nosniff",
263
+ },
264
+ )
265
+
266
+ setattr(docs_handler, _OPENAPI_EXCLUDE_ATTR, True)
267
+ app.get(self.docs_path)(docs_handler)
268
+
269
+ def _docs_html(self) -> str:
270
+ configuration = html.escape(
271
+ json.dumps(
272
+ {
273
+ "url": self.path,
274
+ "withDefaultFonts": False,
275
+ "showDeveloperTools": "never",
276
+ "hideClientButton": True,
277
+ "agent": {"disabled": True},
278
+ "mcp": {"disabled": True},
279
+ "telemetry": False,
280
+ },
281
+ ensure_ascii=False,
282
+ separators=(",", ":"),
283
+ ),
284
+ quote=True,
285
+ )
286
+ script_url = html.escape(self.scalar_script_url, quote=True)
287
+ title = html.escape(self.title, quote=True)
288
+ integrity = ""
289
+ if self.scalar_script_url == _SCALAR_SCRIPT_URL:
290
+ integrity = (
291
+ f'\n integrity="{_SCALAR_SCRIPT_INTEGRITY}"\n crossorigin="anonymous"'
292
+ )
293
+ return f"""<!doctype html>
294
+ <html lang="en">
295
+ <head>
296
+ <meta charset="utf-8">
297
+ <meta name="viewport" content="width=device-width, initial-scale=1">
298
+ <title>{title} API reference</title>
299
+ </head>
300
+ <body>
301
+ <script
302
+ id="api-reference"
303
+ data-configuration="{configuration}"
304
+ src="{script_url}"{integrity}
305
+ ></script>
306
+ <noscript>Enable JavaScript to use the interactive API reference.</noscript>
307
+ </body>
308
+ </html>
309
+ """
310
+
311
+ def _docs_csp(self) -> str:
312
+ return "; ".join(
313
+ (
314
+ "default-src 'none'",
315
+ f"script-src {self._scalar_script_source}",
316
+ "style-src 'unsafe-inline'",
317
+ "img-src data: https:",
318
+ "connect-src 'self' https:",
319
+ "font-src 'none'",
320
+ "object-src 'none'",
321
+ "base-uri 'none'",
322
+ "form-action 'none'",
323
+ "frame-ancestors 'none'",
324
+ )
325
+ )
326
+
327
+
328
+ def _script_source(url: str) -> str:
329
+ if any(ord(character) < 0x20 or ord(character) == 0x7F for character in url):
330
+ raise ValueError("scalar_script_url must not contain control characters")
331
+ if url.startswith("/") and not url.startswith("//"):
332
+ return "'self'"
333
+
334
+ parsed = urlsplit(url)
335
+ if (
336
+ parsed.scheme != "https"
337
+ or parsed.username is not None
338
+ or parsed.password is not None
339
+ or parsed.hostname is None
340
+ or _HTTPS_HOST_RE.fullmatch(parsed.hostname) is None
341
+ ):
342
+ raise ValueError("scalar_script_url must be root-relative or use an HTTPS origin")
343
+ try:
344
+ port = parsed.port
345
+ except ValueError as exc:
346
+ raise ValueError("scalar_script_url has an invalid port") from exc
347
+ suffix = f":{port}" if port is not None else ""
348
+ return f"https://{parsed.hostname}{suffix}"
349
+
233
350
 
234
351
  def _status_text(status: int) -> str:
235
352
  import http
File without changes