Skip to content

Commit 59af414

Browse files
authored
Merge pull request #13 from Athroniaeth/development
Development
2 parents a341dd8 + fbe55a2 commit 59af414

File tree

16 files changed

+1798
-217
lines changed

16 files changed

+1798
-217
lines changed

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
1+
## 0.9.0 (2025-11-26)
2+
3+
### BREAKING CHANGE
4+
5+
- please ensure that if you use sql repository to check how do you use delete_by_id method, this method now return entity and not boolean who define his existence
6+
7+
### Fix
8+
9+
- **cached**: use full API key hash to prevent key_id-only cache hits
10+
11+
### Refactor
12+
13+
- **svc**: replace entity parameter with direct args in create method
14+
- **svc**: create touch method for simplify code of service
15+
- **domain**: create ensure valid scopes domain method for regroup all verification of scopes
16+
17+
### Perf
18+
19+
- **sql**: remove double call to db for delete method
20+
121
## 0.8.3 (2025-11-24)
222

323
### Fix

examples/example_sql_custom.py

Lines changed: 95 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,96 @@
1+
"""Example: Custom API Key with additional fields.
2+
3+
This example demonstrates how to extend the default ApiKey entity and model
4+
with custom fields. Thanks to automatic introspection, you only need to:
5+
6+
1. Create a custom dataclass extending ApiKey
7+
2. Create a custom SQLAlchemy model extending ApiKeyModelMixin
8+
3. Pass them to the repository - no need to override to_model/to_domain!
9+
10+
The automatic mapping handles all common fields plus your custom ones.
11+
"""
12+
13+
import asyncio
114
import os
2-
from dataclasses import field, dataclass
15+
from dataclasses import dataclass, field
316
from pathlib import Path
4-
from typing import Optional, Type
17+
from typing import Optional
518

619
from sqlalchemy import String
720
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
821
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase
922

1023
from fastapi_api_key import ApiKeyService
11-
from fastapi_api_key.domain.entities import ApiKey as OldApiKey
24+
from fastapi_api_key.domain.entities import ApiKey
1225
from fastapi_api_key.hasher.argon2 import Argon2ApiKeyHasher
1326
from fastapi_api_key.repositories.sql import (
1427
SqlAlchemyApiKeyRepository,
1528
ApiKeyModelMixin,
1629
)
17-
import asyncio
1830

1931

32+
# 1. Define your custom SQLAlchemy Base
2033
class Base(DeclarativeBase): ...
2134

2235

36+
# 2. Create a custom domain entity with additional fields
2337
@dataclass
24-
class ApiKey(OldApiKey):
38+
class TenantApiKey(ApiKey):
39+
"""API Key with tenant isolation support."""
40+
41+
tenant_id: Optional[str] = field(default=None)
2542
notes: Optional[str] = field(default=None)
2643

2744

28-
class ApiKeyModel(Base, ApiKeyModelMixin):
45+
# 3. Create a custom SQLAlchemy model with matching columns
46+
class TenantApiKeyModel(Base, ApiKeyModelMixin):
47+
"""SQLAlchemy model with tenant support."""
48+
49+
tenant_id: Mapped[Optional[str]] = mapped_column(
50+
String(64),
51+
nullable=True,
52+
index=True, # Index for efficient tenant queries
53+
)
2954
notes: Mapped[Optional[str]] = mapped_column(
30-
String(128),
55+
String(512),
3156
nullable=True,
3257
)
3358

3459

