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.
- {hayate_openapi-0.2.0 → hayate_openapi-0.3.0}/PKG-INFO +36 -7
- {hayate_openapi-0.2.0 → hayate_openapi-0.3.0}/README.md +35 -6
- {hayate_openapi-0.2.0 → hayate_openapi-0.3.0}/pyproject.toml +1 -1
- {hayate_openapi-0.2.0 → hayate_openapi-0.3.0}/src/hayate_openapi/__init__.py +1 -1
- {hayate_openapi-0.2.0 → hayate_openapi-0.3.0}/src/hayate_openapi/generate.py +117 -0
- {hayate_openapi-0.2.0 → hayate_openapi-0.3.0}/LICENSE +0 -0
- {hayate_openapi-0.2.0 → hayate_openapi-0.3.0}/src/hayate_openapi/__main__.py +0 -0
- {hayate_openapi-0.2.0 → hayate_openapi-0.3.0}/src/hayate_openapi/providers.py +0 -0
- {hayate_openapi-0.2.0 → hayate_openapi-0.3.0}/src/hayate_openapi/py.typed +0 -0
- {hayate_openapi-0.2.0 → hayate_openapi-0.3.0}/src/hayate_openapi/security.py +0 -0
- {hayate_openapi-0.2.0 → hayate_openapi-0.3.0}/src/hayate_openapi/tags.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hayate-openapi
|
|
3
|
-
Version: 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.
|
|
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
|
-
>
|
|
35
|
-
> Release history is in
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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.
|
|
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
|
-
>
|
|
13
|
-
> Release history is in
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|