Skip to content

Commit f578464

Browse files
authored
Merge pull request #15 from Athroniaeth/development
Development
2 parents be8886a + 08c47fb commit f578464

File tree

18 files changed

+2325
-365
lines changed

18 files changed

+2325
-365
lines changed

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
## 0.11.0 (2025-12-01)
2+
3+
### BREAKING CHANGE
4+
5+
- key_hash property now raises ValueError when unset
6+
instead of returning None. Use try/except or check _key_hash directly.
7+
- Remove generic type D, entity_factory parameter, and custom entity/model support. Services and repositories now work directly with ApiKey. Remove extensibility patterns. ApiKeyService no longer accepts entity_factory parameter. SqlAlchemyApiKeyRepository no longer accepts model_cls/domain_cls parameters. Custom entities are no longer supported.
8+
- Please delete all domain_cls parameter use with service
9+
10+
### Refactor
11+
12+
- delete claude code comments and remove str | None who aren't compatible with python 3.9
13+
- **domain**: add key_hash validation, public init aliases, and secure repr
14+
- **tests**: mock crypto backends in hasher tests and improve typing in SqlAlchemyApiKeyRepository
15+
- **tests**: restructure unit tests with 100% coverage on core modules
16+
- remove extensibility patterns (generics, entity factory, custom mapping)
17+
- **svc**: remove optional domain cls to service init
18+
- **cli**: modify bad message for updating .env
19+
120
## 0.10.0 (2025-11-26)
221

322
### Feat

CLAUDE.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
`fastapi-api-key` is a library for issuing, persisting, and verifying API keys in FastAPI applications. It provides pluggable hashing strategies (Argon2, bcrypt), backend-agnostic persistence (SQLAlchemy, in-memory), optional caching (aiocache), and connectors for FastAPI and Typer CLI.
8+
9+
## Development Commands
10+
11+
```bash
12+
# Install all dependencies (runtime + dev)
13+
uv sync --extra all --group dev
14+
15+
# Format and lint (runs ruff format, ruff check, ty, bandit)
16+
uv run lint
17+
18+
# Run tests with coverage
19+
uv run pytest
20+
21+
# Run a single test file
22+
uv run pytest tests/units/test_service.py
23+
24+
# Run a specific test
25+
uv run pytest tests/units/test_service.py::test_function_name -v
26+
27+
# Preview documentation
28+
uv run mkdocs serve
29+
30+
# Build documentation
31+
uv run mkdocs build
32+
33+
# CLI tool (requires cli extra)
34+
uv run fak --help
35+
```
36+
37+
## Architecture
38+
39+
### Core Components
40+
41+
- **`ApiKeyService`** (`src/fastapi_api_key/services/base.py`): Main service orchestrating key creation, verification, and lifecycle. Handles timing attack mitigation via random response delays (rrd parameter).
42+
43+
- **`AbstractApiKeyRepository`** (`src/fastapi_api_key/repositories/base.py`): Repository contract. Implementations:
44+
- `InMemoryApiKeyRepository` - for testing
45+
- `SqlAlchemyApiKeyRepository` - production use with SQLAlchemy
46+
47+
- **`ApiKeyHasher`** (`src/fastapi_api_key/hasher/base.py`): Protocol for hashing. Implementations in `hasher/argon2.py` and `hasher/bcrypt.py`. Uses pepper (secret) + salt pattern.
48+
49+
- **`ApiKey`** (`src/fastapi_api_key/domain/entities.py`): Domain entity representing an API key with fields: id_, key_id, key_hash, name, description, is_active, scopes, expires_at, last_used_at.
50+
51+
### API Key Format
52+
53+
`{global_prefix}-{key_id}-{key_secret}` (e.g., `ak-7a74caa323a5410d-mAfP3l6y...`)
54+
55+
- `key_id`: 16-char UUID fragment (public, stored in DB for lookup)
56+
- `key_secret`: 48-char base64 string (hashed before storage, never returned after creation)
57+
58+
### Connectors
59+
60+
- **FastAPI Router** (`src/fastapi_api_key/api.py`): `create_api_keys_router()` provides CRUD endpoints. `create_depends_api_key()` creates a FastAPI dependency for protecting routes.
61+
62+
- **Typer CLI** (`src/fastapi_api_key/cli.py`): `create_api_keys_cli()` for command-line key management.
63+
64+
### Caching Layer
65+
66+
`CachedApiKeyService` (`src/fastapi_api_key/services/cached.py`) wraps the base service with aiocache support.
67+
68+
## Key Design Decisions
69+
70+
- Secrets are hashed with salt + pepper; plaintext only returned once at creation
71+
- Repository pattern enables swapping storage backends
72+
- Service raises domain errors (`KeyNotFound`, `KeyInactive`, `KeyExpired`, `InvalidKey`, `InvalidScopes`) from `domain/errors.py`
73+
- RFC 9110/7235 compliance: 401 for missing/invalid keys, 403 for inactive/expired
74+
- Supports `Authorization: Bearer`, `X-API-Key` header, and `api_key` query param
75+
76+
## Testing
77+
78+
Tests are in `tests/` with coverage configured in `pyproject.toml`. The library emits warnings when using the default pepper - tests rely on this behavior.
79+
80+
## Branching
81+
82+
Feature branches should be created from `development` branch. PRs target `development`.