35-
class ApiKeyRepository(SqlAlchemyApiKeyRepository[ApiKey, ApiKeyModel]):
36-
def __init__(
37-
self,
38-
async_session: AsyncSession,
39-
model_cls: Type[ApiKeyModel] = ApiKeyModel,
40-
domain_cls: Type[ApiKey] = ApiKey,
41-
) -> None:
42-
super().__init__(
43-
async_session=async_session,
44-
model_cls=model_cls,
45-
domain_cls=domain_cls,
46-
)
47-
48-
@staticmethod
49-
def to_model(
50-
entity: ApiKey,
51-
model_cls: Type[ApiKeyModel],
52-
target: Optional[ApiKeyModel] = None,
53-
) -> ApiKeyModel:
54-
if target is None:
55-
return model_cls(
56-
id_=entity.id_,
57-
name=entity.name,
58-
description=entity.description,
59-
is_active=entity.is_active,
60-
expires_at=entity.expires_at,
61-
created_at=entity.created_at,
62-
last_used_at=entity.last_used_at,
63-
key_id=entity.key_id,
64-
key_hash=entity.key_hash,
65-
notes=entity.notes,
66-
)
67-
68-
# Update existing model
69-
target.name = entity.name
70-
target.description = entity.description
71-
target.is_active = entity.is_active
72-
target.expires_at = entity.expires_at
73-
target.last_used_at = entity.last_used_at
74-
target.key_id = entity.key_id
75-
target.key_hash = entity.key_hash # type: ignore[invalid-assignment]
76-
target.notes = entity.notes
77-
78-
return target
79-
80-
def to_domain(
81-
self,
82-
model: Optional[ApiKeyModel],
83-
model_cls: Type[ApiKey],
84-
) -> Optional[ApiKey]:
85-
if model is None:
86-
return None
87-
88-
return model_cls(
89-
id_=model.id_,
90-
name=model.name,
91-
description=model.description,
92-
is_active=model.is_active,
93-
expires_at=model.expires_at,
94-
created_at=model.created_at,
95-
last_used_at=model.last_used_at,
96-
key_id=model.key_id,
97-
key_hash=model.key_hash,
98-
notes=model.notes,
99-
)
60+
# 4. Create a factory function for your custom entity
61+
def tenant_api_key_factory(
62+
key_id: str,
63+
key_hash: str,
64+
key_secret: str,
65+
name: Optional[str] = None,
66+
description: Optional[str] = None,
67+
is_active: bool = True,
68+
expires_at=None,
69+
scopes=None,
70+
tenant_id: Optional[str] = None,
71+
notes: Optional[str] = None,
72+
**kwargs,
73+
) -> TenantApiKey:
74+
"""Factory for creating TenantApiKey entities."""
75+
return TenantApiKey(
76+
key_id=key_id,
77+
key_hash=key_hash,
78+
_key_secret=key_secret,
79+
name=name,
80+
description=description,
81+
is_active=is_active,
82+
expires_at=expires_at,
83+
scopes=scopes or [],
84+
tenant_id=tenant_id,
85+
notes=notes,
86+
)
10087

10188

102-
# Set env var to override default pepper
103-
# Using a strong, unique pepper is crucial for security
104-
# Default pepper is insecure and should not be used in production
89+
# Configuration
10590
pepper = os.getenv("API_KEY_PEPPER")
10691
hasher = Argon2ApiKeyHasher(pepper=pepper)
10792

108-
path = Path(__file__).parent / "db.sqlite3"
93+
path = Path(__file__).parent / "db_custom.sqlite3"
10994
database_url = os.environ.get("DATABASE_URL", f"sqlite+aiosqlite:///{path}")
11095

