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.
- api_service_handler/__init__.py +77 -0
- api_service_handler/cli.py +341 -0
- api_service_handler/client.py +868 -0
- api_service_handler/config.py +177 -0
- api_service_handler/encryption.py +238 -0
- api_service_handler/enums.py +217 -0
- api_service_handler/exceptions.py +184 -0
- api_service_handler/models.py +301 -0
- api_service_handler/py.typed +0 -0
- api_service_handler/rate_limiter.py +187 -0
- api_service_handler/rotation.py +163 -0
- api_service_handler/storage/__init__.py +7 -0
- api_service_handler/storage/base.py +243 -0
- api_service_handler/storage/memory.py +229 -0
- api_service_handler/storage/mongodb.py +432 -0
- api_service_handler/storage/postgresql.py +429 -0
- api_service_handler/storage/sqlite.py +511 -0
- api_service_handler/usage_tracker.py +219 -0
- api_service_handler/utils.py +322 -0
- api_service_handler-0.1.6.dist-info/METADATA +282 -0
- api_service_handler-0.1.6.dist-info/RECORD +23 -0
- api_service_handler-0.1.6.dist-info/WHEEL +4 -0
- api_service_handler-0.1.6.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Enterprise API key management: rotation, rate-limiting, usage tracking, multi-backend storage.
|
|
2
|
+
|
|
3
|
+
A reusable Python library for managing API keys across all your business services.
|
|
4
|
+
Built with uv, supports multiple storage backends, and provides round-robin rotation,
|
|
5
|
+
rate limiting, usage tracking, concurrent usage control, and rich metadata.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .client import APIServiceHandler, SyncAPIServiceHandler
|
|
11
|
+
from .config import ASHConfig, get_config_from_env
|
|
12
|
+
from .enums import Environment, KeyStatus, Provider, RotationStrategy, StorageBackend
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
APIServiceHandlerError,
|
|
15
|
+
DuplicateKeyError,
|
|
16
|
+
EncryptionError,
|
|
17
|
+
InvalidProviderError,
|
|
18
|
+
KeyExpiredError,
|
|
19
|
+
KeyNotFoundError,
|
|
20
|
+
MaxConcurrentExceededError,
|
|
21
|
+
NoAvailableKeyError,
|
|
22
|
+
RateLimitExceededError,
|
|
23
|
+
StorageConnectionError,
|
|
24
|
+
StorageNotInitializedError,
|
|
25
|
+
)
|
|
26
|
+
from .models import (
|
|
27
|
+
APIKey,
|
|
28
|
+
BulkOperationResult,
|
|
29
|
+
KeyCreateRequest,
|
|
30
|
+
KeyFilter,
|
|
31
|
+
KeyUpdateRequest,
|
|
32
|
+
UsageStats,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
import importlib.metadata
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
__version__ = importlib.metadata.version("api-service-handler")
|
|
39
|
+
except importlib.metadata.PackageNotFoundError:
|
|
40
|
+
__version__ = "unknown"
|
|
41
|
+
__all__ = [
|
|
42
|
+
# Main clients
|
|
43
|
+
"APIServiceHandler",
|
|
44
|
+
"SyncAPIServiceHandler",
|
|
45
|
+
|
|
46
|
+
# Configuration
|
|
47
|
+
"ASHConfig",
|
|
48
|
+
"get_config_from_env",
|
|
49
|
+
|
|
50
|
+
# Enums
|
|
51
|
+
"Environment",
|
|
52
|
+
"KeyStatus",
|
|
53
|
+
"Provider",
|
|
54
|
+
"RotationStrategy",
|
|
55
|
+
"StorageBackend",
|
|
56
|
+
|
|
57
|
+
# Models
|
|
58
|
+
"APIKey",
|
|
59
|
+
"BulkOperationResult",
|
|
60
|
+
"KeyCreateRequest",
|
|
61
|
+
"KeyFilter",
|
|
62
|
+
"KeyUpdateRequest",
|
|
63
|
+
"UsageStats",
|
|
64
|
+
|
|
65
|
+
# Exceptions
|
|
66
|
+
"APIServiceHandlerError",
|
|
67
|
+
"DuplicateKeyError",
|
|
68
|
+
"EncryptionError",
|
|
69
|
+
"InvalidProviderError",
|
|
70
|
+
"KeyExpiredError",
|
|
71
|
+
"KeyNotFoundError",
|
|
72
|
+
"MaxConcurrentExceededError",
|
|
73
|
+
"NoAvailableKeyError",
|
|
74
|
+
"RateLimitExceededError",
|
|
75
|
+
"StorageConnectionError",
|
|
76
|
+
"StorageNotInitializedError",
|
|
77
|
+
]
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Command Line Interface for the API Service Handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from .client import APIServiceHandler
|
|
16
|
+
from .config import get_config_from_env
|
|
17
|
+
from .enums import Provider, KeyStatus, Environment
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
def coro(f):
|
|
22
|
+
@wraps(f)
|
|
23
|
+
def wrapper(*args, **kwargs):
|
|
24
|
+
return asyncio.run(f(*args, **kwargs))
|
|
25
|
+
return wrapper
|
|
26
|
+
|
|
27
|
+
@click.group()
|
|
28
|
+
@click.pass_context
|
|
29
|
+
def cli(ctx: click.Context):
|
|
30
|
+
"""API Service Handler (ash) CLI.
|
|
31
|
+
|
|
32
|
+
Enterprise API key management: rotation, rate-limiting, usage tracking.
|
|
33
|
+
"""
|
|
34
|
+
# Load configuration from environment
|
|
35
|
+
config = get_config_from_env()
|
|
36
|
+
ctx.ensure_object(dict)
|
|
37
|
+
ctx.obj['config'] = config
|
|
38
|
+
|
|
39
|
+
# Do not initialize handler yet, we only need it for subcommands
|
|
40
|
+
# and they might have their own error handling.
|
|
41
|
+
|
|
42
|
+
async def get_handler(ctx: click.Context) -> APIServiceHandler:
|
|
43
|
+
config = ctx.obj['config']
|
|
44
|
+
handler = APIServiceHandler(config=config)
|
|
45
|
+
try:
|
|
46
|
+
await handler.initialize()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
console.print(f"[bold red]Failed to initialize storage:[/bold red] {e}")
|
|
49
|
+
ctx.exit(1)
|
|
50
|
+
return handler
|
|
51
|
+
|
|
52
|
+
@cli.group()
|
|
53
|
+
def keys():
|
|
54
|
+
"""Manage API keys."""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
@keys.command(name="add")
|
|
58
|
+
@click.option('--provider', required=True, help='API provider name (e.g. openai)')
|
|
59
|
+
@click.option('--key', required=True, help='The API key value')
|
|
60
|
+
@click.option('--alias', help='Optional human-friendly name')
|
|
61
|
+
@click.option('--daily-limit', type=int, help='Max requests per day')
|
|
62
|
+
@click.option('--monthly-limit', type=int, help='Max requests per month')
|
|
63
|
+
@click.option('--max-concurrent', type=int, help='Max simultaneous uses')
|
|
64
|
+
@click.option('--environment', default='production', help='Deployment environment')
|
|
65
|
+
@click.pass_context
|
|
66
|
+
@coro
|
|
67
|
+
async def keys_add(ctx: click.Context, provider: str, key: str, alias: Optional[str],
|
|
68
|
+
daily_limit: Optional[int], monthly_limit: Optional[int],
|
|
69
|
+
max_concurrent: Optional[int], environment: str):
|
|
70
|
+
"""Add a new API key."""
|
|
71
|
+
handler = await get_handler(ctx)
|
|
72
|
+
try:
|
|
73
|
+
api_key = await handler.add_key(
|
|
74
|
+
provider=provider,
|
|
75
|
+
key_value=key,
|
|
76
|
+
alias=alias,
|
|
77
|
+
daily_limit=daily_limit,
|
|
78
|
+
monthly_limit=monthly_limit,
|
|
79
|
+
max_concurrent=max_concurrent,
|
|
80
|
+
environment=environment
|
|
81
|
+
)
|
|
82
|
+
console.print(f"[bold green]Successfully added key![/bold green]")
|
|
83
|
+
console.print(f"ID: {api_key.id}")
|
|
84
|
+
provider_str = api_key.provider if isinstance(api_key.provider, str) else api_key.provider.value
|
|
85
|
+
console.print(f"Provider: {provider_str}")
|
|
86
|
+
if alias:
|
|
87
|
+
console.print(f"Alias: {api_key.alias}")
|
|
88
|
+
except Exception as e:
|
|
89
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
90
|
+
finally:
|
|
91
|
+
await handler.close()
|
|
92
|
+
|
|
93
|
+
@keys.command(name="list")
|
|
94
|
+
@click.option('--provider', help='Filter by provider')
|
|
95
|
+
@click.option('--status', help='Filter by status (e.g. active, revoked)')
|
|
96
|
+
@click.option('--show-keys', is_flag=True, help='Display full key values instead of masking')
|
|
97
|
+
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
|
|
98
|
+
@click.pass_context
|
|
99
|
+
@coro
|
|
100
|
+
async def keys_list(ctx: click.Context, provider: Optional[str], status: Optional[str],
|
|
101
|
+
show_keys: bool, as_json: bool):
|
|
102
|
+
"""List API keys."""
|
|
103
|
+
handler = await get_handler(ctx)
|
|
104
|
+
try:
|
|
105
|
+
api_keys = await handler.get_all_keys(
|
|
106
|
+
provider=provider,
|
|
107
|
+
status=status,
|
|
108
|
+
decrypt=show_keys
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if as_json:
|
|
112
|
+
# Pydantic v2 compatible
|
|
113
|
+
data = [k.model_dump(mode="json") for k in api_keys]
|
|
114
|
+
console.print(json.dumps(data, indent=2))
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
table = Table(title="API Keys")
|
|
118
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
119
|
+
table.add_column("Provider", style="magenta")
|
|
120
|
+
table.add_column("Alias")
|
|
121
|
+
table.add_column("Key Value")
|
|
122
|
+
table.add_column("Status")
|
|
123
|
+
table.add_column("Usage (D/M/T)")
|
|
124
|
+
|
|
125
|
+
for k in api_keys:
|
|
126
|
+
key_val = k.key_value if show_keys else f"{k.key_value[:8]}***" if len(k.key_value) > 8 else "***"
|
|
127
|
+
|
|
128
|
+
provider_str = k.provider if isinstance(k.provider, str) else k.provider.value
|
|
129
|
+
status_str = k.status if isinstance(k.status, str) else k.status.value
|
|
130
|
+
status_color = "green" if status_str == "active" else "red" if status_str == "revoked" else "yellow"
|
|
131
|
+
status_text = f"[{status_color}]{status_str}[/{status_color}]"
|
|
132
|
+
|
|
133
|
+
usage_text = f"{k.daily_usage_count}/{k.monthly_usage_count}/{k.total_usage_count}"
|
|
134
|
+
|
|
135
|
+
table.add_row(
|
|
136
|
+
k.id[:8] + "...",
|
|
137
|
+
provider_str,
|
|
138
|
+
k.alias or "-",
|
|
139
|
+
key_val,
|
|
140
|
+
status_text,
|
|
141
|
+
usage_text
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
console.print(table)
|
|
145
|
+
console.print(f"Total keys: {len(api_keys)}")
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
149
|
+
finally:
|
|
150
|
+
await handler.close()
|
|
151
|
+
|
|
152
|
+
@keys.command(name="get")
|
|
153
|
+
@click.argument('key_id')
|
|
154
|
+
@click.option('--show-key', is_flag=True, help='Display full key value instead of masking')
|
|
155
|
+
@click.pass_context
|
|
156
|
+
@coro
|
|
157
|
+
async def keys_get(ctx: click.Context, key_id: str, show_key: bool):
|
|
158
|
+
"""Get details for a specific API key."""
|
|
159
|
+
handler = await get_handler(ctx)
|
|
160
|
+
try:
|
|
161
|
+
key = await handler.get_key(key_id, decrypt=show_key)
|
|
162
|
+
|
|
163
|
+
console.print(f"[bold]Key Details for [cyan]{key.id}[/cyan][/bold]")
|
|
164
|
+
provider_str = key.provider if isinstance(key.provider, str) else key.provider.value
|
|
165
|
+
console.print(f"Provider: {provider_str}")
|
|
166
|
+
console.print(f"Alias: {key.alias or '-'}")
|
|
167
|
+
key_val = key.key_value if show_key else f"{key.key_value[:8]}***" if len(key.key_value) > 8 else "***"
|
|
168
|
+
console.print(f"Key Value: {key_val}")
|
|
169
|
+
|
|
170
|
+
status_str = key.status if isinstance(key.status, str) else key.status.value
|
|
171
|
+
status_color = "green" if status_str == "active" else "red" if status_str == "revoked" else "yellow"
|
|
172
|
+
console.print(f"Status: [{status_color}]{status_str}[/{status_color}]")
|
|
173
|
+
|
|
174
|
+
env_str = key.environment if isinstance(key.environment, str) else key.environment.value
|
|
175
|
+
console.print(f"Environment: {env_str}")
|
|
176
|
+
|
|
177
|
+
console.print("\n[bold]Rate Limits & Usage[/bold]")
|
|
178
|
+
console.print(f"Daily Limit: {key.daily_limit or 'Unlimited'}")
|
|
179
|
+
console.print(f"Daily Usage: {key.daily_usage_count}")
|
|
180
|
+
console.print(f"Monthly Limit: {key.monthly_limit or 'Unlimited'}")
|
|
181
|
+
console.print(f"Monthly Usage: {key.monthly_usage_count}")
|
|
182
|
+
console.print(f"Total Usage: {key.total_usage_count}")
|
|
183
|
+
console.print(f"Concurrent Max: {key.max_concurrent or 'Unlimited'}")
|
|
184
|
+
console.print(f"Concurrent Curr: {key.concurrent_usage}")
|
|
185
|
+
|
|
186
|
+
except Exception as e:
|
|
187
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
188
|
+
finally:
|
|
189
|
+
await handler.close()
|
|
190
|
+
|
|
191
|
+
@keys.command(name="update")
|
|
192
|
+
@click.argument('key_id')
|
|
193
|
+
@click.option('--alias', help='New human-friendly name')
|
|
194
|
+
@click.option('--status', help='New status (e.g. active, inactive)')
|
|
195
|
+
@click.option('--daily-limit', type=int, help='New max requests per day')
|
|
196
|
+
@click.option('--monthly-limit', type=int, help='New max requests per month')
|
|
197
|
+
@click.pass_context
|
|
198
|
+
@coro
|
|
199
|
+
async def keys_update(ctx: click.Context, key_id: str, alias: Optional[str],
|
|
200
|
+
status: Optional[str], daily_limit: Optional[int],
|
|
201
|
+
monthly_limit: Optional[int]):
|
|
202
|
+
"""Update an existing API key."""
|
|
203
|
+
handler = await get_handler(ctx)
|
|
204
|
+
try:
|
|
205
|
+
updated = await handler.update_key(
|
|
206
|
+
key_id,
|
|
207
|
+
alias=alias,
|
|
208
|
+
status=status,
|
|
209
|
+
daily_limit=daily_limit,
|
|
210
|
+
monthly_limit=monthly_limit
|
|
211
|
+
)
|
|
212
|
+
console.print(f"[bold green]Successfully updated key {updated.id}[/bold green]")
|
|
213
|
+
except Exception as e:
|
|
214
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
215
|
+
finally:
|
|
216
|
+
await handler.close()
|
|
217
|
+
|
|
218
|
+
@keys.command(name="delete")
|
|
219
|
+
@click.argument('key_id')
|
|
220
|
+
@click.option('--hard', is_flag=True, help='Hard delete from database instead of revoking')
|
|
221
|
+
@click.pass_context
|
|
222
|
+
@coro
|
|
223
|
+
async def keys_delete(ctx: click.Context, key_id: str, hard: bool):
|
|
224
|
+
"""Delete or revoke an API key."""
|
|
225
|
+
handler = await get_handler(ctx)
|
|
226
|
+
try:
|
|
227
|
+
if not click.confirm(f"Are you sure you want to {'hard ' if hard else 'soft '}delete key {key_id}?"):
|
|
228
|
+
ctx.exit(0)
|
|
229
|
+
|
|
230
|
+
await handler.delete_key(key_id, hard=hard)
|
|
231
|
+
console.print(f"[bold green]Successfully deleted key {key_id}[/bold green]")
|
|
232
|
+
except Exception as e:
|
|
233
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
234
|
+
finally:
|
|
235
|
+
await handler.close()
|
|
236
|
+
|
|
237
|
+
@cli.group()
|
|
238
|
+
def usage():
|
|
239
|
+
"""View and reset usage statistics."""
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
@usage.command(name="stats")
|
|
243
|
+
@click.argument('key_id')
|
|
244
|
+
@click.pass_context
|
|
245
|
+
@coro
|
|
246
|
+
async def usage_stats(ctx: click.Context, key_id: str):
|
|
247
|
+
"""Get usage statistics for a specific key."""
|
|
248
|
+
handler = await get_handler(ctx)
|
|
249
|
+
try:
|
|
250
|
+
stats = await handler.get_usage_stats(key_id)
|
|
251
|
+
|
|
252
|
+
table = Table(title=f"Usage Stats for {key_id}")
|
|
253
|
+
table.add_column("Metric", style="cyan")
|
|
254
|
+
table.add_column("Value")
|
|
255
|
+
|
|
256
|
+
provider_str = stats.provider if isinstance(stats.provider, str) else stats.provider.value
|
|
257
|
+
table.add_row("Provider", provider_str)
|
|
258
|
+
if stats.alias:
|
|
259
|
+
table.add_row("Alias", stats.alias)
|
|
260
|
+
|
|
261
|
+
table.add_row("Daily Usage", str(stats.daily_usage_count))
|
|
262
|
+
table.add_row("Daily Remaining", str(stats.daily_remaining) if stats.daily_remaining is not None else "Unlimited")
|
|
263
|
+
|
|
264
|
+
table.add_row("Monthly Usage", str(stats.monthly_usage_count))
|
|
265
|
+
table.add_row("Monthly Remaining", str(stats.monthly_remaining) if stats.monthly_remaining is not None else "Unlimited")
|
|
266
|
+
|
|
267
|
+
table.add_row("Total Usage", str(stats.total_usage_count))
|
|
268
|
+
table.add_row("Concurrent", str(stats.concurrent_usage))
|
|
269
|
+
|
|
270
|
+
console.print(table)
|
|
271
|
+
except Exception as e:
|
|
272
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
273
|
+
finally:
|
|
274
|
+
await handler.close()
|
|
275
|
+
|
|
276
|
+
@usage.command(name="reset-daily")
|
|
277
|
+
@click.pass_context
|
|
278
|
+
@coro
|
|
279
|
+
async def usage_reset_daily(ctx: click.Context):
|
|
280
|
+
"""Manually trigger daily reset for all keys."""
|
|
281
|
+
handler = await get_handler(ctx)
|
|
282
|
+
try:
|
|
283
|
+
count = await handler.reset_daily_counts()
|
|
284
|
+
console.print(f"[bold green]Reset daily counters for {count} keys.[/bold green]")
|
|
285
|
+
except Exception as e:
|
|
286
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
287
|
+
finally:
|
|
288
|
+
await handler.close()
|
|
289
|
+
|
|
290
|
+
@usage.command(name="reset-monthly")
|
|
291
|
+
@click.pass_context
|
|
292
|
+
@coro
|
|
293
|
+
async def usage_reset_monthly(ctx: click.Context):
|
|
294
|
+
"""Manually trigger monthly reset for all keys."""
|
|
295
|
+
handler = await get_handler(ctx)
|
|
296
|
+
try:
|
|
297
|
+
count = await handler.reset_monthly_counts()
|
|
298
|
+
console.print(f"[bold green]Reset monthly counters for {count} keys.[/bold green]")
|
|
299
|
+
except Exception as e:
|
|
300
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
301
|
+
finally:
|
|
302
|
+
await handler.close()
|
|
303
|
+
|
|
304
|
+
@cli.command()
|
|
305
|
+
@click.pass_context
|
|
306
|
+
@coro
|
|
307
|
+
async def health(ctx: click.Context):
|
|
308
|
+
"""Check the health of the storage backend."""
|
|
309
|
+
handler = await get_handler(ctx)
|
|
310
|
+
try:
|
|
311
|
+
status = await handler.health_check()
|
|
312
|
+
if status.get("storage_healthy"):
|
|
313
|
+
console.print("[bold green]Storage backend is healthy![/bold green]")
|
|
314
|
+
else:
|
|
315
|
+
console.print("[bold red]Storage backend is NOT healthy![/bold red]")
|
|
316
|
+
|
|
317
|
+
for k, v in status.items():
|
|
318
|
+
console.print(f"{k}: {v}")
|
|
319
|
+
except Exception as e:
|
|
320
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
321
|
+
finally:
|
|
322
|
+
await handler.close()
|
|
323
|
+
|
|
324
|
+
@cli.command()
|
|
325
|
+
@click.pass_context
|
|
326
|
+
@coro
|
|
327
|
+
async def info(ctx: click.Context):
|
|
328
|
+
"""View API Service Handler information and stats."""
|
|
329
|
+
handler = await get_handler(ctx)
|
|
330
|
+
try:
|
|
331
|
+
inf = await handler.info()
|
|
332
|
+
console.print("[bold]API Service Handler Info[/bold]")
|
|
333
|
+
for k, v in inf.items():
|
|
334
|
+
console.print(f"{k}: {v}")
|
|
335
|
+
except Exception as e:
|
|
336
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
337
|
+
finally:
|
|
338
|
+
await handler.close()
|
|
339
|
+
|
|
340
|
+
if __name__ == '__main__':
|
|
341
|
+
cli()
|