api-service-handler 0.1.6__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,432 @@
1
+ """MongoDB storage backend using motor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from datetime import date, datetime, timezone
7
+ from typing import Any, Optional
8
+
9
+ from ..enums import KeyStatus, Provider
10
+ from ..exceptions import DuplicateKeyError, KeyNotFoundError, StorageConnectionError
11
+ from ..models import APIKey, BulkOperationResult, KeyFilter, KeyUpdateRequest
12
+ from .base import StorageBackend
13
+
14
+ def _try_import_motor():
15
+ """Lazy import motor with helpful error."""
16
+ try:
17
+ import motor.motor_asyncio
18
+ return motor.motor_asyncio
19
+ except ImportError:
20
+ raise ImportError(
21
+ "MongoDB backend requires 'motor'. "
22
+ "Install with: pip install api-service-handler[mongodb]"
23
+ )
24
+
25
+ class MongoDBStorageBackend(StorageBackend):
26
+ """MongoDB storage backend using motor.
27
+
28
+ Good for highly scalable, document-oriented storage.
29
+ Install with: pip install api-service-handler[mongodb]
30
+ """
31
+
32
+ def __init__(self, connection_string: str, metadata_indexes: Optional[list[str]] = None) -> None:
33
+ """Initialize MongoDB backend.
34
+
35
+ Args:
36
+ connection_string: MongoDB connection URI, e.g., 'mongodb://localhost:27017/my_db'.
37
+ metadata_indexes: List of metadata keys to create compound indexes for.
38
+ """
39
+ self._motor = _try_import_motor()
40
+ self._connection_string = connection_string
41
+ self._metadata_indexes = metadata_indexes or []
42
+ self._client = None
43
+ self._db = None
44
+ self._collection = None
45
+ self._initialized = False
46
+
47
+ async def initialize(self) -> None:
48
+ """Connect to MongoDB and setup indexes."""
49
+ try:
50
+ self._client = self._motor.AsyncIOMotorClient(self._connection_string)
51
+
52
+ # Extract database name from URI, default to api_service_handler
53
+ db_name = self._client.get_database().name if self._client.get_database().name else "api_service_handler"
54
+ self._db = self._client[db_name]
55
+ self._collection = self._db["api_keys"]
56
+
57
+ # Create indexes
58
+ import pymongo
59
+
60
+ # Provider index
61
+ await self._collection.create_index([("provider", pymongo.ASCENDING)])
62
+
63
+ # Status index
64
+ await self._collection.create_index([("status", pymongo.ASCENDING)])
65
+
66
+ # Compound provider/status index
67
+ await self._collection.create_index(
68
+ [("provider", pymongo.ASCENDING), ("status", pymongo.ASCENDING)]
69
+ )
70
+
71
+ # Dynamic compound indexes for metadata
72
+ for md_key in self._metadata_indexes:
73
+ await self._collection.create_index(
74
+ [
75
+ ("provider", pymongo.ASCENDING),
76
+ ("status", pymongo.ASCENDING),
77
+ (f"metadata.{md_key}", pymongo.ASCENDING)
78
+ ]
79
+ )
80
+
81
+ # Unique compound index for active keys to prevent duplicates
82
+ try:
83
+ await self._collection.create_index(
84
+ [("key_value", pymongo.ASCENDING), ("provider", pymongo.ASCENDING)],
85
+ unique=True,
86
+ partialFilterExpression={"status": {"$ne": "revoked"}}
87
+ )
88
+ except pymongo.errors.OperationFailure:
89
+ # Fallback for AWS DocumentDB which does not support $ne in partial index
90
+ await self._collection.create_index(
91
+ [("key_value", pymongo.ASCENDING), ("provider", pymongo.ASCENDING)],
92
+ unique=True
93
+ )
94
+
95
+ self._initialized = True
96
+ except Exception as e:
97
+ raise StorageConnectionError(backend="mongodb", detail=str(e))
98
+
99
+ async def close(self) -> None:
100
+ """Close the MongoDB connection."""
101
+ if self._client:
102
+ self._client.close()
103
+ self._client = None
104
+ self._db = None
105
+ self._collection = None
106
+ self._initialized = False
107
+
108
+ def _dict_to_key(self, doc: dict[str, Any]) -> APIKey:
109
+ """Convert a MongoDB document to an APIKey model."""
110
+ if not doc:
111
+ raise ValueError("Cannot convert empty document to APIKey")
112
+
113
+ # Map _id to id
114
+ if "_id" in doc:
115
+ doc["id"] = str(doc.pop("_id"))
116
+
117
+ return APIKey(**doc)
118
+
119
+ def _key_to_dict(self, key: APIKey) -> dict[str, Any]:
120
+ """Convert an APIKey model to a MongoDB document."""
121
+ data = key.model_dump()
122
+
123
+ # Handle enums
124
+ data["provider"] = data["provider"].value if hasattr(data["provider"], "value") else data["provider"]
125
+ data["status"] = data["status"].value if hasattr(data["status"], "value") else data["status"]
126
+ data["environment"] = data["environment"].value if hasattr(data["environment"], "value") else data["environment"]
127
+
128
+ # MongoDB native datetime is UTC, but APIKey stores datetimes. We can dump to standard formats.
129
+ # Ensure datetimes are proper datetime objects for MongoDB
130
+ # pydantic model_dump handles basic types, but we want proper dates
131
+ # model_dump might convert dates to strings or leave as dates.
132
+ # Let's ensure proper MongoDB types
133
+
134
+ # map id to _id
135
+ data["_id"] = data.pop("id")
136
+
137
+ # Datetimes and dates are naturally handled by motor/pymongo if they are datetime objects,
138
+ # but pydantic might leave them. Let's ensure they are native types.
139
+ for field in ["last_used_at", "expires_at", "created_at", "updated_at"]:
140
+ if data.get(field) and isinstance(data[field], str):
141
+ data[field] = datetime.fromisoformat(data[field])
142
+
143
+ for field in ["last_reset_daily", "last_reset_monthly"]:
144
+ if data.get(field) and isinstance(data[field], str):
145
+ data[field] = datetime.fromisoformat(data[field]).date()
146
+ # pymongo requires datetime for dates too usually, convert date to datetime
147
+ if data.get(field) and isinstance(data[field], date) and not isinstance(data[field], datetime):
148
+ data[field] = datetime.combine(data[field], datetime.min.time(), tzinfo=timezone.utc)
149
+
150
+ return data
151
+
152
+ # ── CRUD ───────────────────────────────────────────────────────────────
153
+
154
+ async def add_key(self, key: APIKey) -> APIKey:
155
+ doc = self._key_to_dict(key)
156
+ try:
157
+ await self._collection.insert_one(doc)
158
+ return key
159
+ except self._motor.pymongo.errors.DuplicateKeyError:
160
+ provider_val = key.provider.value if isinstance(key.provider, Provider) else key.provider
161
+ raise DuplicateKeyError(provider=provider_val, key_value=key.key_value)
162
+
163
+ async def get_key(self, key_id: str) -> APIKey:
164
+ doc = await self._collection.find_one({"_id": key_id})
165
+ if not doc:
166
+ raise KeyNotFoundError(key_id=key_id)
167
+ return self._dict_to_key(doc)
168
+
169
+ async def get_keys_by_provider(self, provider: Provider) -> list[APIKey]:
170
+ provider_val = provider.value if isinstance(provider, Provider) else provider
171
+ cursor = self._collection.find({"provider": provider_val})
172
+ return [self._dict_to_key(doc) async for doc in cursor]
173
+
174
+ async def get_all_keys(self, key_filter: Optional[KeyFilter] = None) -> list[APIKey]:
175
+ query = {}
176
+
177
+ if key_filter:
178
+ if key_filter.provider is not None:
179
+ provider_val = key_filter.provider.value if isinstance(key_filter.provider, Provider) else key_filter.provider
180
+ query["provider"] = provider_val
181
+ if key_filter.status is not None:
182
+ status_val = key_filter.status.value if isinstance(key_filter.status, KeyStatus) else key_filter.status
183
+ query["status"] = status_val
184
+ if key_filter.environment is not None:
185
+ env_val = key_filter.environment.value if hasattr(key_filter.environment, "value") else key_filter.environment
186
+ query["environment"] = env_val
187
+ if key_filter.alias_contains:
188
+ query["alias"] = {"$regex": key_filter.alias_contains, "$options": "i"}
189
+ if key_filter.tags:
190
+ query["tags"] = {"$in": key_filter.tags}
191
+ if key_filter.metadata_filter:
192
+ for k, v in key_filter.metadata_filter.items():
193
+ query[f"metadata.{k}"] = v
194
+
195
+ # Sorting logic: priority asc, created_at asc
196
+ cursor = self._collection.find(query).sort([("priority", 1), ("created_at", 1)])
197
+ keys = [self._dict_to_key(doc) async for doc in cursor]
198
+
199
+ # Capacity filter applies in memory
200
+ if key_filter and key_filter.has_capacity is not None:
201
+ keys = [k for k in keys if k.has_capacity == key_filter.has_capacity]
202
+
203
+ return keys
204
+
205
+ async def update_key(self, key_id: str, updates: KeyUpdateRequest) -> APIKey:
206
+ # Check exists
207
+ await self.get_key(key_id)
208
+
209
+ update_data = updates.model_dump(exclude_unset=True)
210
+ if not update_data:
211
+ return await self.get_key(key_id)
212
+
213
+ # Handle enums
214
+ if "status" in update_data:
215
+ val = update_data["status"]
216
+ update_data["status"] = val.value if hasattr(val, "value") else val
217
+ if "environment" in update_data:
218
+ val = update_data["environment"]
219
+ update_data["environment"] = val.value if hasattr(val, "value") else val
220
+
221
+ # Handle datetimes
222
+ if "expires_at" in update_data and update_data["expires_at"]:
223
+ if isinstance(update_data["expires_at"], str):
224
+ update_data["expires_at"] = datetime.fromisoformat(update_data["expires_at"])
225
+
226
+ update_data["updated_at"] = datetime.now(timezone.utc)
227
+
228
+ await self._collection.update_one({"_id": key_id}, {"$set": update_data})
229
+ return await self.get_key(key_id)
230
+
231
+ async def delete_key(self, key_id: str, soft: bool = True) -> bool:
232
+ await self.get_key(key_id)
233
+
234
+ if soft:
235
+ now = datetime.now(timezone.utc)
236
+ await self._collection.update_one(
237
+ {"_id": key_id},
238
+ {"$set": {"status": "revoked", "updated_at": now}}
239
+ )
240
+ else:
241
+ await self._collection.delete_one({"_id": key_id})
242
+
243
+ return True
244
+
245
+ # ── Usage Tracking ─────────────────────────────────────────────────────
246
+
247
+ async def increment_usage(
248
+ self,
249
+ key_id: str,
250
+ daily: int = 1,
251
+ monthly: int = 1,
252
+ total: int = 1,
253
+ ) -> None:
254
+ now = datetime.now(timezone.utc)
255
+ await self._collection.update_one(
256
+ {"_id": key_id},
257
+ {
258
+ "$inc": {
259
+ "daily_usage_count": daily,
260
+ "monthly_usage_count": monthly,
261
+ "total_usage_count": total
262
+ },
263
+ "$set": {
264
+ "last_used_at": now,
265
+ "updated_at": now
266
+ }
267
+ }
268
+ )
269
+
270
+ async def update_concurrent_usage(self, key_id: str, delta: int) -> int:
271
+ now = datetime.now(timezone.utc)
272
+ import pymongo
273
+ # Aggregation pipeline update (MongoDB 4.2+) — atomically clamps to 0.
274
+ updated = await self._collection.find_one_and_update(
275
+ {"_id": key_id},
276
+ [
277
+ {
278
+ "$set": {
279
+ "concurrent_usage": {
280
+ "$max": [0, {"$add": ["$concurrent_usage", delta]}]
281
+ },
282
+ "updated_at": now,
283
+ }
284
+ }
285
+ ],
286
+ return_document=pymongo.ReturnDocument.AFTER,
287
+ )
288
+ if not updated:
289
+ raise KeyNotFoundError(key_id=key_id)
290
+ return updated.get("concurrent_usage", 0)
291
+
292
+ async def reset_daily_counts(self, before_date: date) -> int:
293
+ now = datetime.now(timezone.utc)
294
+ before_dt = datetime.combine(before_date, datetime.min.time(), tzinfo=timezone.utc)
295
+
296
+ result = await self._collection.update_many(
297
+ {"last_reset_daily": {"$lt": before_dt}},
298
+ {
299
+ "$set": {
300
+ "daily_usage_count": 0,
301
+ "last_reset_daily": before_dt,
302
+ "updated_at": now
303
+ }
304
+ }
305
+ )
306
+
307
+ # Recover rate-limited keys if monthly is ok
308
+ # For simplicity, we fetch rate_limited keys and check them
309
+ # It's harder to do a complex conditionally-update-many in Mongo based on other fields
310
+ cursor = self._collection.find({"status": "rate_limited"})
311
+ async for doc in cursor:
312
+ monthly_limit = doc.get("monthly_limit")
313
+ monthly_count = doc.get("monthly_usage_count", 0)
314
+ if monthly_limit is None or monthly_count < monthly_limit:
315
+ # also check daily, but we just reset it
316
+ daily_limit = doc.get("daily_limit")
317
+ daily_count = doc.get("daily_usage_count", 0)
318
+ if daily_limit is None or daily_count < daily_limit:
319
+ await self._collection.update_one(
320
+ {"_id": doc["_id"]},
321
+ {"$set": {"status": "active", "updated_at": now}}
322
+ )
323
+
324
+ return result.modified_count
325
+
326
+ async def reset_monthly_counts(self, before_date: date) -> int:
327
+ now = datetime.now(timezone.utc)
328
+ before_dt = datetime.combine(before_date, datetime.min.time(), tzinfo=timezone.utc)
329
+
330
+ # To reset correctly, we need to find keys where month or year differs
331
+ # MongoDB aggregation would be complex, let's just fetch last_reset_monthly
332
+ cursor = self._collection.find({}, {"_id": 1, "last_reset_monthly": 1, "status": 1})
333
+ ids_to_reset = []
334
+ async for doc in cursor:
335
+ last_reset = doc.get("last_reset_monthly")
336
+ if last_reset:
337
+ if isinstance(last_reset, datetime):
338
+ last_reset_date = last_reset.date()
339
+ else:
340
+ last_reset_date = date.fromisoformat(last_reset) if isinstance(last_reset, str) else last_reset
341
+
342
+ if last_reset_date.month != before_date.month or last_reset_date.year != before_date.year:
343
+ ids_to_reset.append(doc["_id"])
344
+
345
+ if not ids_to_reset:
346
+ return 0
347
+
348
+ await self._collection.update_many(
349
+ {"_id": {"$in": ids_to_reset}},
350
+ {
351
+ "$set": {
352
+ "monthly_usage_count": 0,
353
+ "last_reset_monthly": before_dt,
354
+ "updated_at": now
355
+ }
356
+ }
357
+ )
358
+
359
+ # Recover rate-limited
360
+ await self._collection.update_many(
361
+ {"_id": {"$in": ids_to_reset}, "status": "rate_limited"},
362
+ {"$set": {"status": "active", "updated_at": now}}
363
+ )
364
+
365
+ return len(ids_to_reset)
366
+
367
+ async def update_last_used(self, key_id: str) -> None:
368
+ now = datetime.now(timezone.utc)
369
+ await self._collection.update_one(
370
+ {"_id": key_id},
371
+ {"$set": {"last_used_at": now, "updated_at": now}}
372
+ )
373
+
374
+ # ── Bulk Operations ────────────────────────────────────────────────────
375
+
376
+ async def bulk_add_keys(self, keys: list[APIKey]) -> BulkOperationResult:
377
+ result = BulkOperationResult(total=len(keys))
378
+
379
+ if not keys:
380
+ return result
381
+
382
+ # For bulk add, doing one by one to catch individual errors and not fail all
383
+ for key in keys:
384
+ try:
385
+ await self.add_key(key)
386
+ result.successful += 1
387
+ result.created_ids.append(key.id)
388
+ except Exception as e:
389
+ result.failed += 1
390
+ result.errors.append(f"Key {key.alias or key.id}: {e}")
391
+
392
+ return result
393
+
394
+ async def bulk_delete_keys(self, key_ids: list[str], soft: bool = True) -> BulkOperationResult:
395
+ result = BulkOperationResult(total=len(key_ids))
396
+
397
+ if not key_ids:
398
+ return result
399
+
400
+ for key_id in key_ids:
401
+ try:
402
+ await self.delete_key(key_id, soft=soft)
403
+ result.successful += 1
404
+ except Exception as e:
405
+ result.failed += 1
406
+ result.errors.append(f"Key {key_id}: {e}")
407
+
408
+ return result
409
+
410
+ # ── Health ─────────────────────────────────────────────────────────────
411
+
412
+ async def health_check(self) -> bool:
413
+ if not self._client or not self._initialized:
414
+ return False
415
+ try:
416
+ await self._client.admin.command('ping')
417
+ return True
418
+ except Exception:
419
+ return False
420
+
421
+ async def count_keys(
422
+ self,
423
+ provider: Optional[Provider] = None,
424
+ status: Optional[KeyStatus] = None,
425
+ ) -> int:
426
+ query = {}
427
+ if provider is not None:
428
+ query["provider"] = provider.value if hasattr(provider, "value") else provider
429
+ if status is not None:
430
+ query["status"] = status.value if hasattr(status, "value") else status
431
+
432
+ return await self._collection.count_documents(query)