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,511 @@
1
+ """SQLite storage backend using aiosqlite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import date, datetime, timezone
7
+ from typing import Optional
8
+
9
+ from ..enums import Provider, KeyStatus
10
+ from ..exceptions import KeyNotFoundError, DuplicateKeyError, StorageConnectionError
11
+ from ..models import APIKey, KeyFilter, KeyUpdateRequest, BulkOperationResult
12
+ from .base import StorageBackend
13
+
14
+
15
+ def _try_import_aiosqlite():
16
+ """Lazy import aiosqlite with helpful error."""
17
+ try:
18
+ import aiosqlite
19
+ return aiosqlite
20
+ except ImportError:
21
+ raise ImportError(
22
+ "SQLite backend requires 'aiosqlite'. "
23
+ "Install with: pip install api-service-handler[sqlite]"
24
+ )
25
+
26
+
27
+ class SQLiteStorageBackend(StorageBackend):
28
+ """SQLite storage backend using aiosqlite.
29
+
30
+ Good default for single-service deployments and development.
31
+ Install with: pip install api-service-handler[sqlite]
32
+ """
33
+
34
+ def __init__(self, connection_string: str) -> None:
35
+ """Initialize SQLite backend.
36
+
37
+ Args:
38
+ connection_string: Path to SQLite DB file, e.g., 'sqlite:///keys.db' or '/path/to/keys.db'.
39
+ """
40
+ self._aiosqlite = _try_import_aiosqlite()
41
+ # Strip sqlite:/// prefix if present
42
+ if connection_string.startswith("sqlite:///"):
43
+ self._db_path = connection_string[len("sqlite:///"):]
44
+ elif connection_string.startswith("sqlite://"):
45
+ self._db_path = connection_string[len("sqlite://"):]
46
+ else:
47
+ self._db_path = connection_string
48
+ self._db = None
49
+ self._initialized = False
50
+
51
+ async def initialize(self) -> None:
52
+ """Create the SQLite database and tables."""
53
+ try:
54
+ self._db = await self._aiosqlite.connect(self._db_path)
55
+ await self._db.execute("PRAGMA journal_mode=WAL")
56
+ await self._db.execute("PRAGMA foreign_keys=ON")
57
+
58
+ await self._db.execute("""
59
+ CREATE TABLE IF NOT EXISTS api_keys (
60
+ id TEXT PRIMARY KEY,
61
+ provider TEXT NOT NULL,
62
+ key_value TEXT NOT NULL,
63
+ alias TEXT,
64
+ status TEXT NOT NULL DEFAULT 'active',
65
+ environment TEXT NOT NULL DEFAULT 'production',
66
+ daily_limit INTEGER,
67
+ monthly_limit INTEGER,
68
+ daily_usage_count INTEGER NOT NULL DEFAULT 0,
69
+ monthly_usage_count INTEGER NOT NULL DEFAULT 0,
70
+ total_usage_count INTEGER NOT NULL DEFAULT 0,
71
+ concurrent_usage INTEGER NOT NULL DEFAULT 0,
72
+ max_concurrent INTEGER,
73
+ weight INTEGER NOT NULL DEFAULT 1,
74
+ priority INTEGER NOT NULL DEFAULT 0,
75
+ metadata TEXT NOT NULL DEFAULT '{}',
76
+ tags TEXT NOT NULL DEFAULT '[]',
77
+ last_used_at TEXT,
78
+ last_reset_daily TEXT NOT NULL,
79
+ last_reset_monthly TEXT NOT NULL,
80
+ expires_at TEXT,
81
+ created_at TEXT NOT NULL,
82
+ updated_at TEXT NOT NULL
83
+ )
84
+ """)
85
+
86
+ await self._db.execute(
87
+ "CREATE INDEX IF NOT EXISTS idx_api_keys_provider ON api_keys(provider)"
88
+ )
89
+ await self._db.execute(
90
+ "CREATE INDEX IF NOT EXISTS idx_api_keys_status ON api_keys(status)"
91
+ )
92
+ await self._db.execute(
93
+ "CREATE INDEX IF NOT EXISTS idx_api_keys_provider_status ON api_keys(provider, status)"
94
+ )
95
+
96
+ await self._db.commit()
97
+ self._initialized = True
98
+
99
+ except Exception as e:
100
+ raise StorageConnectionError(backend="sqlite", details=str(e))
101
+
102
+ async def close(self) -> None:
103
+ """Close the SQLite connection."""
104
+ if self._db:
105
+ await self._db.close()
106
+ self._db = None
107
+ self._initialized = False
108
+
109
+ def _row_to_key(self, row: dict) -> APIKey:
110
+ """Convert a database row to an APIKey model."""
111
+ data = dict(row)
112
+ # Parse JSON fields
113
+ data["metadata"] = json.loads(data.get("metadata", "{}"))
114
+ data["tags"] = json.loads(data.get("tags", "[]"))
115
+
116
+ # Parse datetime fields
117
+ for dt_field in ("last_used_at", "expires_at", "created_at", "updated_at"):
118
+ if data.get(dt_field):
119
+ data[dt_field] = datetime.fromisoformat(data[dt_field])
120
+ elif dt_field in ("created_at", "updated_at"):
121
+ data[dt_field] = datetime.now(timezone.utc)
122
+
123
+ # Parse date fields
124
+ for d_field in ("last_reset_daily", "last_reset_monthly"):
125
+ if data.get(d_field):
126
+ data[d_field] = date.fromisoformat(data[d_field])
127
+ else:
128
+ data[d_field] = date.today()
129
+
130
+ return APIKey(**data)
131
+
132
+ def _key_to_params(self, key: APIKey) -> dict:
133
+ """Convert an APIKey model to SQLite insert parameters."""
134
+ return {
135
+ "id": key.id,
136
+ "provider": key.provider.value if isinstance(key.provider, Provider) else key.provider,
137
+ "key_value": key.key_value,
138
+ "alias": key.alias,
139
+ "status": key.status.value if isinstance(key.status, KeyStatus) else key.status,
140
+ "environment": key.environment.value if hasattr(key.environment, "value") else key.environment,
141
+ "daily_limit": key.daily_limit,
142
+ "monthly_limit": key.monthly_limit,
143
+ "daily_usage_count": key.daily_usage_count,
144
+ "monthly_usage_count": key.monthly_usage_count,
145
+ "total_usage_count": key.total_usage_count,
146
+ "concurrent_usage": key.concurrent_usage,
147
+ "max_concurrent": key.max_concurrent,
148
+ "weight": key.weight,
149
+ "priority": key.priority,
150
+ "metadata": json.dumps(key.metadata),
151
+ "tags": json.dumps(key.tags),
152
+ "last_used_at": key.last_used_at.isoformat() if key.last_used_at else None,
153
+ "last_reset_daily": key.last_reset_daily.isoformat(),
154
+ "last_reset_monthly": key.last_reset_monthly.isoformat(),
155
+ "expires_at": key.expires_at.isoformat() if key.expires_at else None,
156
+ "created_at": key.created_at.isoformat(),
157
+ "updated_at": key.updated_at.isoformat(),
158
+ }
159
+
160
+ # ── CRUD ───────────────────────────────────────────────────────────────
161
+
162
+ async def add_key(self, key: APIKey) -> APIKey:
163
+ """Insert a new key into SQLite."""
164
+ # Check for duplicates
165
+ provider_val = key.provider.value if isinstance(key.provider, Provider) else key.provider
166
+ async with self._db.execute(
167
+ "SELECT id FROM api_keys WHERE key_value = ? AND provider = ? AND status != 'revoked'",
168
+ (key.key_value, provider_val),
169
+ ) as cursor:
170
+ if await cursor.fetchone():
171
+ raise DuplicateKeyError(provider=provider_val, key_value=key.key_value)
172
+
173
+ params = self._key_to_params(key)
174
+ columns = ", ".join(params.keys())
175
+ placeholders = ", ".join(f":{k}" for k in params.keys())
176
+
177
+ await self._db.execute(
178
+ f"INSERT INTO api_keys ({columns}) VALUES ({placeholders})",
179
+ params,
180
+ )
181
+ await self._db.commit()
182
+ return key
183
+
184
+ async def get_key(self, key_id: str) -> APIKey:
185
+ """Get a key by ID."""
186
+ self._db.row_factory = self._aiosqlite.Row
187
+ async with self._db.execute(
188
+ "SELECT * FROM api_keys WHERE id = ?", (key_id,)
189
+ ) as cursor:
190
+ row = await cursor.fetchone()
191
+ if not row:
192
+ raise KeyNotFoundError(key_id=key_id)
193
+ return self._row_to_key(dict(row))
194
+
195
+ async def get_keys_by_provider(self, provider: Provider) -> list[APIKey]:
196
+ """Get all keys for a provider."""
197
+ provider_val = provider.value if isinstance(provider, Provider) else provider
198
+ self._db.row_factory = self._aiosqlite.Row
199
+ async with self._db.execute(
200
+ "SELECT * FROM api_keys WHERE provider = ?", (provider_val,)
201
+ ) as cursor:
202
+ rows = await cursor.fetchall()
203
+ return [self._row_to_key(dict(row)) for row in rows]
204
+
205
+ async def get_all_keys(self, key_filter: Optional[KeyFilter] = None) -> list[APIKey]:
206
+ """Get all keys with optional filtering."""
207
+ self._db.row_factory = self._aiosqlite.Row
208
+
209
+ query = "SELECT * FROM api_keys"
210
+ params: list = []
211
+ conditions: list[str] = []
212
+
213
+ if key_filter:
214
+ if key_filter.provider is not None:
215
+ provider_val = (
216
+ key_filter.provider.value
217
+ if isinstance(key_filter.provider, Provider)
218
+ else key_filter.provider
219
+ )
220
+ conditions.append("provider = ?")
221
+ params.append(provider_val)
222
+ if key_filter.status is not None:
223
+ status_val = (
224
+ key_filter.status.value
225
+ if isinstance(key_filter.status, KeyStatus)
226
+ else key_filter.status
227
+ )
228
+ conditions.append("status = ?")
229
+ params.append(status_val)
230
+ if key_filter.environment is not None:
231
+ env_val = (
232
+ key_filter.environment.value
233
+ if hasattr(key_filter.environment, "value")
234
+ else key_filter.environment
235
+ )
236
+ conditions.append("environment = ?")
237
+ params.append(env_val)
238
+ if key_filter.alias_contains:
239
+ conditions.append("alias LIKE ?")
240
+ params.append(f"%{key_filter.alias_contains}%")
241
+
242
+ if conditions:
243
+ query += " WHERE " + " AND ".join(conditions)
244
+
245
+ query += " ORDER BY priority ASC, created_at ASC"
246
+
247
+ async with self._db.execute(query, params) as cursor:
248
+ rows = await cursor.fetchall()
249
+ keys = [self._row_to_key(dict(row)) for row in rows]
250
+
251
+ # Apply in-memory filters that can't be done in SQL easily
252
+ if key_filter:
253
+ if key_filter.tags:
254
+ keys = [
255
+ k for k in keys
256
+ if any(tag in k.tags for tag in key_filter.tags)
257
+ ]
258
+ if key_filter.metadata_filter:
259
+ keys = [
260
+ k for k in keys
261
+ if all(
262
+ k.metadata.get(mk) == mv
263
+ for mk, mv in key_filter.metadata_filter.items()
264
+ )
265
+ ]
266
+ if key_filter.has_capacity is not None:
267
+ keys = [k for k in keys if k.has_capacity == key_filter.has_capacity]
268
+
269
+ return keys
270
+
271
+ async def update_key(self, key_id: str, updates: KeyUpdateRequest) -> APIKey:
272
+ """Update a key's fields."""
273
+ # Verify key exists
274
+ await self.get_key(key_id)
275
+
276
+ update_data = updates.model_dump(exclude_unset=True)
277
+ if not update_data:
278
+ return await self.get_key(key_id)
279
+
280
+ update_data["updated_at"] = datetime.now(timezone.utc).isoformat()
281
+
282
+ # Serialize complex fields
283
+ if "metadata" in update_data:
284
+ update_data["metadata"] = json.dumps(update_data["metadata"])
285
+ if "tags" in update_data:
286
+ update_data["tags"] = json.dumps(update_data["tags"])
287
+ if "expires_at" in update_data and update_data["expires_at"]:
288
+ update_data["expires_at"] = update_data["expires_at"].isoformat()
289
+ if "status" in update_data:
290
+ val = update_data["status"]
291
+ update_data["status"] = val.value if hasattr(val, "value") else val
292
+ if "environment" in update_data:
293
+ val = update_data["environment"]
294
+ update_data["environment"] = val.value if hasattr(val, "value") else val
295
+
296
+ set_clause = ", ".join(f"{k} = :{k}" for k in update_data.keys())
297
+ update_data["_id"] = key_id
298
+
299
+ await self._db.execute(
300
+ f"UPDATE api_keys SET {set_clause} WHERE id = :_id",
301
+ update_data,
302
+ )
303
+ await self._db.commit()
304
+ return await self.get_key(key_id)
305
+
306
+ async def delete_key(self, key_id: str, soft: bool = True) -> bool:
307
+ """Delete or revoke a key."""
308
+ await self.get_key(key_id) # Verify exists
309
+
310
+ if soft:
311
+ now = datetime.now(timezone.utc).isoformat()
312
+ await self._db.execute(
313
+ "UPDATE api_keys SET status = 'revoked', updated_at = ? WHERE id = ?",
314
+ (now, key_id),
315
+ )
316
+ else:
317
+ await self._db.execute("DELETE FROM api_keys WHERE id = ?", (key_id,))
318
+
319
+ await self._db.commit()
320
+ return True
321
+
322
+ # ── Usage Tracking ─────────────────────────────────────────────────────
323
+
324
+ async def increment_usage(
325
+ self,
326
+ key_id: str,
327
+ daily: int = 1,
328
+ monthly: int = 1,
329
+ total: int = 1,
330
+ ) -> None:
331
+ """Atomically increment usage counters."""
332
+ now = datetime.now(timezone.utc).isoformat()
333
+ await self._db.execute(
334
+ """
335
+ UPDATE api_keys
336
+ SET daily_usage_count = daily_usage_count + ?,
337
+ monthly_usage_count = monthly_usage_count + ?,
338
+ total_usage_count = total_usage_count + ?,
339
+ last_used_at = ?,
340
+ updated_at = ?
341
+ WHERE id = ?
342
+ """,
343
+ (daily, monthly, total, now, now, key_id),
344
+ )
345
+ await self._db.commit()
346
+
347
+ async def update_concurrent_usage(self, key_id: str, delta: int) -> int:
348
+ """Adjust concurrent usage."""
349
+ now = datetime.now(timezone.utc).isoformat()
350
+ await self._db.execute(
351
+ """
352
+ UPDATE api_keys
353
+ SET concurrent_usage = MAX(0, concurrent_usage + ?),
354
+ updated_at = ?
355
+ WHERE id = ?
356
+ """,
357
+ (delta, now, key_id),
358
+ )
359
+ await self._db.commit()
360
+
361
+ self._db.row_factory = self._aiosqlite.Row
362
+ async with self._db.execute(
363
+ "SELECT concurrent_usage FROM api_keys WHERE id = ?", (key_id,)
364
+ ) as cursor:
365
+ row = await cursor.fetchone()
366
+ if not row:
367
+ raise KeyNotFoundError(key_id=key_id)
368
+ return row["concurrent_usage"]
369
+
370
+ async def reset_daily_counts(self, before_date: date) -> int:
371
+ """Reset daily counts."""
372
+ before_str = before_date.isoformat()
373
+ now = datetime.now(timezone.utc).isoformat()
374
+
375
+ cursor = await self._db.execute(
376
+ """
377
+ UPDATE api_keys
378
+ SET daily_usage_count = 0,
379
+ last_reset_daily = ?,
380
+ updated_at = ?
381
+ WHERE last_reset_daily < ?
382
+ """,
383
+ (before_str, now, before_str),
384
+ )
385
+ # Recover rate-limited keys if monthly is ok
386
+ await self._db.execute(
387
+ """
388
+ UPDATE api_keys
389
+ SET status = 'active'
390
+ WHERE status = 'rate_limited'
391
+ AND (monthly_limit IS NULL OR monthly_usage_count < monthly_limit)
392
+ """,
393
+ )
394
+ await self._db.commit()
395
+ return cursor.rowcount
396
+
397
+ async def reset_monthly_counts(self, before_date: date) -> int:
398
+ """Reset monthly counts."""
399
+ before_str = before_date.isoformat()
400
+ now = datetime.now(timezone.utc).isoformat()
401
+
402
+ # Get keys that need reset (different month/year)
403
+ self._db.row_factory = self._aiosqlite.Row
404
+ async with self._db.execute(
405
+ "SELECT id, last_reset_monthly FROM api_keys"
406
+ ) as cursor:
407
+ rows = await cursor.fetchall()
408
+
409
+ ids_to_reset = []
410
+ for row in rows:
411
+ last_reset = date.fromisoformat(row["last_reset_monthly"])
412
+ if last_reset.month != before_date.month or last_reset.year != before_date.year:
413
+ ids_to_reset.append(row["id"])
414
+
415
+ if not ids_to_reset:
416
+ return 0
417
+
418
+ placeholders = ",".join("?" for _ in ids_to_reset)
419
+ await self._db.execute(
420
+ f"""
421
+ UPDATE api_keys
422
+ SET monthly_usage_count = 0,
423
+ last_reset_monthly = ?,
424
+ status = CASE WHEN status = 'rate_limited' THEN 'active' ELSE status END,
425
+ updated_at = ?
426
+ WHERE id IN ({placeholders})
427
+ """,
428
+ [before_str, now] + ids_to_reset,
429
+ )
430
+ await self._db.commit()
431
+ return len(ids_to_reset)
432
+
433
+ async def update_last_used(self, key_id: str) -> None:
434
+ """Update last_used_at."""
435
+ now = datetime.now(timezone.utc).isoformat()
436
+ await self._db.execute(
437
+ "UPDATE api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?",
438
+ (now, now, key_id),
439
+ )
440
+ await self._db.commit()
441
+
442
+ # ── Bulk Operations ────────────────────────────────────────────────────
443
+
444
+ async def bulk_add_keys(self, keys: list[APIKey]) -> BulkOperationResult:
445
+ """Insert multiple keys."""
446
+ result = BulkOperationResult(total=len(keys))
447
+
448
+ for key in keys:
449
+ try:
450
+ await self.add_key(key)
451
+ result.successful += 1
452
+ result.created_ids.append(key.id)
453
+ except Exception as e:
454
+ result.failed += 1
455
+ result.errors.append(f"Key {key.alias or key.id}: {e}")
456
+
457
+ return result
458
+
459
+ async def bulk_delete_keys(self, key_ids: list[str], soft: bool = True) -> BulkOperationResult:
460
+ """Delete multiple keys."""
461
+ result = BulkOperationResult(total=len(key_ids))
462
+
463
+ for key_id in key_ids:
464
+ try:
465
+ await self.delete_key(key_id, soft=soft)
466
+ result.successful += 1
467
+ except Exception as e:
468
+ result.failed += 1
469
+ result.errors.append(f"Key {key_id}: {e}")
470
+
471
+ return result
472
+
473
+ # ── Health ─────────────────────────────────────────────────────────────
474
+
475
+ async def health_check(self) -> bool:
476
+ """Check SQLite is accessible."""
477
+ if not self._db or not self._initialized:
478
+ return False
479
+ try:
480
+ async with self._db.execute("SELECT 1") as cursor:
481
+ await cursor.fetchone()
482
+ return True
483
+ except Exception:
484
+ return False
485
+
486
+ async def count_keys(
487
+ self,
488
+ provider: Optional[Provider] = None,
489
+ status: Optional[KeyStatus] = None,
490
+ ) -> int:
491
+ """Count keys matching criteria."""
492
+ query = "SELECT COUNT(*) as cnt FROM api_keys"
493
+ params: list = []
494
+ conditions: list[str] = []
495
+
496
+ if provider is not None:
497
+ provider_val = provider.value if isinstance(provider, Provider) else provider
498
+ conditions.append("provider = ?")
499
+ params.append(provider_val)
500
+ if status is not None:
501
+ status_val = status.value if isinstance(status, KeyStatus) else status
502
+ conditions.append("status = ?")
503
+ params.append(status_val)
504
+
505
+ if conditions:
506
+ query += " WHERE " + " AND ".join(conditions)
507
+
508
+ self._db.row_factory = self._aiosqlite.Row
509
+ async with self._db.execute(query, params) as cursor:
510
+ row = await cursor.fetchone()
511
+ return row["cnt"] if row else 0