docs/usage/database.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# Database Configuration
2+
3+
This guide covers SQLAlchemy setup and connection pooling best practices for production use.
4+
5+
## Installation
6+
7+
Install the SQLAlchemy extra:
8+
9+
```bash
10+
pip install fastapi-api-key[sqlalchemy]
11+
```
12+
13+
## Basic Setup
14+
15+
The `SqlAlchemyApiKeyRepository` requires an `AsyncSession`:
16+
17+
```python
18+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
19+
from fastapi_api_key.repositories.sql import SqlAlchemyApiKeyRepository
20+
21+
# Create async engine
22+
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
23+
24+
# Create session factory
25+
async_session = async_sessionmaker(engine, expire_on_commit=False)
26+
27+
# Use in your application
28+
async with async_session() as session:
29+
repo = SqlAlchemyApiKeyRepository(session)
30+
# ... use repository
31+
await session.commit()
32+
```
33+
34+
## Connection Pooling
35+
36+
SQLAlchemy uses connection pooling by default. For production, configure the pool explicitly.
37+
38+
### PostgreSQL (asyncpg)
39+
40+
```python
41+
from sqlalchemy.ext.asyncio import create_async_engine
42+
43+
engine = create_async_engine(
44+
"postgresql+asyncpg://user:pass@localhost/db",
45+
pool_size=5, # Number of persistent connections
46+
max_overflow=10, # Additional connections when pool is exhausted
47+
pool_timeout=30, # Seconds to wait for a connection
48+
pool_recycle=1800, # Recycle connections after 30 minutes
49+
pool_pre_ping=True, # Verify connections before use
50+
)
51+
```
52+
53+
### SQLite (aiosqlite)
54+
55+
SQLite doesn't benefit from connection pooling. Use `NullPool` for async SQLite:
56+
57+
```python
58+
from sqlalchemy.ext.asyncio import create_async_engine
59+
from sqlalchemy.pool import NullPool
60+
61+
engine = create_async_engine(
62+
"sqlite+aiosqlite:///./api_keys.db",
63+
poolclass=NullPool,
64+
)
65+
```
66+
67+
### MySQL (aiomysql)
68+
69+
```python
70+
from sqlalchemy.ext.asyncio import create_async_engine
71+
72+
engine = create_async_engine(
73+
"mysql+aiomysql://user:pass@localhost/db",
74+
pool_size=5,
75+
max_overflow=10,
76+
pool_timeout=30,
77+
pool_recycle=3600, # MySQL default wait_timeout is 8 hours
78+
pool_pre_ping=True,
79+
)
80+
```
81+
82+
## Pool Size Guidelines
83+
84+
| Scenario | pool_size | max_overflow |
85+
|----------|-----------|--------------|
86+
| Development | 2 | 5 |
87+
| Small app (<100 req/s) | 5 | 10 |
88+
| Medium app (100-1000 req/s) | 10 | 20 |
89+
| Large app (>1000 req/s) | 20+ | 40+ |
90+
91+
!!! tip "Rule of Thumb"
92+
A good starting point is `pool_size = (2 * CPU cores) + effective_spindle_count`.
93+
For cloud databases, start with 5-10 and monitor.
94+
95+
## FastAPI Integration
96+
97+
Use a dependency to manage sessions per request:
98+
99+
```python
100+
from contextlib import asynccontextmanager
101+
from fastapi import FastAPI, Depends
102+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
103+
104+
from fastapi_api_key import ApiKeyService
105+
from fastapi_api_key.repositories.sql import SqlAlchemyApiKeyRepository
106+
107+
# Engine with connection pooling
108+
engine = create_async_engine(
109+
"postgresql+asyncpg://user:pass@localhost/db",
110+
pool_size=5,
111+
max_overflow=10,
112+
pool_pre_ping=True,
113+
)
114+
115+
async_session = async_sessionmaker(engine, expire_on_commit=False)
116+
117+
118+
@asynccontextmanager
119+
async def lifespan(app: FastAPI):
120+
# Startup: optionally create tables
121+
async with engine.begin() as conn:
122+
# await conn.run_sync(Base.metadata.create_all)
123+
pass
124+
yield
125+
# Shutdown: dispose of the connection pool
126+
await engine.dispose()
127+
128+
129+
app = FastAPI(lifespan=lifespan)
130+
131+
132+
async def get_session():
133+
async with async_session() as session:
134+
yield session
135+
136+
137+
async def get_api_key_service(session: AsyncSession = Depends(get_session)):
138+
repo = SqlAlchemyApiKeyRepository(session)
139+
return ApiKeyService(repo=repo)
140+
```
141+
142+
## Connection Health
143+
144+
### Pre-ping
145+
146+
Enable `pool_pre_ping=True` to test connections before use. This handles:
147+
148+
- Database restarts
149+
- Network interruptions
150+
- Idle connection timeouts
151+
152+
### Pool Recycling
153+
154+
Set `pool_recycle` to a value less than your database's connection timeout:
155+
156+
| Database | Default Timeout | Recommended `pool_recycle` |
157+
|----------|-----------------|---------------------------|
158+
| PostgreSQL | No limit | 1800 (30 min) |
159+
| MySQL | 8 hours | 3600 (1 hour) |
160+
| MariaDB | 8 hours | 3600 (1 hour) |
161+
162+
## Monitoring
163+
164+
Log pool statistics for debugging:
165+
166+
```python
167+
import logging
168+
169+
logging.getLogger("sqlalchemy.pool").setLevel(logging.DEBUG)
170+
```
171+
172+
Check pool status programmatically:
173+
174+
```python
175+
pool = engine.pool
176+
print(f"Pool size: {pool.size()}")
177+
print(f"Checked out: {pool.checkedout()}")
178+
print(f"Overflow: {pool.overflow()}")
179+
print(f"Checked in: {pool.checkedin()}")
180+
```
181+
182+
## Common Issues
183+
184+
### "QueuePool limit reached"
185+
186+
The pool is exhausted. Solutions:
187+
188+
1. Increase `pool_size` and `max_overflow`
189+
2. Ensure sessions are properly closed (use context managers)
190+
3. Reduce query execution time
191+
192+
### "Connection reset by peer"
193+
194+
The database closed an idle connection. Solutions:
195+
196+
1. Enable `pool_pre_ping=True`
197+
2. Set `pool_recycle` to a lower value
198+
3. Check database idle timeout settings
199+
200+
### High latency on first request
201+
202+
The pool creates connections lazily. Pre-warm the pool:
203+
204+
```python
205+
async def warm_pool():
206+
"""Pre-create connections to avoid cold start latency."""
207+
async with engine.connect() as conn:
208+
await conn.execute(text("SELECT 1"))
209+
```

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ nav:
6363
- Quickstart: quickstart.md
6464
- Usage:
6565
- Dotenv: usage/dotenv.md
66+
- Database: usage/database.md
6667
- Cache: usage/cache.md
6768
- FastAPI: usage/fastapi.md
6869
- Scopes: usage/scopes.md

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-api-key"
3-
version = "0.10.0"
3+
version = "0.11.0"
44
description = "fastapi-api-key provides secure, production-ready API key management for FastAPI. It offers pluggable hashing strategies (Argon2 or bcrypt), backend-agnostic persistence (currently SQLAlchemy), and an optional cache layer (aiocache). Includes a Typer CLI and a FastAPI router for CRUD management of keys."
55
readme = "README.md"
66
authors = [
@@ -100,6 +100,7 @@ dev = [
100100
"typer>=0.12.5",
101101
"mkdocs>=1.6.1",
102102
"mkdocs-material>=9.6.23",
103+
"httpx>=0.28.1",
103104
]
104105

105106
[tool.commitizen]
@@ -141,4 +142,4 @@ omit = [
141142
"src/fastapi_api_key/__init__.py",
142143
"src/fastapi_api_key/__main__.py",
143144
]
144-
branch = true
145+
branch = true

0 commit comments

Comments
 (0)