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/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>'''
lenapi/parameters.py ADDED
@@ -0,0 +1,8 @@
1
+ """Request parameter markers."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Body:
8
+ """Read a JSON request body without applying a schema or validation."""