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
114import os
2- from dataclasses import field , dataclass
15+ from dataclasses import dataclass , field
316from pathlib import Path
4- from typing import Optional , Type
17+ from typing import Optional
518
619from sqlalchemy import String
720from sqlalchemy .ext .asyncio import AsyncSession , create_async_engine , async_sessionmaker
821from sqlalchemy .orm import Mapped , mapped_column , DeclarativeBase
922
1023from 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
1225from fastapi_api_key .hasher .argon2 import Argon2ApiKeyHasher
1326from fastapi_api_key .repositories .sql import (
1427 SqlAlchemyApiKeyRepository ,
1528 ApiKeyModelMixin ,
1629)
17- import asyncio
1830
1931
32+ # 1. Define your custom SQLAlchemy Base
2033class 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
10590pepper = os .getenv ("API_KEY_PEPPER" )
10691hasher = Argon2ApiKeyHasher (pepper = pepper )
10792
108- path = Path (__file__ ).parent / "db .sqlite3"
93+ path = Path (__file__ ).parent / "db_custom .sqlite3"
10994database_url = os .environ .get ("DATABASE_URL" , f"sqlite+aiosqlite:///{ path } " )
11095
11196async_engine = create_async_engine (database_url , future = True )
@@ -117,21 +102,45 @@ def to_domain(
117102
118103
119104async 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"\n Verified! Tenant: { verified .tenant_id } " )
143+
136144
137- asyncio .run (main ())
145+ if __name__ == "__main__" :
146+ asyncio .run (main ())
0 commit comments