fastapi-auth-starter 0.1.3__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.
- fastapi_auth_starter/__init__.py +7 -0
- fastapi_auth_starter/cli.py +326 -0
- fastapi_auth_starter-0.1.3.data/data/README.md +247 -0
- fastapi_auth_starter-0.1.3.data/data/alembic/README +1 -0
- fastapi_auth_starter-0.1.3.data/data/alembic/env.py +100 -0
- fastapi_auth_starter-0.1.3.data/data/alembic/script.py.mako +28 -0
- fastapi_auth_starter-0.1.3.data/data/alembic/versions/279c472f4fd8_add_user_table.py +42 -0
- fastapi_auth_starter-0.1.3.data/data/alembic/versions/5f062b3648fa_change_user_id_from_uuid_to_string_for_.py +38 -0
- fastapi_auth_starter-0.1.3.data/data/alembic/versions/8d275132562b_create_tasks_table.py +44 -0
- fastapi_auth_starter-0.1.3.data/data/alembic.ini +150 -0
- fastapi_auth_starter-0.1.3.data/data/app/__init__.py +5 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/__init__.py +4 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/__init__.py +4 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/api.py +21 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/__init__.py +4 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/auth.py +513 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/health.py +50 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/task.py +182 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/user.py +144 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/schemas/__init__.py +8 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/schemas/auth.py +198 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/schemas/task.py +61 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/schemas/user.py +96 -0
- fastapi_auth_starter-0.1.3.data/data/app/core/__init__.py +4 -0
- fastapi_auth_starter-0.1.3.data/data/app/core/config.py +107 -0
- fastapi_auth_starter-0.1.3.data/data/app/core/database.py +106 -0
- fastapi_auth_starter-0.1.3.data/data/app/core/dependencies.py +148 -0
- fastapi_auth_starter-0.1.3.data/data/app/core/exceptions.py +7 -0
- fastapi_auth_starter-0.1.3.data/data/app/db/__init__.py +4 -0
- fastapi_auth_starter-0.1.3.data/data/app/main.py +91 -0
- fastapi_auth_starter-0.1.3.data/data/app/models/__init__.py +14 -0
- fastapi_auth_starter-0.1.3.data/data/app/models/task.py +56 -0
- fastapi_auth_starter-0.1.3.data/data/app/models/user.py +45 -0
- fastapi_auth_starter-0.1.3.data/data/app/services/__init__.py +8 -0
- fastapi_auth_starter-0.1.3.data/data/app/services/auth.py +405 -0
- fastapi_auth_starter-0.1.3.data/data/app/services/task.py +165 -0
- fastapi_auth_starter-0.1.3.data/data/app/services/user.py +108 -0
- fastapi_auth_starter-0.1.3.data/data/pyproject.toml +77 -0
- fastapi_auth_starter-0.1.3.data/data/runtime.txt +2 -0
- fastapi_auth_starter-0.1.3.data/data/vercel.json +19 -0
- fastapi_auth_starter-0.1.3.dist-info/METADATA +283 -0
- fastapi_auth_starter-0.1.3.dist-info/RECORD +44 -0
- fastapi_auth_starter-0.1.3.dist-info/WHEEL +4 -0
- fastapi_auth_starter-0.1.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Application configuration settings
|
|
3
|
+
Handles environment variables and configuration management
|
|
4
|
+
All configuration values should be set in .env file or environment variables
|
|
5
|
+
Reference: https://fastapi.tiangolo.com/advanced/settings/
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
from pydantic import Field, ConfigDict
|
|
9
|
+
from pydantic_settings import BaseSettings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Settings(BaseSettings):
|
|
13
|
+
"""
|
|
14
|
+
Application settings loaded from environment variables
|
|
15
|
+
Uses pydantic BaseSettings for validation and type conversion
|
|
16
|
+
|
|
17
|
+
All values should be set in .env file (see .env.example for template)
|
|
18
|
+
"""
|
|
19
|
+
# API Configuration
|
|
20
|
+
# These can have defaults but should be overridden in .env
|
|
21
|
+
API_V1_PREFIX: str = "/api/v1"
|
|
22
|
+
PROJECT_NAME: str = "FastAPI Auth Starter"
|
|
23
|
+
VERSION: str = "0.1.0"
|
|
24
|
+
|
|
25
|
+
# Database Configuration
|
|
26
|
+
# PostgreSQL connection string format: postgresql+asyncpg://user:password@host:port/dbname
|
|
27
|
+
# Reference: https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls
|
|
28
|
+
# Set in .env file or Vercel environment variables
|
|
29
|
+
# Note: Special characters in password must be URL-encoded (e.g., ! = %21)
|
|
30
|
+
DATABASE_URL: str = Field(
|
|
31
|
+
...,
|
|
32
|
+
description="PostgreSQL database URL. Must be set via environment variable."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# WorkOS Configuration
|
|
36
|
+
# WorkOS API key for user management
|
|
37
|
+
# Reference: https://workos.com/docs/reference/api-reference/user-management
|
|
38
|
+
WORKOS_API_KEY: str = Field(
|
|
39
|
+
...,
|
|
40
|
+
description="WorkOS API key. Must be set via environment variable."
|
|
41
|
+
)
|
|
42
|
+
WORKOS_CLIENT_ID: str = Field(
|
|
43
|
+
...,
|
|
44
|
+
description="WorkOS client ID. Must be set via environment variable."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
WORKOS_DEFAULT_CONNECTION_ID: str | None = Field(
|
|
48
|
+
None,
|
|
49
|
+
description="Default WorkOS SSO connection ID. Can be overridden by frontend."
|
|
50
|
+
)
|
|
51
|
+
# Allowed redirect URIs (comma-separated or JSON array)
|
|
52
|
+
# Security: Only these URIs are allowed for OAuth redirects
|
|
53
|
+
WORKOS_ALLOWED_REDIRECT_URIS: str = Field(
|
|
54
|
+
...,
|
|
55
|
+
description="Comma-separated list of allowed redirect URIs for OAuth"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def allowed_redirect_uris_list(self) -> list[str]:
|
|
60
|
+
"""
|
|
61
|
+
Parse allowed redirect URIs into a list.
|
|
62
|
+
|
|
63
|
+
Supports two formats:
|
|
64
|
+
1. JSON array: ["https://app.example.com/callback", "https://app2.example.com/callback"]
|
|
65
|
+
2. Comma-separated: https://app.example.com/callback,https://app2.example.com/callback
|
|
66
|
+
|
|
67
|
+
Reference: https://docs.python.org/3/library/json.html
|
|
68
|
+
"""
|
|
69
|
+
raw = self.WORKOS_ALLOWED_REDIRECT_URIS.strip()
|
|
70
|
+
if not raw:
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
# Try parsing as JSON first (supports JSON array format)
|
|
74
|
+
try:
|
|
75
|
+
parsed = json.loads(raw)
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
# Fall back to comma-separated string format
|
|
78
|
+
return [uri.strip() for uri in raw.split(",") if uri.strip()]
|
|
79
|
+
|
|
80
|
+
# Handle parsed JSON result
|
|
81
|
+
if isinstance(parsed, str):
|
|
82
|
+
return [parsed.strip()]
|
|
83
|
+
if isinstance(parsed, list):
|
|
84
|
+
return [str(uri).strip() for uri in parsed if str(uri).strip()]
|
|
85
|
+
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"WORKOS_ALLOWED_REDIRECT_URIS must be a JSON array or comma-separated string"
|
|
88
|
+
)
|
|
89
|
+
# Alembic Configuration
|
|
90
|
+
# Used for database migrations
|
|
91
|
+
# Reference: https://alembic.sqlalchemy.org/en/latest/tutorial.html
|
|
92
|
+
ALEMBIC_CONFIG: str = "alembic.ini"
|
|
93
|
+
|
|
94
|
+
# Pydantic v2 configuration
|
|
95
|
+
# Reference: https://docs.pydantic.dev/latest/api/config/
|
|
96
|
+
model_config = ConfigDict(
|
|
97
|
+
env_file=".env", # Load from .env file (required)
|
|
98
|
+
case_sensitive=True, # Environment variable names are case-sensitive
|
|
99
|
+
extra="ignore", # Ignore extra environment variables not defined in this class
|
|
100
|
+
# This allows additional env vars (like WorkOS config) to exist without causing validation errors
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Global settings instance
|
|
105
|
+
# Import this in other modules to access configuration
|
|
106
|
+
settings = Settings()
|
|
107
|
+
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database connection and session management
|
|
3
|
+
Uses SQLAlchemy async engine for PostgreSQL
|
|
4
|
+
Reference: https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html
|
|
5
|
+
"""
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
8
|
+
from sqlalchemy.pool import NullPool
|
|
9
|
+
|
|
10
|
+
from app.core.config import settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Validate DATABASE_URL is set
|
|
14
|
+
if not settings.DATABASE_URL:
|
|
15
|
+
raise ValueError(
|
|
16
|
+
"DATABASE_URL environment variable is not set. "
|
|
17
|
+
"Please set it in your .env file or Vercel environment variables."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Create async engine for PostgreSQL
|
|
21
|
+
# Using NullPool for serverless (Vercel) - each request gets a fresh connection
|
|
22
|
+
# Connection pooling doesn't work well in serverless environments where functions
|
|
23
|
+
# are isolated and can be terminated/cold-started
|
|
24
|
+
# Reference: https://docs.sqlalchemy.org/en/20/core/pooling.html#switching-pool-implementations
|
|
25
|
+
# Reference: https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#using-multiple-asyncio-event-loops
|
|
26
|
+
engine = create_async_engine(
|
|
27
|
+
settings.DATABASE_URL,
|
|
28
|
+
echo=False, # Set to True for SQL query logging (useful for debugging)
|
|
29
|
+
poolclass=NullPool, # No connection pooling - each request gets a new connection
|
|
30
|
+
# This is critical for serverless environments where connection reuse
|
|
31
|
+
# across function invocations causes connection termination errors
|
|
32
|
+
# Reference: https://docs.sqlalchemy.org/en/20/core/pooling.html#disabling-pooling-using-nullpool
|
|
33
|
+
connect_args={
|
|
34
|
+
# asyncpg-specific connection arguments
|
|
35
|
+
# Reference: https://magicstack.github.io/asyncpg/current/api/index.html#connection
|
|
36
|
+
"command_timeout": 60, # Increased timeout for serverless network latency
|
|
37
|
+
"server_settings": {
|
|
38
|
+
"application_name": "fastapi_auth_starter",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Create async session factory
|
|
45
|
+
# This is used to create database sessions throughout the application
|
|
46
|
+
# Reference: https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#session-basics
|
|
47
|
+
async_session_maker = async_sessionmaker(
|
|
48
|
+
engine,
|
|
49
|
+
class_=AsyncSession,
|
|
50
|
+
expire_on_commit=False, # Keep objects accessible after commit
|
|
51
|
+
# This is important for serverless - objects remain accessible after commit
|
|
52
|
+
# allowing timestamps to be read even if not refreshed
|
|
53
|
+
autocommit=False,
|
|
54
|
+
autoflush=False,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Base class for all database models
|
|
59
|
+
# All models should inherit from this class
|
|
60
|
+
# Reference: https://docs.sqlalchemy.org/en/20/orm/declarative_styles.html
|
|
61
|
+
class Base(DeclarativeBase):
|
|
62
|
+
"""Base class for SQLAlchemy declarative models"""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Dependency to get database session
|
|
67
|
+
# Used in FastAPI route handlers via dependency injection
|
|
68
|
+
# Reference: https://fastapi.tiangolo.com/tutorial/dependencies/
|
|
69
|
+
async def get_db() -> AsyncSession:
|
|
70
|
+
"""
|
|
71
|
+
Dependency function that provides a database session
|
|
72
|
+
Automatically closes the session after the request completes
|
|
73
|
+
|
|
74
|
+
For serverless environments (Vercel), this ensures proper transaction handling:
|
|
75
|
+
- Commits on success
|
|
76
|
+
- Rolls back on error
|
|
77
|
+
- Always closes the session
|
|
78
|
+
"""
|
|
79
|
+
async with async_session_maker() as session:
|
|
80
|
+
try:
|
|
81
|
+
yield session
|
|
82
|
+
# Commit transaction on success
|
|
83
|
+
# This commits all changes made during the request
|
|
84
|
+
# In serverless, this must complete before the function terminates
|
|
85
|
+
await session.commit()
|
|
86
|
+
except Exception as e:
|
|
87
|
+
# Rollback on any exception to maintain data consistency
|
|
88
|
+
try:
|
|
89
|
+
await session.rollback()
|
|
90
|
+
except Exception:
|
|
91
|
+
# If rollback fails, connection is likely already closed
|
|
92
|
+
# This can happen in serverless when connections are terminated
|
|
93
|
+
pass
|
|
94
|
+
# Re-raise the original exception so FastAPI can handle it properly
|
|
95
|
+
raise
|
|
96
|
+
finally:
|
|
97
|
+
# Always close the session to release connection
|
|
98
|
+
# With NullPool, this closes the connection completely
|
|
99
|
+
# In serverless, this is critical to prevent connection leaks
|
|
100
|
+
try:
|
|
101
|
+
await session.close()
|
|
102
|
+
except Exception:
|
|
103
|
+
# If close fails, connection is likely already closed or terminated
|
|
104
|
+
# This is acceptable - we're in cleanup anyway
|
|
105
|
+
pass
|
|
106
|
+
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication dependencies for protecting endpoints
|
|
3
|
+
Reference: https://fastapi.tiangolo.com/tutorial/dependencies/
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from fastapi import Depends, HTTPException, status
|
|
9
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
10
|
+
from workos import WorkOSClient
|
|
11
|
+
from workos.exceptions import AuthenticationException, NotFoundException
|
|
12
|
+
|
|
13
|
+
from app.services.auth import AuthService
|
|
14
|
+
from app.api.v1.schemas.auth import WorkOSUserResponse
|
|
15
|
+
from app.core.config import settings
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# HTTPBearer automatically extracts Bearer token from Authorization header
|
|
20
|
+
# Reference: https://fastapi.tiangolo.com/reference/security/#fastapi.security.HTTPBearer
|
|
21
|
+
security = HTTPBearer()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@lru_cache()
|
|
25
|
+
def get_auth_service() -> AuthService:
|
|
26
|
+
"""
|
|
27
|
+
Get a singleton AuthService instance.
|
|
28
|
+
|
|
29
|
+
Using lru_cache ensures the same AuthService instance is reused across requests,
|
|
30
|
+
which preserves the JWKS cache (_jwks_cache and _jwks_cache_expiry) at the
|
|
31
|
+
application level rather than request level. This avoids repeated JWKS API calls
|
|
32
|
+
to WorkOS and improves performance.
|
|
33
|
+
|
|
34
|
+
Reference: https://docs.python.org/3/library/functools.html#functools.lru_cache
|
|
35
|
+
"""
|
|
36
|
+
return AuthService()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def get_current_user(
|
|
40
|
+
credentials: HTTPAuthorizationCredentials = Depends(security)
|
|
41
|
+
) -> WorkOSUserResponse:
|
|
42
|
+
"""
|
|
43
|
+
Dependency to get the current authenticated user.
|
|
44
|
+
|
|
45
|
+
Validates the JWT access token from the Authorization header using WorkOS JWKS,
|
|
46
|
+
then fetches full user details from WorkOS.
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
@router.get("/protected")
|
|
50
|
+
async def protected_route(current_user = Depends(get_current_user)):
|
|
51
|
+
return {"user_id": current_user.id, "email": current_user.email}
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
credentials: HTTPAuthorizationCredentials containing the Bearer token
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
WorkOSUserResponse: The authenticated user with full details
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
HTTPException: 401 if token is invalid or missing
|
|
61
|
+
"""
|
|
62
|
+
auth_service = get_auth_service()
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
# Extract the token from credentials
|
|
66
|
+
access_token = credentials.credentials
|
|
67
|
+
logger.debug(f"Verifying session with token: {access_token[:20]}...")
|
|
68
|
+
|
|
69
|
+
# Verify the session with WorkOS (validates JWT signature and expiration)
|
|
70
|
+
# Reference: https://workos.com/docs/reference/authkit/session-tokens/access-token
|
|
71
|
+
session_data = await auth_service.verify_session(access_token)
|
|
72
|
+
|
|
73
|
+
user_id = session_data.get('user_id')
|
|
74
|
+
if not user_id:
|
|
75
|
+
logger.error("Token missing user_id (sub claim)")
|
|
76
|
+
raise HTTPException(
|
|
77
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
78
|
+
detail="Invalid token: missing user information",
|
|
79
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
logger.debug(f"Token verified successfully. User ID: {user_id}")
|
|
83
|
+
|
|
84
|
+
# Fetch full user details from WorkOS
|
|
85
|
+
# The JWT contains basic info, but we fetch full details for complete user object
|
|
86
|
+
workos_client = WorkOSClient(
|
|
87
|
+
api_key=settings.WORKOS_API_KEY,
|
|
88
|
+
client_id=settings.WORKOS_CLIENT_ID
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Offload synchronous WorkOS call to thread pool
|
|
92
|
+
workos_user = await asyncio.to_thread(
|
|
93
|
+
workos_client.user_management.get_user,
|
|
94
|
+
user_id=user_id
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Convert WorkOS user to our schema
|
|
98
|
+
user = WorkOSUserResponse(
|
|
99
|
+
object=workos_user.object,
|
|
100
|
+
id=workos_user.id,
|
|
101
|
+
email=workos_user.email,
|
|
102
|
+
first_name=workos_user.first_name,
|
|
103
|
+
last_name=workos_user.last_name,
|
|
104
|
+
email_verified=workos_user.email_verified,
|
|
105
|
+
profile_picture_url=workos_user.profile_picture_url,
|
|
106
|
+
created_at=workos_user.created_at,
|
|
107
|
+
updated_at=workos_user.updated_at,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
logger.debug(f"User fetched: {user.id} ({user.email})")
|
|
111
|
+
return user
|
|
112
|
+
|
|
113
|
+
except ValueError as e:
|
|
114
|
+
# Map error to RFC6750-compliant WWW-Authenticate header
|
|
115
|
+
msg = str(e)
|
|
116
|
+
# Default values
|
|
117
|
+
error = "invalid_token"
|
|
118
|
+
description = msg or "The access token is invalid"
|
|
119
|
+
|
|
120
|
+
if "expired" in msg.lower():
|
|
121
|
+
description = "The access token expired"
|
|
122
|
+
|
|
123
|
+
raise HTTPException(
|
|
124
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
125
|
+
detail=msg,
|
|
126
|
+
headers={
|
|
127
|
+
"WWW-Authenticate": f'Bearer realm="api", error="{error}", error_description="{description}"'
|
|
128
|
+
},
|
|
129
|
+
) from e
|
|
130
|
+
except NotFoundException:
|
|
131
|
+
# User not found in WorkOS (shouldn't happen if token is valid)
|
|
132
|
+
logger.error(f"User not found in WorkOS after token verification")
|
|
133
|
+
raise HTTPException(
|
|
134
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
135
|
+
detail="User not found",
|
|
136
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
137
|
+
)
|
|
138
|
+
except HTTPException:
|
|
139
|
+
# Re-raise HTTP exceptions (already formatted)
|
|
140
|
+
raise
|
|
141
|
+
except Exception as e:
|
|
142
|
+
# Unexpected error - log full details for debugging
|
|
143
|
+
logger.error(f"Unexpected authentication error: {type(e).__name__}: {e}", exc_info=True)
|
|
144
|
+
raise HTTPException(
|
|
145
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
146
|
+
detail=f"Authentication failed: {str(e)}",
|
|
147
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
148
|
+
) from e
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI application entry point
|
|
3
|
+
Main application factory and configuration
|
|
4
|
+
Reference: https://fastapi.tiangolo.com/tutorial/bigger-applications/
|
|
5
|
+
"""
|
|
6
|
+
import logging
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
11
|
+
from sqlalchemy import text
|
|
12
|
+
|
|
13
|
+
from app.api.v1.api import api_router
|
|
14
|
+
from app.core.config import settings
|
|
15
|
+
from app.core.database import engine
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@asynccontextmanager
|
|
21
|
+
async def lifespan(app: FastAPI):
|
|
22
|
+
"""
|
|
23
|
+
Lifespan context manager for startup and shutdown events
|
|
24
|
+
Validates database connection on startup
|
|
25
|
+
Reference: https://fastapi.tiangolo.com/advanced/events/
|
|
26
|
+
"""
|
|
27
|
+
# Startup: Test database connection
|
|
28
|
+
try:
|
|
29
|
+
async with engine.begin() as conn:
|
|
30
|
+
await conn.execute(text("SELECT 1"))
|
|
31
|
+
logger.info("✓ Database connection successful")
|
|
32
|
+
except Exception as e:
|
|
33
|
+
logger.error(f"✗ Database connection failed: {e}")
|
|
34
|
+
logger.error(
|
|
35
|
+
"Please check:\n"
|
|
36
|
+
"1. DATABASE_URL is set correctly in Vercel environment variables\n"
|
|
37
|
+
"2. AWS RDS security group allows connections from Vercel IP ranges\n"
|
|
38
|
+
"3. Database is accessible and credentials are correct"
|
|
39
|
+
)
|
|
40
|
+
# Don't raise - let the app start but connections will fail
|
|
41
|
+
# This allows health checks to work
|
|
42
|
+
|
|
43
|
+
yield
|
|
44
|
+
|
|
45
|
+
# Shutdown: Dispose of database connections
|
|
46
|
+
await engine.dispose()
|
|
47
|
+
logger.info("Database connections closed")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Create FastAPI application instance
|
|
51
|
+
# Reference: https://fastapi.tiangolo.com/reference/fastapi/
|
|
52
|
+
app = FastAPI(
|
|
53
|
+
title=settings.PROJECT_NAME,
|
|
54
|
+
version=settings.VERSION,
|
|
55
|
+
description="A clean architecture FastAPI starter with PostgreSQL and Alembic",
|
|
56
|
+
docs_url="/docs", # Swagger UI documentation
|
|
57
|
+
redoc_url="/redoc", # ReDoc documentation
|
|
58
|
+
lifespan=lifespan, # Add lifespan for startup/shutdown events
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
allowed_origins = [
|
|
62
|
+
"http://localhost:3000",
|
|
63
|
+
# add other trusted origins here
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
app.add_middleware(
|
|
67
|
+
CORSMiddleware,
|
|
68
|
+
allow_origins=allowed_origins,
|
|
69
|
+
allow_credentials=True,
|
|
70
|
+
allow_methods=["*"],
|
|
71
|
+
allow_headers=["*"],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Include API routers
|
|
76
|
+
# All routes from api_router will be included in the main app
|
|
77
|
+
app.include_router(api_router)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.get("/")
|
|
81
|
+
async def root():
|
|
82
|
+
"""
|
|
83
|
+
Root endpoint
|
|
84
|
+
Provides basic information about the API
|
|
85
|
+
"""
|
|
86
|
+
return {
|
|
87
|
+
"message": f"Welcome to {settings.PROJECT_NAME}",
|
|
88
|
+
"version": settings.VERSION,
|
|
89
|
+
"docs": "/docs",
|
|
90
|
+
}
|
|
91
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database models
|
|
3
|
+
All SQLAlchemy models should be defined here or imported here
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Import Base for models to inherit from
|
|
7
|
+
from app.core.database import Base
|
|
8
|
+
|
|
9
|
+
# Import models here as they are created
|
|
10
|
+
from app.models.task import Task
|
|
11
|
+
from app.models.user import User
|
|
12
|
+
|
|
13
|
+
# Export all models for easy imports
|
|
14
|
+
__all__ = ["Base", "Task", "User"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Task database model
|
|
3
|
+
SQLAlchemy model for tasks
|
|
4
|
+
Reference: https://docs.sqlalchemy.org/en/20/orm/declarative_styles.html
|
|
5
|
+
"""
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from sqlalchemy import Integer, String, Boolean, DateTime, func
|
|
8
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
9
|
+
|
|
10
|
+
from app.core.database import Base
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Task(Base):
|
|
14
|
+
"""
|
|
15
|
+
Task model representing a task in the database
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
id: Primary key, auto-incrementing integer
|
|
19
|
+
title: Task title (required)
|
|
20
|
+
description: Optional task description
|
|
21
|
+
completed: Whether the task is completed (default: False)
|
|
22
|
+
created_at: Timestamp when task was created (auto-generated)
|
|
23
|
+
updated_at: Timestamp when task was last updated (auto-generated)
|
|
24
|
+
|
|
25
|
+
Reference: https://docs.sqlalchemy.org/en/20/orm/mapped_sql_expressions.html
|
|
26
|
+
"""
|
|
27
|
+
__tablename__ = "tasks"
|
|
28
|
+
|
|
29
|
+
# Primary key
|
|
30
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
31
|
+
|
|
32
|
+
# Task fields
|
|
33
|
+
title: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
|
34
|
+
description: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
|
35
|
+
completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
36
|
+
|
|
37
|
+
# Timestamps
|
|
38
|
+
# Using server_default with func.now() for automatic timestamp generation
|
|
39
|
+
# These are set by PostgreSQL on INSERT/UPDATE
|
|
40
|
+
# Reference: https://docs.sqlalchemy.org/en/20/core/defaults.html#server-side-defaults
|
|
41
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
42
|
+
DateTime(timezone=True),
|
|
43
|
+
server_default=func.now(), # Server-side default for creation time
|
|
44
|
+
nullable=False,
|
|
45
|
+
)
|
|
46
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
47
|
+
DateTime(timezone=True),
|
|
48
|
+
server_default=func.now(), # Server-side default for initial value
|
|
49
|
+
onupdate=func.now(), # Update on modification
|
|
50
|
+
nullable=False,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def __repr__(self) -> str:
|
|
54
|
+
"""String representation of Task"""
|
|
55
|
+
return f"<Task(id={self.id}, title='{self.title}', completed={self.completed})>"
|
|
56
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
|
4
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
5
|
+
from app.core.database import Base
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class User(Base):
|
|
9
|
+
"""
|
|
10
|
+
User model representing a user in the database
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
id: Primary key, UUID
|
|
14
|
+
email: User email (required)
|
|
15
|
+
first_name: User first name (optional)
|
|
16
|
+
last_name: User last name (optional)
|
|
17
|
+
created_at: Timestamp when user was created (auto-generated)
|
|
18
|
+
updated_at: Timestamp when user was last updated (auto-generated)
|
|
19
|
+
|
|
20
|
+
Reference: https://docs.sqlalchemy.org/en/20/orm/mapped_sql_expressions.html
|
|
21
|
+
"""
|
|
22
|
+
__tablename__ = "users"
|
|
23
|
+
|
|
24
|
+
id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
25
|
+
email: Mapped[str] = mapped_column(String, unique=True, index=True)
|
|
26
|
+
first_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
27
|
+
last_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
28
|
+
# pending_verification_token: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
29
|
+
# token_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
30
|
+
# is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
31
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
32
|
+
DateTime(timezone=True),
|
|
33
|
+
server_default=func.now(), # Server-side default for creation time
|
|
34
|
+
nullable=False,
|
|
35
|
+
)
|
|
36
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
37
|
+
DateTime(timezone=True),
|
|
38
|
+
server_default=func.now(), # Server-side default for initial value
|
|
39
|
+
onupdate=func.now(), # Update on modification
|
|
40
|
+
nullable=False,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def __repr__(self) -> str:
|
|
44
|
+
"String representation of user"
|
|
45
|
+
return f"<User(id={self.id}, email='{self.email}', created_at={self.created_at})>"
|