hayate-openapi 0.1.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,25 @@
1
+ """hayate-openapi: OpenAPI 3.1 from what your app already knows."""
2
+
3
+ from .generate import OpenApi
4
+ from .providers import (
5
+ MsgspecProvider,
6
+ PydanticProvider,
7
+ RawSchemaProvider,
8
+ SchemaProvider,
9
+ default_providers,
10
+ )
11
+ from .tags import describe, validated
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "MsgspecProvider",
17
+ "OpenApi",
18
+ "PydanticProvider",
19
+ "RawSchemaProvider",
20
+ "SchemaProvider",
21
+ "__version__",
22
+ "default_providers",
23
+ "describe",
24
+ "validated",
25
+ ]
@@ -0,0 +1,44 @@
1
+ """CLI: python -m hayate_openapi app:app --title X --version 1.0 [-o out.json]"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .generate import OpenApi
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ parser = argparse.ArgumentParser(
16
+ prog="python -m hayate_openapi",
17
+ description="Emit the OpenAPI 3.1 document for a hayate app.",
18
+ )
19
+ parser.add_argument("app", help="import target, e.g. 'main:app'")
20
+ parser.add_argument("--title", required=True)
21
+ parser.add_argument("--version", required=True)
22
+ parser.add_argument("--description")
23
+ parser.add_argument("-o", "--output", help="write here instead of stdout")
24
+ args = parser.parse_args(argv)
25
+
26
+ module_name, _, attribute = args.app.partition(":")
27
+ if not attribute:
28
+ parser.error("app must be 'module:attribute', e.g. 'main:app'")
29
+ sys.path.insert(0, str(Path.cwd()))
30
+ application = getattr(importlib.import_module(module_name), attribute)
31
+
32
+ document = OpenApi(
33
+ application, title=args.title, version=args.version, description=args.description
34
+ ).generate()
35
+ text = json.dumps(document, indent=2)
36
+ if args.output:
37
+ Path(args.output).write_text(text + "\n", encoding="utf-8")
38
+ else:
39
+ print(text)
40
+ return 0
41
+
42
+
43
+ if __name__ == "__main__":
44
+ sys.exit(main())
@@ -0,0 +1,195 @@
1
+ """The generator: walk ``app.routes``, merge the tags, emit OpenAPI 3.1.
2
+
3
+ Everything here is a pure function of the app object — no I/O, no globals —
4
+ so ``generate()`` is trivially testable and the mounted endpoint is just
5
+ ``c.json(generate())``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any
12
+
13
+ from hayate import Context, Response
14
+
15
+ from .providers import SchemaProvider, default_providers, resolve
16
+ from .tags import OPENAPI_ATTR
17
+
18
+ OPENAPI_VERSION = "3.1.1"
19
+
20
+ _PARAM_RE = re.compile(r":(\w+)(\([^)]*\))?")
21
+ # Only real HTTP verbs become operations; hayate's websocket routes use an
22
+ # internal marker method and are not documentable in OpenAPI.
23
+ _HTTP_METHODS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"})
24
+
25
+
26
+ def _convert_path(pattern: str) -> tuple[str, list[dict[str, Any]]] | None:
27
+ """URLPattern pathname -> (OpenAPI path, path parameters).
28
+
29
+ Wildcard patterns are not documentable operations; returns None for them.
30
+ """
31
+ if "*" in pattern:
32
+ return None
33
+ parameters: list[dict[str, Any]] = []
34
+
35
+ def replace(match: re.Match[str]) -> str:
36
+ name, regex = match.group(1), match.group(2)
37
+ schema: dict[str, Any] = {"type": "string"}
38
+ if regex:
39
+ schema["pattern"] = regex[1:-1]
40
+ parameters.append({"name": name, "in": "path", "required": True, "schema": schema})
41
+ return "{" + name + "}"
42
+
43
+ return _PARAM_RE.sub(replace, pattern), parameters
44
+
45
+
46
+ def _operation_id(method: str, path: str) -> str:
47
+ slug = re.sub(r"[^a-zA-Z0-9]+", "_", path).strip("_") or "root"
48
+ return f"{method.lower()}_{slug}"
49
+
50
+
51
+ class OpenApi:
52
+ def __init__(
53
+ self,
54
+ app: Any,
55
+ *,
56
+ title: str,
57
+ version: str,
58
+ description: str | None = None,
59
+ path: str = "/openapi.json",
60
+ providers: list[SchemaProvider] | None = None,
61
+ ) -> None:
62
+ self.app = app
63
+ self.title = title
64
+ self.version = version
65
+ self.description = description
66
+ self.path = path
67
+ self.providers = providers if providers is not None else default_providers()
68
+
69
+ # -- generation --------------------------------------------------------------------
70
+
71
+ def generate(self) -> dict[str, Any]:
72
+ paths: dict[str, dict[str, Any]] = {}
73
+ components: dict[str, Any] = {}
74
+
75
+ for route in self.app.routes:
76
+ if route.method not in _HTTP_METHODS:
77
+ continue
78
+ converted = _convert_path(route.pattern)
79
+ if converted is None:
80
+ continue
81
+ path, path_params = converted
82
+ operation = self._operation(route, path, path_params, components)
83
+ paths.setdefault(path, {})[route.method.lower()] = operation
84
+
85
+ document: dict[str, Any] = {
86
+ "openapi": OPENAPI_VERSION,
87
+ "info": {"title": self.title, "version": self.version},
88
+ "paths": paths,
89
+ }
90
+ if self.description is not None:
91
+ document["info"]["description"] = self.description
92
+ if components:
93
+ document["components"] = {"schemas": components}
94
+ return document
95
+
96
+ def _operation(
97
+ self,
98
+ route: Any,
99
+ path: str,
100
+ path_params: list[dict[str, Any]],
101
+ components: dict[str, Any],
102
+ ) -> dict[str, Any]:
103
+ meta = getattr(route.handler, OPENAPI_ATTR, {})
104
+ operation: dict[str, Any] = {
105
+ "operationId": meta.get("operation_id") or _operation_id(route.method, path),
106
+ "responses": {},
107
+ }
108
+ for key in ("summary", "description", "tags"):
109
+ if meta.get(key) is not None:
110
+ operation[key] = meta[key]
111
+ if meta.get("deprecated"):
112
+ operation["deprecated"] = True
113
+
114
+ parameters = list(path_params)
115
+ has_validator = False
116
+ for middleware in route.middleware:
117
+ tag = getattr(middleware, OPENAPI_ATTR, None)
118
+ if tag is None:
119
+ continue
120
+ has_validator = True
121
+ schema = self._register_schema(tag["type"], components)
122
+ target = tag["target"]
123
+ if target == "json":
124
+ operation["requestBody"] = {
125
+ "required": True,
126
+ "content": {"application/json": {"schema": schema}},
127
+ }
128
+ elif target == "form":
129
+ operation["requestBody"] = {
130
+ "required": True,
131
+ "content": {"application/x-www-form-urlencoded": {"schema": schema}},
132
+ }
133
+ else: # query: expand an object schema into individual parameters
134
+ parameters.extend(self._query_parameters(schema, components))
135
+
136
+ if parameters:
137
+ operation["parameters"] = parameters
138
+
139
+ responses: dict[str, Any] = {}
140
+ for status, type_ in (meta.get("responses") or {}).items():
141
+ entry: dict[str, Any] = {"description": _status_text(status)}
142
+ if type_ is not None:
143
+ entry["content"] = {
144
+ "application/json": {"schema": self._register_schema(type_, components)}
145
+ }
146
+ responses[str(status)] = entry
147
+ if not responses:
148
+ responses["200"] = {"description": "Successful response"}
149
+ if has_validator and "400" not in responses:
150
+ responses["400"] = {
151
+ "description": "Validation failed",
152
+ "content": {"application/problem+json": {"schema": {"type": "object"}}},
153
+ }
154
+ operation["responses"] = responses
155
+ return operation
156
+
157
+ def _register_schema(self, type_: Any, components: dict[str, Any]) -> dict[str, Any]:
158
+ provider = resolve(self.providers, type_)
159
+ schema, defs = provider.schema(type_)
160
+ components.update(defs)
161
+ return schema
162
+
163
+ def _query_parameters(
164
+ self, schema: dict[str, Any], components: dict[str, Any]
165
+ ) -> list[dict[str, Any]]:
166
+ resolved = self._deref(schema, components)
167
+ required = set(resolved.get("required", ()))
168
+ out = []
169
+ for name, prop in (resolved.get("properties") or {}).items():
170
+ out.append({"name": name, "in": "query", "required": name in required, "schema": prop})
171
+ return out
172
+
173
+ @staticmethod
174
+ def _deref(schema: dict[str, Any], components: dict[str, Any]) -> dict[str, Any]:
175
+ ref = schema.get("$ref", "")
176
+ if ref.startswith("#/components/schemas/"):
177
+ return components.get(ref.rsplit("/", 1)[1], {})
178
+ return schema
179
+
180
+ # -- mounting ----------------------------------------------------------------------
181
+
182
+ def register(self, app: Any) -> None:
183
+ async def openapi_handler(c: Context) -> Response:
184
+ return c.json(self.generate())
185
+
186
+ app.get(self.path)(openapi_handler)
187
+
188
+
189
+ def _status_text(status: int) -> str:
190
+ import http
191
+
192
+ try:
193
+ return http.HTTPStatus(status).phrase
194
+ except ValueError:
195
+ return "Response"
@@ -0,0 +1,109 @@
1
+ """Schema providers: type -> (JSON Schema 2020-12, referenced $defs).
2
+
3
+ The chain is guarded-import autodetection (DESIGN §3.2): msgspec Structs,
4
+ pydantic BaseModels, then raw dicts passed through untouched. Each provider
5
+ also supplies the validation converter, so ``validated()`` needs no separate
6
+ wiring.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ from collections.abc import Callable
13
+ from typing import Any, Protocol
14
+
15
+
16
+ class SchemaProvider(Protocol):
17
+ def supports(self, type_: Any) -> bool: ...
18
+
19
+ def schema(self, type_: Any) -> tuple[dict[str, Any], dict[str, Any]]:
20
+ """Return (schema for the type, {name: definition} it references)."""
21
+ ...
22
+
23
+ def converter(self, type_: Any) -> Callable[[Any], Any]:
24
+ """A callable that validates/converts raw data (raises on failure)."""
25
+ ...
26
+
27
+
28
+ _REF_PREFIX = "#/components/schemas/"
29
+
30
+
31
+ class MsgspecProvider:
32
+ def __init__(self) -> None:
33
+ import msgspec
34
+
35
+ self._msgspec = msgspec
36
+
37
+ def supports(self, type_: Any) -> bool:
38
+ return isinstance(type_, type) and issubclass(type_, self._msgspec.Struct)
39
+
40
+ def schema(self, type_: Any) -> tuple[dict[str, Any], dict[str, Any]]:
41
+ schemas, components = self._msgspec.json.schema_components(
42
+ (type_,), ref_template=_REF_PREFIX + "{name}"
43
+ )
44
+ return schemas[0], dict(components)
45
+
46
+ def converter(self, type_: Any) -> Callable[[Any], Any]:
47
+ return lambda data: self._msgspec.convert(data, type_)
48
+
49
+
50
+ class PydanticProvider:
51
+ def __init__(self) -> None:
52
+ from pydantic import TypeAdapter
53
+
54
+ self._adapter = TypeAdapter
55
+
56
+ def supports(self, type_: Any) -> bool:
57
+ try:
58
+ from pydantic import BaseModel
59
+ except ImportError: # pragma: no cover - guarded twice
60
+ return False
61
+ return isinstance(type_, type) and issubclass(type_, BaseModel)
62
+
63
+ def schema(self, type_: Any) -> tuple[dict[str, Any], dict[str, Any]]:
64
+ raw = self._adapter(type_).json_schema(
65
+ ref_template=_REF_PREFIX + "{model}", mode="validation"
66
+ )
67
+ defs = raw.pop("$defs", {})
68
+ # The root model itself lands inline; hoist it so every model ref
69
+ # points into components uniformly.
70
+ name = type_.__name__
71
+ defs[name] = raw
72
+ return {"$ref": _REF_PREFIX + name}, defs
73
+
74
+ def converter(self, type_: Any) -> Callable[[Any], Any]:
75
+ adapter = self._adapter(type_)
76
+ return adapter.validate_python
77
+
78
+
79
+ class RawSchemaProvider:
80
+ """A plain dict is taken as literal JSON Schema: documented, not enforced."""
81
+
82
+ def supports(self, type_: Any) -> bool:
83
+ return isinstance(type_, dict)
84
+
85
+ def schema(self, type_: Any) -> tuple[dict[str, Any], dict[str, Any]]:
86
+ return dict(type_), {}
87
+
88
+ def converter(self, type_: Any) -> Callable[[Any], Any]:
89
+ return lambda data: data
90
+
91
+
92
+ def default_providers() -> list[SchemaProvider]:
93
+ chain: list[SchemaProvider] = []
94
+ with contextlib.suppress(ImportError):
95
+ chain.append(MsgspecProvider())
96
+ with contextlib.suppress(ImportError):
97
+ chain.append(PydanticProvider())
98
+ chain.append(RawSchemaProvider())
99
+ return chain
100
+
101
+
102
+ def resolve(providers: list[SchemaProvider], type_: Any) -> SchemaProvider:
103
+ for provider in providers:
104
+ if provider.supports(type_):
105
+ return provider
106
+ raise TypeError(
107
+ f"no schema provider supports {type_!r}: install msgspec or pydantic, "
108
+ "or pass a raw JSON Schema dict"
109
+ )
hayate_openapi/tags.py ADDED
@@ -0,0 +1,66 @@
1
+ """The two annotation surfaces (DESIGN §3.1): ``validated`` wraps the core
2
+ validator and tags the middleware with its type; ``describe`` tags the
3
+ handler. Both are additive — untagged routes still document (thinly).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from hayate import Middleware, validator
11
+
12
+ from .providers import SchemaProvider, default_providers, resolve
13
+
14
+ OPENAPI_ATTR = "__openapi__"
15
+
16
+
17
+ def validated(
18
+ target: str,
19
+ type_: Any,
20
+ *,
21
+ providers: list[SchemaProvider] | None = None,
22
+ ) -> Middleware:
23
+ """``hayate.validator`` plus an OpenAPI tag.
24
+
25
+ Validation behavior is identical to wiring the converter yourself; the
26
+ only addition is the ``__openapi__`` attribute the generator reads.
27
+ """
28
+ chain = providers if providers is not None else default_providers()
29
+ provider = resolve(chain, type_)
30
+ middleware = validator(target, provider.converter(type_))
31
+ middleware.__openapi__ = {"target": target, "type": type_} # type: ignore[attr-defined]
32
+ return middleware
33
+
34
+
35
+ def describe(
36
+ *,
37
+ summary: str | None = None,
38
+ description: str | None = None,
39
+ tags: list[str] | None = None,
40
+ status: int = 200,
41
+ response: Any | None = None,
42
+ responses: dict[int, Any] | None = None,
43
+ operation_id: str | None = None,
44
+ deprecated: bool = False,
45
+ ):
46
+ """Attach OpenAPI operation metadata to a handler (all fields optional).
47
+
48
+ ``response=T, status=201`` is sugar for ``responses={201: T}``; use
49
+ ``responses={404: None}`` for a schema-less documented status.
50
+ """
51
+ merged: dict[int, Any] = dict(responses or {})
52
+ if response is not None or (responses is None and status != 200):
53
+ merged.setdefault(status, response)
54
+
55
+ def wrap(handler):
56
+ handler.__openapi__ = {
57
+ "summary": summary,
58
+ "description": description,
59
+ "tags": tags,
60
+ "responses": merged,
61
+ "operation_id": operation_id,
62
+ "deprecated": deprecated,
63
+ }
64
+ return handler
65
+
66
+ return wrap
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: hayate-openapi
3
+ Version: 0.1.0
4
+ Summary: OpenAPI 3.1 generation for hayate: routes from app.routes, schemas from your validators
5
+ Keywords: hayate,openapi,json-schema,documentation
6
+ Author: Yusuke Hayashi
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Documentation
15
+ Requires-Dist: hayate>=0.8.0
16
+ Requires-Python: >=3.12
17
+ Project-URL: Repository, https://github.com/hayatepy/hayate-openapi
18
+ Description-Content-Type: text/markdown
19
+
20
+ # hayate-openapi
21
+
22
+ OpenAPI 3.1 generation for [hayate](https://github.com/hayatepy/hayate) —
23
+ built from what your app already knows: routes from `app.routes`, request
24
+ schemas from your validators, response schemas from one decorator. No magic
25
+ inference, no schema-library lock-in.
26
+
27
+ > **Status: alpha (0.1.x).** The emitted document passes the official
28
+ > `openapi-spec-validator` and feeds `openapi-typescript` for end-to-end
29
+ > TypeScript types. The internal design memo (Japanese, per project
30
+ > convention) lives in [DESIGN.md](DESIGN.md).
31
+
32
+ ```python
33
+ from hayate import Hayate
34
+ from hayate_openapi import OpenApi, describe, validated
35
+ import msgspec
36
+
37
+ class BookIn(msgspec.Struct):
38
+ title: str
39
+
40
+ app = Hayate()
41
+
42
+ @app.post("/books", validated("json", BookIn)) # validator + schema tag in one
43
+ @describe(status=201, summary="Create a book")
44
+ async def create(c):
45
+ book = c.req.valid("json") # BookIn instance — validation still runs
46
+ return c.json({"title": book.title}, status=201)
47
+
48
+ OpenApi(app, title="Bookstore", version="1.0.0").register(app)
49
+ # GET /openapi.json is live; or emit statically:
50
+ # python -m hayate_openapi main:app --title Bookstore --version 1.0.0
51
+ ```
52
+
53
+ ## How it works
54
+
55
+ | Source | What it provides |
56
+ |---|---|
57
+ | `app.routes` (hayate ≥ 0.8) | every method + path, converted to OpenAPI templating (`:id` → `{id}`) |
58
+ | `validated(target, T)` | request body / query / form schemas — a tagging wrapper around the core `validator`, behavior-identical |
59
+ | `@describe(...)` | summary, tags, response schemas, operationId — all optional, all additive |
60
+
61
+ Schema conversion goes through a `SchemaProvider` protocol. msgspec and
62
+ pydantic are auto-detected (guarded imports); a plain dict is taken as
63
+ literal JSON Schema. **The package itself depends only on hayate.**
64
+
65
+ TypeScript types, the recommended recipe:
66
+
67
+ ```sh
68
+ python -m hayate_openapi main:app --title API --version 1.0.0 -o openapi.json
69
+ npx openapi-typescript openapi.json -o src/api-types.ts
70
+ ```
71
+
72
+ Docs UI: serve the JSON and point any renderer at it — e.g. one line of
73
+ [Scalar](https://github.com/scalar/scalar) or Redoc HTML. Nothing is bundled.
74
+
75
+ ## What is documented (and what is not)
76
+
77
+ - Routes with real HTTP verbs; WebSocket routes and wildcard mounts
78
+ (`/api/auth/*`) are skipped.
79
+ - Responses you declare. Undeclared operations get a bare 200 — the
80
+ generator never invents schemas.
81
+ - Operations with a validator automatically document the 400
82
+ `application/problem+json` failure the framework actually returns.
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,9 @@
1
+ hayate_openapi/__init__.py,sha256=zaImc5N-U16xMczgZDZJSic4-Q1QA78vbA_5Y1mUAnw,497
2
+ hayate_openapi/__main__.py,sha256=dErSnPqdZE_BNjdXg-MDF6b-2WXKJDlbwAglIbVyxpU,1385
3
+ hayate_openapi/generate.py,sha256=Kk7LWhyovdm73o7YC5CUUSSOw05k-b-sjGBjgny5Xc4,7013
4
+ hayate_openapi/providers.py,sha256=8aeovYi1sR-wko9i3O2lsHodAraq-PWzgiOpwPGpHKU,3525
5
+ hayate_openapi/tags.py,sha256=f0r5ME0vhWAF7APsChAUq_ARd2cPjMNTUPfT4K4bI9U,2042
6
+ hayate_openapi-0.1.0.dist-info/licenses/LICENSE,sha256=KTFzH0Ey0y0j9ZWIkI_AhY449BepGTS6gJ1MD2u4LHs,1071
7
+ hayate_openapi-0.1.0.dist-info/WHEEL,sha256=r-Se0i_n47Mj8pdnVuq7W628oP9YAKMaTjp4l3lQcns,81
8
+ hayate_openapi-0.1.0.dist-info/METADATA,sha256=0_yNzU4gcN4Q8cDt-O5bGmFDvqcaQmpJsXyD3nxbanU,3241
9
+ hayate_openapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.31
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yusuke Hayashi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.