lenapi 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.
- lenapi/__init__.py +79 -0
- lenapi/application.py +636 -0
- lenapi/authorization.py +96 -0
- lenapi/background.py +395 -0
- lenapi/cli.py +731 -0
- lenapi/database.py +84 -0
- lenapi/dependencies.py +12 -0
- lenapi/logging.py +13 -0
- lenapi/middleware.py +144 -0
- lenapi/model.py +54 -0
- lenapi/models.py +1052 -0
- lenapi/mongodb.py +262 -0
- lenapi/openapi.py +181 -0
- lenapi/parameters.py +8 -0
- lenapi/payments.py +543 -0
- lenapi/request.py +177 -0
- lenapi/response.py +91 -0
- lenapi/security.py +665 -0
- lenapi/security_store.py +193 -0
- lenapi/service.py +350 -0
- lenapi-0.1.0.dist-info/METADATA +959 -0
- lenapi-0.1.0.dist-info/RECORD +25 -0
- lenapi-0.1.0.dist-info/WHEEL +5 -0
- lenapi-0.1.0.dist-info/entry_points.txt +2 -0
- lenapi-0.1.0.dist-info/top_level.txt +1 -0
lenapi/mongodb.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Optional MongoDB integration using the official PyMongo driver.
|
|
2
|
+
|
|
3
|
+
MongoDB documents are Pydantic models, separate from LenApi's SQLAlchemy
|
|
4
|
+
``Model`` classes and Alembic migrations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import Any, ClassVar
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MultipleDocumentsFound(LookupError):
|
|
16
|
+
"""Raised when ``MongoDocument.get`` matches more than one document."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MongoDatabase:
|
|
20
|
+
"""Own sync and async PyMongo clients for one MongoDB database.
|
|
21
|
+
|
|
22
|
+
Install with ``pip install 'lenapi[mongodb]'``. Network I/O stays lazy until
|
|
23
|
+
the first query or an explicit ``ping()`` / ``aping()`` call.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, uri: str | None = None, database_name: str | None = None,
|
|
27
|
+
*, client=None, async_client=None, **client_options) -> None:
|
|
28
|
+
self.uri = uri or os.getenv("MONGODB_URI")
|
|
29
|
+
self.database_name = database_name or os.getenv("MONGODB_DATABASE")
|
|
30
|
+
if not self.uri:
|
|
31
|
+
raise ValueError("Pass uri or set MONGODB_URI")
|
|
32
|
+
if not self.database_name:
|
|
33
|
+
raise ValueError("Pass database_name or set MONGODB_DATABASE")
|
|
34
|
+
MongoClient = AsyncMongoClient = None
|
|
35
|
+
if client is None or async_client is None:
|
|
36
|
+
try:
|
|
37
|
+
from pymongo import AsyncMongoClient, MongoClient
|
|
38
|
+
except ImportError as exc:
|
|
39
|
+
raise ImportError("MongoDB support requires `pip install 'lenapi[mongodb]'`") from exc
|
|
40
|
+
self.client = client if client is not None else MongoClient(self.uri, **client_options)
|
|
41
|
+
self.async_client = async_client if async_client is not None else AsyncMongoClient(self.uri, **client_options)
|
|
42
|
+
self.database = self.client[self.database_name]
|
|
43
|
+
self.async_database = self.async_client[self.database_name]
|
|
44
|
+
|
|
45
|
+
def collection(self, name: str):
|
|
46
|
+
"""Return a synchronous PyMongo collection."""
|
|
47
|
+
return self.database[name]
|
|
48
|
+
|
|
49
|
+
def acollection(self, name: str):
|
|
50
|
+
"""Return an asynchronous PyMongo collection."""
|
|
51
|
+
return self.async_database[name]
|
|
52
|
+
|
|
53
|
+
def ping(self) -> bool:
|
|
54
|
+
"""Check connectivity synchronously."""
|
|
55
|
+
return self.client.admin.command("ping").get("ok") == 1.0
|
|
56
|
+
|
|
57
|
+
async def aping(self) -> bool:
|
|
58
|
+
"""Check connectivity asynchronously."""
|
|
59
|
+
result = await self.async_client.admin.command("ping")
|
|
60
|
+
return result.get("ok") == 1.0
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
"""Close the synchronous client."""
|
|
64
|
+
self.client.close()
|
|
65
|
+
|
|
66
|
+
async def aclose(self) -> None:
|
|
67
|
+
"""Close both clients; await this from ASGI shutdown or async code."""
|
|
68
|
+
self.client.close()
|
|
69
|
+
await self.async_client.close()
|
|
70
|
+
|
|
71
|
+
def __enter__(self):
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, *_exc):
|
|
75
|
+
self.close()
|
|
76
|
+
|
|
77
|
+
async def __aenter__(self):
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
async def __aexit__(self, *_exc):
|
|
81
|
+
await self.aclose()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _object_id(value):
|
|
85
|
+
if isinstance(value, str) and len(value) == 24:
|
|
86
|
+
try:
|
|
87
|
+
from bson import ObjectId
|
|
88
|
+
if ObjectId.is_valid(value):
|
|
89
|
+
return ObjectId(value)
|
|
90
|
+
except ImportError:
|
|
91
|
+
pass
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _mongo_filter(filters: dict[str, Any]) -> dict[str, Any]:
|
|
96
|
+
result = dict(filters)
|
|
97
|
+
if "id" in result:
|
|
98
|
+
result["_id"] = _object_id(result.pop("id"))
|
|
99
|
+
if "_id" in result:
|
|
100
|
+
result["_id"] = _object_id(result["_id"])
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _document_values(document: dict[str, Any]) -> dict[str, Any]:
|
|
105
|
+
result = dict(document)
|
|
106
|
+
if "_id" in result:
|
|
107
|
+
result["id"] = str(result.pop("_id"))
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class MongoDocument(BaseModel):
|
|
112
|
+
"""Pydantic document base for MongoDB collections.
|
|
113
|
+
|
|
114
|
+
Bind each subclass to a ``MongoDatabase``. The default collection is the
|
|
115
|
+
lower-case class name; override ``collection_name`` to choose another.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
119
|
+
id: str | None = Field(default=None, alias="_id")
|
|
120
|
+
collection_name: ClassVar[str | None] = None
|
|
121
|
+
_mongo_database: ClassVar[MongoDatabase | None] = None
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def bind(cls, database: MongoDatabase):
|
|
125
|
+
"""Bind this document class to a configured MongoDatabase."""
|
|
126
|
+
cls._mongo_database = database
|
|
127
|
+
return cls
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def _collection_name(cls) -> str:
|
|
131
|
+
return cls.collection_name or cls.__name__.lower()
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def _database(cls) -> MongoDatabase:
|
|
135
|
+
if cls._mongo_database is None:
|
|
136
|
+
raise RuntimeError(f"Bind {cls.__name__} to MongoDatabase first: {cls.__name__}.bind(db)")
|
|
137
|
+
return cls._mongo_database
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def _from_document(cls, value: dict[str, Any] | None):
|
|
141
|
+
return None if value is None else cls.model_validate(_document_values(value))
|
|
142
|
+
|
|
143
|
+
def _as_document(self) -> dict[str, Any]:
|
|
144
|
+
return self.model_dump(by_alias=True, exclude_none=True)
|
|
145
|
+
|
|
146
|
+
def save(self):
|
|
147
|
+
"""Insert this document, or update its existing document by ``id``."""
|
|
148
|
+
collection = type(self)._database().collection(type(self)._collection_name())
|
|
149
|
+
values = self._as_document()
|
|
150
|
+
if self.id is None:
|
|
151
|
+
result = collection.insert_one(values)
|
|
152
|
+
self.id = str(result.inserted_id)
|
|
153
|
+
else:
|
|
154
|
+
values.pop("_id", None)
|
|
155
|
+
collection.update_one({"_id": _object_id(self.id)}, {"$set": values}, upsert=True)
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
async def asave(self):
|
|
159
|
+
"""Async insert or update."""
|
|
160
|
+
collection = type(self)._database().acollection(type(self)._collection_name())
|
|
161
|
+
values = self._as_document()
|
|
162
|
+
if self.id is None:
|
|
163
|
+
result = await collection.insert_one(values)
|
|
164
|
+
self.id = str(result.inserted_id)
|
|
165
|
+
else:
|
|
166
|
+
values.pop("_id", None)
|
|
167
|
+
await collection.update_one({"_id": _object_id(self.id)}, {"$set": values}, upsert=True)
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def create(cls, **values):
|
|
172
|
+
"""Validate, insert, and return a document."""
|
|
173
|
+
return cls(**values).save()
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
async def acreate(cls, **values):
|
|
177
|
+
"""Async validate, insert, and return a document."""
|
|
178
|
+
return await cls(**values).asave()
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def get(cls, **filters):
|
|
182
|
+
"""Return one matching document, or ``None``; reject duplicate matches."""
|
|
183
|
+
collection = cls._database().collection(cls._collection_name())
|
|
184
|
+
query = _mongo_filter(filters)
|
|
185
|
+
cursor = collection.find(query).limit(2)
|
|
186
|
+
rows = list(cursor)
|
|
187
|
+
if len(rows) > 1:
|
|
188
|
+
raise MultipleDocumentsFound(f"More than one {cls.__name__} matches the query")
|
|
189
|
+
return cls._from_document(rows[0]) if rows else None
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
async def aget(cls, **filters):
|
|
193
|
+
"""Async form of ``get``."""
|
|
194
|
+
collection = cls._database().acollection(cls._collection_name())
|
|
195
|
+
cursor = collection.find(_mongo_filter(filters)).limit(2)
|
|
196
|
+
rows = await cursor.to_list(length=2)
|
|
197
|
+
if len(rows) > 1:
|
|
198
|
+
raise MultipleDocumentsFound(f"More than one {cls.__name__} matches the query")
|
|
199
|
+
return cls._from_document(rows[0]) if rows else None
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def filter(cls, **filters):
|
|
203
|
+
"""Return all documents matching a MongoDB query."""
|
|
204
|
+
collection = cls._database().collection(cls._collection_name())
|
|
205
|
+
return [cls._from_document(row) for row in collection.find(_mongo_filter(filters))]
|
|
206
|
+
|
|
207
|
+
@classmethod
|
|
208
|
+
async def afilter(cls, **filters):
|
|
209
|
+
"""Async form of ``filter``."""
|
|
210
|
+
collection = cls._database().acollection(cls._collection_name())
|
|
211
|
+
cursor = collection.find(_mongo_filter(filters))
|
|
212
|
+
rows = await cursor.to_list(length=None)
|
|
213
|
+
return [cls._from_document(row) for row in rows]
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def all(cls):
|
|
217
|
+
"""Return every document in the collection."""
|
|
218
|
+
return cls.filter()
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
async def aall(cls):
|
|
222
|
+
"""Async form of ``all``."""
|
|
223
|
+
return await cls.afilter()
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def count(cls, **filters) -> int:
|
|
227
|
+
return cls._database().collection(cls._collection_name()).count_documents(_mongo_filter(filters))
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
async def acount(cls, **filters) -> int:
|
|
231
|
+
return await cls._database().acollection(cls._collection_name()).count_documents(_mongo_filter(filters))
|
|
232
|
+
|
|
233
|
+
def delete(self) -> bool:
|
|
234
|
+
"""Delete this persisted document; return whether one was removed."""
|
|
235
|
+
if self.id is None:
|
|
236
|
+
return False
|
|
237
|
+
result = type(self)._database().collection(type(self)._collection_name()).delete_one(
|
|
238
|
+
{"_id": _object_id(self.id)}
|
|
239
|
+
)
|
|
240
|
+
return result.deleted_count == 1
|
|
241
|
+
|
|
242
|
+
async def adelete(self) -> bool:
|
|
243
|
+
"""Async form of ``delete``."""
|
|
244
|
+
if self.id is None:
|
|
245
|
+
return False
|
|
246
|
+
result = await type(self)._database().acollection(type(self)._collection_name()).delete_one(
|
|
247
|
+
{"_id": _object_id(self.id)}
|
|
248
|
+
)
|
|
249
|
+
return result.deleted_count == 1
|
|
250
|
+
|
|
251
|
+
@classmethod
|
|
252
|
+
def ensure_index(cls, keys, **options):
|
|
253
|
+
"""Create a collection index using PyMongo index key syntax."""
|
|
254
|
+
return cls._database().collection(cls._collection_name()).create_index(keys, **options)
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
async def aensure_index(cls, keys, **options):
|
|
258
|
+
"""Async form of ``ensure_index``."""
|
|
259
|
+
return await cls._database().acollection(cls._collection_name()).create_index(keys, **options)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
__all__ = ["MongoDatabase", "MongoDocument", "MultipleDocumentsFound"]
|
lenapi/openapi.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""OpenAPI generation from lenApi routes and Pydantic models."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, TypeAdapter
|
|
7
|
+
|
|
8
|
+
from .background import BackgroundTasks
|
|
9
|
+
from .dependencies import Depends
|
|
10
|
+
from .request import Request
|
|
11
|
+
from .parameters import Body
|
|
12
|
+
from .security import APIKeyAuth, BasicAuth, HMACAuth, JWTAuth, OIDCAuth, OpaqueBearerAuth, SessionAuth, WebhookAuth
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _add_definitions(schema: dict[str, Any], components: dict[str, Any]) -> dict[str, Any]:
|
|
16
|
+
for name, definition in schema.pop("$defs", {}).items():
|
|
17
|
+
components.setdefault(name, definition)
|
|
18
|
+
return schema
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _model_ref(model: type[BaseModel], components: dict[str, Any]) -> dict[str, str]:
|
|
22
|
+
if model.__name__ not in components:
|
|
23
|
+
schema = model.model_json_schema(ref_template="#/components/schemas/{model}")
|
|
24
|
+
components[model.__name__] = _add_definitions(schema, components)
|
|
25
|
+
return {"$ref": f"#/components/schemas/{model.__name__}"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _type_schema(annotation: Any, components: dict[str, Any]) -> dict[str, Any]:
|
|
29
|
+
schema = TypeAdapter(annotation).json_schema(ref_template="#/components/schemas/{model}")
|
|
30
|
+
return _add_definitions(schema, components)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _auth_schemes(dependency, seen=None) -> dict[str, dict[str, str]]:
|
|
34
|
+
seen = set() if seen is None else seen
|
|
35
|
+
if dependency in seen:
|
|
36
|
+
return {}
|
|
37
|
+
seen.add(dependency)
|
|
38
|
+
if isinstance(dependency, JWTAuth):
|
|
39
|
+
return {"BearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}}
|
|
40
|
+
if isinstance(dependency, BasicAuth):
|
|
41
|
+
return {"BasicAuth": {"type": "http", "scheme": "basic"}}
|
|
42
|
+
if isinstance(dependency, APIKeyAuth):
|
|
43
|
+
name = "ApiKey_" + "".join(char if char.isalnum() else "_" for char in dependency.header_name)
|
|
44
|
+
return {name: {"type": "apiKey", "in": "header", "name": dependency.header_name}}
|
|
45
|
+
if isinstance(dependency, SessionAuth):
|
|
46
|
+
return {"SessionCookie": {"type": "apiKey", "in": "cookie", "name": dependency.cookie_name}}
|
|
47
|
+
if isinstance(dependency, HMACAuth):
|
|
48
|
+
return {
|
|
49
|
+
"HMACTimestamp": {"type": "apiKey", "in": "header", "name": "X-LenAPI-Timestamp"},
|
|
50
|
+
"HMACNonce": {"type": "apiKey", "in": "header", "name": "X-LenAPI-Nonce"},
|
|
51
|
+
"HMACSignature": {"type": "apiKey", "in": "header", "name": "X-LenAPI-Signature"},
|
|
52
|
+
}
|
|
53
|
+
if isinstance(dependency, WebhookAuth):
|
|
54
|
+
schemes = {
|
|
55
|
+
"WebhookSignature": {
|
|
56
|
+
"type": "apiKey", "in": "header", "name": dependency.signature_header
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if dependency.timestamp_header:
|
|
60
|
+
schemes["WebhookTimestamp"] = {
|
|
61
|
+
"type": "apiKey", "in": "header", "name": dependency.timestamp_header
|
|
62
|
+
}
|
|
63
|
+
if dependency.event_id_header and dependency.security_store is not None:
|
|
64
|
+
schemes["WebhookEventId"] = {
|
|
65
|
+
"type": "apiKey", "in": "header", "name": dependency.event_id_header
|
|
66
|
+
}
|
|
67
|
+
return schemes
|
|
68
|
+
if isinstance(dependency, (OpaqueBearerAuth, OIDCAuth)):
|
|
69
|
+
return {"BearerToken": {"type": "http", "scheme": "bearer"}}
|
|
70
|
+
try:
|
|
71
|
+
parameters = inspect.signature(dependency).parameters.values()
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
return {}
|
|
74
|
+
schemes = {}
|
|
75
|
+
for parameter in parameters:
|
|
76
|
+
if isinstance(parameter.default, Depends):
|
|
77
|
+
schemes.update(_auth_schemes(parameter.default.dependency, seen))
|
|
78
|
+
return schemes
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_openapi(app, *, title: str, version: str) -> dict[str, Any]:
|
|
82
|
+
components: dict[str, Any] = {}
|
|
83
|
+
paths: dict[str, Any] = {}
|
|
84
|
+
security_schemes: dict[str, dict[str, str]] = {}
|
|
85
|
+
|
|
86
|
+
for route in app.routes:
|
|
87
|
+
for method in route.methods:
|
|
88
|
+
operation: dict[str, Any] = {
|
|
89
|
+
"operationId": f"{route.endpoint.__name__}_{method.lower()}",
|
|
90
|
+
"summary": route.endpoint.__name__.replace("_", " ").capitalize(),
|
|
91
|
+
"responses": {"200": {"description": "Successful response"}},
|
|
92
|
+
}
|
|
93
|
+
if route.request_body is not None:
|
|
94
|
+
operation["requestBody"] = route.request_body
|
|
95
|
+
if route.api_flag:
|
|
96
|
+
operation["x-lenapi-throttle"] = {
|
|
97
|
+
"key": "client_ip",
|
|
98
|
+
"cool_time_seconds": route.cool_time,
|
|
99
|
+
}
|
|
100
|
+
parameters = []
|
|
101
|
+
request_body = operation.get("requestBody")
|
|
102
|
+
for name, parameter in inspect.signature(route.endpoint).parameters.items():
|
|
103
|
+
annotation = parameter.annotation
|
|
104
|
+
if isinstance(parameter.default, Body):
|
|
105
|
+
request_body = {
|
|
106
|
+
"required": True,
|
|
107
|
+
"content": {"application/json": {"schema": {}}},
|
|
108
|
+
}
|
|
109
|
+
continue
|
|
110
|
+
if name == "request" and annotation in (Request, inspect.Parameter.empty):
|
|
111
|
+
continue
|
|
112
|
+
if annotation is BackgroundTasks:
|
|
113
|
+
continue
|
|
114
|
+
if isinstance(parameter.default, Depends):
|
|
115
|
+
schemes = _auth_schemes(parameter.default.dependency)
|
|
116
|
+
if schemes:
|
|
117
|
+
security_schemes.update(schemes)
|
|
118
|
+
existing = operation.get("security", [{}])[0]
|
|
119
|
+
existing.update({name: [] for name in schemes})
|
|
120
|
+
operation["security"] = [existing]
|
|
121
|
+
continue
|
|
122
|
+
if getattr(annotation, "__module__", "").startswith("sqlalchemy"):
|
|
123
|
+
continue
|
|
124
|
+
if inspect.isclass(annotation) and issubclass(annotation, BaseModel):
|
|
125
|
+
request_body = {
|
|
126
|
+
"required": parameter.default is inspect.Parameter.empty,
|
|
127
|
+
"content": {"application/json": {"schema": _model_ref(annotation, components)}},
|
|
128
|
+
}
|
|
129
|
+
continue
|
|
130
|
+
if annotation is inspect.Parameter.empty:
|
|
131
|
+
schema = {"type": "string"}
|
|
132
|
+
else:
|
|
133
|
+
try:
|
|
134
|
+
schema = _type_schema(annotation, components)
|
|
135
|
+
except Exception:
|
|
136
|
+
schema = {"type": "string"}
|
|
137
|
+
in_path = name in route.parameter_names
|
|
138
|
+
parameters.append({
|
|
139
|
+
"name": name,
|
|
140
|
+
"in": "path" if in_path else "query",
|
|
141
|
+
"required": in_path or parameter.default is inspect.Parameter.empty,
|
|
142
|
+
"schema": schema,
|
|
143
|
+
})
|
|
144
|
+
if parameters:
|
|
145
|
+
operation["parameters"] = parameters
|
|
146
|
+
if request_body is not None:
|
|
147
|
+
operation["requestBody"] = request_body
|
|
148
|
+
if route.response_model is not None:
|
|
149
|
+
operation["responses"]["200"]["content"] = {
|
|
150
|
+
"application/json": {"schema": _type_schema(route.response_model, components)}
|
|
151
|
+
}
|
|
152
|
+
paths.setdefault(route.path, {})[method.lower()] = operation
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"openapi": "3.1.0",
|
|
156
|
+
"info": {"title": title, "version": version},
|
|
157
|
+
"paths": paths,
|
|
158
|
+
"components": {
|
|
159
|
+
"schemas": components,
|
|
160
|
+
**({"securitySchemes": security_schemes} if security_schemes else {}),
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def swagger_html(openapi_url: str) -> str:
|
|
166
|
+
return f'''<!doctype html>
|
|
167
|
+
<html lang="en">
|
|
168
|
+
<head>
|
|
169
|
+
<meta charset="utf-8">
|
|
170
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
171
|
+
<title>API docs</title>
|
|
172
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
|
|
173
|
+
</head>
|
|
174
|
+
<body>
|
|
175
|
+
<div id="swagger-ui"></div>
|
|
176
|
+
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
177
|
+
<script>
|
|
178
|
+
window.onload = () => SwaggerUIBundle({{ url: {openapi_url!r}, dom_id: '#swagger-ui' }});
|
|
179
|
+
</script>
|
|
180
|
+
</body>
|
|
181
|
+
</html>'''
|