Skip to content

Commit fbe55a2

Browse files
committed
docs(examples): simplify custom entity example with auto-mapping
1 parent 8fa9961 commit fbe55a2

File tree

2 files changed

+236
-87
lines changed

2 files changed

+236
-87
lines changed

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())

tests/units/test_repo.py

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
from dataclasses import dataclass, field
12
from datetime import timedelta
3+
from typing import Optional
24

35
import pytest
6+
from sqlalchemy import String
47
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
8+
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase
59

610

711
from fastapi_api_key.domain.entities import ApiKey
812
from fastapi_api_key.repositories.base import AbstractApiKeyRepository, ApiKeyFilter
9-
from fastapi_api_key.repositories.sql import SqlAlchemyApiKeyRepository
13+
from fastapi_api_key.repositories.sql import SqlAlchemyApiKeyRepository, ApiKeyModelMixin
1014
from fastapi_api_key.utils import key_id_factory, datetime_factory
1115
from tests.conftest import make_api_key
1216

@@ -530,3 +534,139 @@ async def test_count_ignores_pagination(repository: AbstractApiKeyRepository) ->
530534
# Count should return total, not limited count
531535
count = await repository.count(ApiKeyFilter(limit=3, offset=5))
532536
assert count == 10
537+
538+
539+
# =============================================================================
540+
# Tests for automatic mapping with custom fields
541+
# =============================================================================
542+
543+
544+
class CustomBase(DeclarativeBase): ...
545+
546+
547+
@dataclass
548+
class CustomApiKey(ApiKey):
549+
"""Custom API key with additional fields for testing."""
550+
551+
tenant_id: Optional[str] = field(default=None)
552+
custom_field: Optional[str] = field(default=None)
553+
554+
555+
class CustomApiKeyModel(CustomBase, ApiKeyModelMixin):
556+
"""Custom SQLAlchemy model with additional columns."""
557+
558+
tenant_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
559+
custom_field: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
560+
561+
562+
@pytest.mark.asyncio
563+
async def test_auto_mapping_with_custom_fields() -> None:
564+
"""to_model/to_domain: should automatically map custom fields."""
565+
async_engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
566+
567+
async with async_engine.begin() as conn:
568+
await conn.run_sync(CustomBase.metadata.create_all)
569+
570+
async_session_maker = async_sessionmaker(
571+
async_engine,
572+
class_=AsyncSession,
573+
expire_on_commit=False,
574+
)
575+
576+
async with async_session_maker() as session:
577+
repo = SqlAlchemyApiKeyRepository(
578+
async_session=session,
579+
model_cls=CustomApiKeyModel,
580+
domain_cls=CustomApiKey,
581+
)
582+
583+
# Create entity with custom fields
584+
original = make_api_key()
585+
custom_entity = CustomApiKey(
586+
id_=original.id_,
587+
name=original.name,
588+
description=original.description,
589+
is_active=original.is_active,
590+
expires_at=original.expires_at,
591+
created_at=original.created_at,
592+
key_id=original.key_id,
593+
key_hash=original.key_hash,
594+
_key_secret=original._key_secret,
595+
scopes=original.scopes,
596+
tenant_id="tenant-abc",
597+
custom_field="custom-value",
598+
)
599+
600+
# Create and retrieve
601+
created = await repo.create(custom_entity)
602+
603+
assert isinstance(created, CustomApiKey)
604+
assert created.tenant_id == "tenant-abc"
605+
assert created.custom_field == "custom-value"
606+
607+
# Verify retrieval
608+
retrieved = await repo.get_by_id(created.id_)
609+
assert isinstance(retrieved, CustomApiKey)
610+
assert retrieved.tenant_id == "tenant-abc"
611+
assert retrieved.custom_field == "custom-value"
612+
613+
# Verify update
614+
retrieved.tenant_id = "tenant-xyz"
615+
retrieved.custom_field = "updated-value"
616+
updated = await repo.update(retrieved)
617+
618+
assert updated.tenant_id == "tenant-xyz"
619+
assert updated.custom_field == "updated-value"
620+
621+
await async_engine.dispose()
622+
623+
624+
@pytest.mark.asyncio
625+
async def test_auto_mapping_preserves_base_fields() -> None:
626+
"""to_model/to_domain: custom fields should not break base field mapping."""
627+
async_engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
628+
629+
async with async_engine.begin() as conn:
630+
await conn.run_sync(CustomBase.metadata.create_all)
631+
632+
async_session_maker = async_sessionmaker(
633+
async_engine,
634+
class_=AsyncSession,
635+
expire_on_commit=False,
636+
)
637+
638+
async with async_session_maker() as session:
639+
repo = SqlAlchemyApiKeyRepository(
640+
async_session=session,
641+
model_cls=CustomApiKeyModel,
642+
domain_cls=CustomApiKey,
643+
)
644+
645+
original = make_api_key()
646+
custom_entity = CustomApiKey(
647+
id_=original.id_,
648+
name="test-name",
649+
description="test-description",
650+
is_active=True,
651+
expires_at=original.expires_at,
652+
created_at=original.created_at,
653+
key_id=original.key_id,
654+
key_hash=original.key_hash,
655+
_key_secret=original._key_secret,
656+
scopes=["read", "write", "admin"],
657+
tenant_id="tenant-123",
658+
custom_field=None,
659+
)
660+
661+
created = await repo.create(custom_entity)
662+
663+
# Verify all base fields are preserved
664+
assert created.id_ == original.id_
665+
assert created.name == "test-name"
666+
assert created.description == "test-description"
667+
assert created.is_active is True
668+
assert created.key_id == original.key_id
669+
assert created.key_hash == original.key_hash
670+
assert created.scopes == ["read", "write", "admin"]
671+
672+
await async_engine.dispose()

0 commit comments

Comments
 (0)