11196
async_engine = create_async_engine(database_url, future=True)
@@ -117,21 +102,45 @@ def to_domain(
117102

118103

119104
async def main():
105+
# Create tables
106+
async with async_engine.begin() as conn:
107+
await conn.run_sync(Base.metadata.create_all)
108+
120109
async with async_session_maker() as session:
121-
repo = SqlAlchemyApiKeyRepository(session)
110+
# Create repository with custom model and domain classes
111+
# No need to override to_model/to_domain - automatic mapping handles it!
112+
repo = SqlAlchemyApiKeyRepository(
113+
async_session=session,
114+
model_cls=TenantApiKeyModel,
115+
domain_cls=TenantApiKey,
116+
)
122117

123-
# Don't need to create Base and ApiKeyModel, the repository does it for you
124-
await repo.ensure_table()
118+
# Create service with custom factory
119+
service = ApiKeyService(
120+
repo=repo,
121+
hasher=hasher,
122+
entity_factory=tenant_api_key_factory,
123+
)
125124

126-
service = ApiKeyService(repo=repo, hasher=hasher)
125+
# Create an API key with custom fields
126+
entity, secret = await service.create(
127+
name="tenant-key",
128+
description="API key for tenant operations",
129+
scopes=["read", "write"],
130+
tenant_id="tenant-123",
131+
notes="Created for demo purposes",
132+
)
127133

128-
# Entity have updated id after creation
129-
entity, secret = await service.create(name="persistent")
130-
print("Stored key", entity.id_, "secret", secret)
134+
print(f"Created key for tenant: {entity.tenant_id}")
135+
print(f"Notes: {entity.notes}")
136+
print(f"Secret (store securely!): {secret}")
131137

132-
# Don't forget to commit the session to persist the key
133-
# You can also use a transaction `async with session.begin():`
134138
await session.commit()
135139

140+
# Verify the key works
141+
verified = await service.verify_key(secret)
142+
print(f"\nVerified! Tenant: {verified.tenant_id}")
143+
136144

137-
asyncio.run(main())
145+
if __name__ == "__main__":
146+
asyncio.run(main())

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-api-key"
3-
version = "0.8.3"
3+
version = "0.9.0"
44
description = "fastapi-api-key provides secure, production-ready API key management for FastAPI. It offers pluggable hashing strategies (Argon2, bcrypt, or custom), backend-agnostic persistence (SQLAlchemy, in-memory, or your own repository), and an optional cache layer (aiocache, Redis). Includes a Typer CLI and a FastAPI router for CRUD management of keys."
55
readme = "README.md"
66
authors = [

src/fastapi_api_key/api.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from fastapi.security import APIKeyHeader, HTTPBearer, HTTPAuthorizationCredentials
2121
from pydantic import BaseModel, Field
2222

23+
from fastapi_api_key.repositories.base import ApiKeyFilter
2324
from fastapi_api_key.services.base import ApiKeyService
2425
from fastapi_api_key.domain.entities import ApiKey, ApiKeyEntity
2526
from fastapi_api_key.domain.errors import (
@@ -98,6 +99,50 @@ class DeletedResponse(BaseModel):
9899
status: Literal["deleted"] = "deleted"
99100

100101

102+
class ApiKeySearchIn(BaseModel):
103+
"""Search criteria for filtering API keys.
104+
105+
All criteria are optional. Only provided criteria are applied (AND logic).
106+
"""
107+
108+
is_active: Optional[bool] = Field(None, description="Filter by active status")
109+
expires_before: Optional[datetime] = Field(None, description="Keys expiring before this date")
110+
expires_after: Optional[datetime] = Field(None, description="Keys expiring after this date")
111+
created_before: Optional[datetime] = Field(None, description="Keys created before this date")
112+
created_after: Optional[datetime] = Field(None, description="Keys created after this date")
113+
never_used: Optional[bool] = Field(None, description="True = never used keys, False = used keys")
114+
scopes_contain_all: Optional[List[str]] = Field(None, description="Keys must have ALL these scopes")
115+
scopes_contain_any: Optional[List[str]] = Field(None, description="Keys must have at least ONE of these scopes")
116+
name_contains: Optional[str] = Field(None, description="Name contains this substring (case-insensitive)")
117+
name_exact: Optional[str] = Field(None, description="Exact name match")
118+
119+
def to_filter(self, limit: int = 100, offset: int = 0) -> ApiKeyFilter:
120+
"""Convert to ApiKeyFilter with pagination."""
121+
return ApiKeyFilter(
122+
is_active=self.is_active,
123+
expires_before=self.expires_before,
124+
expires_after=self.expires_after,
125+
created_before=self.created_before,
126+
created_after=self.created_after,
127+
never_used=self.never_used,
128+
scopes_contain_all=self.scopes_contain_all,
129+
scopes_contain_any=self.scopes_contain_any,
130+
name_contains=self.name_contains,
131+
name_exact=self.name_exact,
132+
limit=limit,
133+
offset=offset,
134+
)
135+
136+
137+
class ApiKeySearchOut(BaseModel):
138+
"""Paginated search results."""
139+
140+
items: List[ApiKeyOut] = Field(description="List of matching API keys")
141+
total: int = Field(description="Total number of matching keys (ignoring pagination)")
142+
limit: int = Field(description="Page size used")
143+
offset: int = Field(description="Offset used")
144+
145+
101146
def _to_out(entity: ApiKey) -> ApiKeyOut:
102147
"""Map an `ApiKey` entity to the public `ApiKeyOut` schema."""
103148
return ApiKeyOut(
@@ -178,6 +223,40 @@ async def list_api_keys(
178223
items = await svc.list(offset=offset, limit=limit)
179224
return [_to_out(e) for e in items]
180225

226+
@router.post(
227+
path="/search",
228+
response_model=ApiKeySearchOut,
229+
status_code=status.HTTP_200_OK,
230+
summary="Search API keys with filters",
231+
)
232+
async def search_api_keys(
233+
payload: ApiKeySearchIn,
234+
svc: ApiKeyService = Depends(depends_svc_api_keys),
235+
offset: Annotated[int, Query(ge=0, description="Items to skip")] = 0,
236+
limit: Annotated[int, Query(gt=0, le=100, description="Page size")] = 50,
237+
) -> ApiKeySearchOut:
238+
"""Search API keys with advanced filtering criteria.
239+
240+
Args:
241+
payload: Search criteria (all optional, AND logic).
242+
svc: Injected `ApiKeyService`.
243+
offset: Number of items to skip.
244+
limit: Max number of items to return.
245+
246+
Returns:
247+
Paginated search results with total count.
248+
"""
249+
filter = payload.to_filter(limit=limit, offset=offset)
250+
items = await svc.find(filter)
251+
total = await svc.count(filter)
252+
253+
return ApiKeySearchOut(
254+
items=[_to_out(e) for e in items],
255+
total=total,
256+
limit=limit,
257+
offset=offset,
258+
)
259+
181260
@router.get(
182261
"/{api_key_id}",
183262
response_model=ApiKeyOut,

0 commit comments

Comments
 (0)