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,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Task API routes
|
|
3
|
+
CRUD endpoints for task management
|
|
4
|
+
Reference: https://fastapi.tiangolo.com/tutorial/sql-databases/
|
|
5
|
+
"""
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from app.core.database import get_db
|
|
11
|
+
from app.api.v1.schemas.task import TaskCreate, TaskUpdate, TaskResponse
|
|
12
|
+
from app.services.task import TaskService
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Create router for task endpoints
|
|
16
|
+
router = APIRouter(
|
|
17
|
+
prefix="/tasks",
|
|
18
|
+
tags=["tasks"], # Groups endpoints in API documentation
|
|
19
|
+
responses={
|
|
20
|
+
404: {"description": "Task not found"},
|
|
21
|
+
500: {"description": "Internal server error"}
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.get(
|
|
27
|
+
"",
|
|
28
|
+
response_model=List[TaskResponse],
|
|
29
|
+
summary="List tasks",
|
|
30
|
+
description="Retrieve a list of tasks with optional filtering and pagination",
|
|
31
|
+
status_code=status.HTTP_200_OK
|
|
32
|
+
)
|
|
33
|
+
async def get_tasks(
|
|
34
|
+
skip: int = Query(0, ge=0, description="Number of tasks to skip"),
|
|
35
|
+
limit: int = Query(100, ge=1, le=1000, description="Maximum number of tasks to return"),
|
|
36
|
+
completed: Optional[bool] = Query(None, description="Filter by completion status"),
|
|
37
|
+
db: AsyncSession = Depends(get_db)
|
|
38
|
+
) -> List[TaskResponse]:
|
|
39
|
+
"""
|
|
40
|
+
Get a list of tasks
|
|
41
|
+
|
|
42
|
+
Supports:
|
|
43
|
+
- Pagination via skip and limit parameters
|
|
44
|
+
- Filtering by completion status
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
List of TaskResponse objects
|
|
48
|
+
"""
|
|
49
|
+
tasks = await TaskService.get_tasks(db, skip=skip, limit=limit, completed=completed)
|
|
50
|
+
return tasks
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.get(
|
|
54
|
+
"/{task_id}",
|
|
55
|
+
response_model=TaskResponse,
|
|
56
|
+
summary="Get task by ID",
|
|
57
|
+
description="Retrieve a single task by its ID",
|
|
58
|
+
status_code=status.HTTP_200_OK,
|
|
59
|
+
responses={
|
|
60
|
+
404: {"description": "Task not found"}
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
async def get_task(
|
|
64
|
+
task_id: int,
|
|
65
|
+
db: AsyncSession = Depends(get_db)
|
|
66
|
+
) -> TaskResponse:
|
|
67
|
+
"""
|
|
68
|
+
Get a single task by ID
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
task_id: ID of the task to retrieve
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
TaskResponse object
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
HTTPException: If task is not found
|
|
78
|
+
"""
|
|
79
|
+
task = await TaskService.get_task(db, task_id)
|
|
80
|
+
if not task:
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
83
|
+
detail=f"Task with ID {task_id} not found"
|
|
84
|
+
)
|
|
85
|
+
return task
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.post(
|
|
89
|
+
"",
|
|
90
|
+
response_model=TaskResponse,
|
|
91
|
+
summary="Create task",
|
|
92
|
+
description="Create a new task",
|
|
93
|
+
status_code=status.HTTP_201_CREATED
|
|
94
|
+
)
|
|
95
|
+
async def create_task(
|
|
96
|
+
task_data: TaskCreate,
|
|
97
|
+
db: AsyncSession = Depends(get_db)
|
|
98
|
+
) -> TaskResponse:
|
|
99
|
+
"""
|
|
100
|
+
Create a new task
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
task_data: Task creation data
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Created TaskResponse object
|
|
107
|
+
"""
|
|
108
|
+
task = await TaskService.create_task(db, task_data)
|
|
109
|
+
return task
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@router.put(
|
|
113
|
+
"/{task_id}",
|
|
114
|
+
response_model=TaskResponse,
|
|
115
|
+
summary="Update task",
|
|
116
|
+
description="Update an existing task (all fields required)",
|
|
117
|
+
status_code=status.HTTP_200_OK,
|
|
118
|
+
responses={
|
|
119
|
+
404: {"description": "Task not found"}
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
async def update_task(
|
|
123
|
+
task_id: int,
|
|
124
|
+
task_data: TaskUpdate,
|
|
125
|
+
db: AsyncSession = Depends(get_db)
|
|
126
|
+
) -> TaskResponse:
|
|
127
|
+
"""
|
|
128
|
+
Update an existing task
|
|
129
|
+
|
|
130
|
+
All fields in TaskUpdate are optional, allowing partial updates.
|
|
131
|
+
Only provided fields will be updated.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
task_id: ID of the task to update
|
|
135
|
+
task_data: Task update data
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Updated TaskResponse object
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
HTTPException: If task is not found
|
|
142
|
+
"""
|
|
143
|
+
task = await TaskService.update_task(db, task_id, task_data)
|
|
144
|
+
if not task:
|
|
145
|
+
raise HTTPException(
|
|
146
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
147
|
+
detail=f"Task with ID {task_id} not found"
|
|
148
|
+
)
|
|
149
|
+
return task
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@router.delete(
|
|
153
|
+
"/{task_id}",
|
|
154
|
+
summary="Delete task",
|
|
155
|
+
description="Delete a task by ID",
|
|
156
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
157
|
+
responses={
|
|
158
|
+
404: {"description": "Task not found"},
|
|
159
|
+
204: {"description": "Task deleted successfully"}
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
async def delete_task(
|
|
163
|
+
task_id: int,
|
|
164
|
+
db: AsyncSession = Depends(get_db)
|
|
165
|
+
):
|
|
166
|
+
"""
|
|
167
|
+
Delete a task
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
task_id: ID of the task to delete
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
HTTPException: If task is not found
|
|
174
|
+
"""
|
|
175
|
+
deleted = await TaskService.delete_task(db, task_id)
|
|
176
|
+
if not deleted:
|
|
177
|
+
raise HTTPException(
|
|
178
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
179
|
+
detail=f"Task with ID {task_id} not found"
|
|
180
|
+
)
|
|
181
|
+
return None # 204 No Content
|
|
182
|
+
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
3
|
+
from workos.exceptions import BadRequestException
|
|
4
|
+
from app.api.v1.schemas.auth import WorkOSUserResponse
|
|
5
|
+
from app.api.v1.schemas.user import UserResponse, UserUpdate
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from app.core.database import get_db
|
|
9
|
+
# from app.core.exceptions import InvalidPasswordException
|
|
10
|
+
from app.core.dependencies import get_current_user
|
|
11
|
+
from app.services.user import UserService
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
router = APIRouter(
|
|
17
|
+
prefix="/users",
|
|
18
|
+
tags=["users"],
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
@router.get("", response_model=List[UserResponse])
|
|
22
|
+
async def get_users(
|
|
23
|
+
skip: int = Query(0, ge=0, description="Number of users to skip"),
|
|
24
|
+
limit: int = Query(100, ge=1, le=1000, description="Maximum number of users to return"),
|
|
25
|
+
current_user: WorkOSUserResponse = Depends(get_current_user),
|
|
26
|
+
db: AsyncSession = Depends(get_db)
|
|
27
|
+
) -> List[UserResponse]:
|
|
28
|
+
"""
|
|
29
|
+
Get a list of users
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
skip: Number of users to skip (for pagination)
|
|
33
|
+
limit: Maximum number of users to return (for pagination)
|
|
34
|
+
db: Database session
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of UserResponse objects
|
|
38
|
+
"""
|
|
39
|
+
user_service = UserService()
|
|
40
|
+
users = await user_service.get_users(db, skip=skip, limit=limit)
|
|
41
|
+
return users
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.get("/{user_id}", response_model=UserResponse)
|
|
45
|
+
async def get_user(
|
|
46
|
+
user_id: str,
|
|
47
|
+
db: AsyncSession = Depends(get_db)
|
|
48
|
+
) -> UserResponse:
|
|
49
|
+
"""
|
|
50
|
+
Get a user by ID
|
|
51
|
+
"""
|
|
52
|
+
user_service = UserService()
|
|
53
|
+
user = await user_service.get_user(db, user_id)
|
|
54
|
+
if not user:
|
|
55
|
+
raise HTTPException(
|
|
56
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
57
|
+
detail="User not found"
|
|
58
|
+
)
|
|
59
|
+
return user
|
|
60
|
+
|
|
61
|
+
# POST /users removed - use POST /auth/signup for user registration instead
|
|
62
|
+
|
|
63
|
+
@router.patch(
|
|
64
|
+
"/{user_id}",
|
|
65
|
+
response_model=UserResponse,
|
|
66
|
+
summary="Update user",
|
|
67
|
+
description="Update a user by ID",
|
|
68
|
+
status_code=status.HTTP_200_OK
|
|
69
|
+
)
|
|
70
|
+
async def update_user(
|
|
71
|
+
user_id: str,
|
|
72
|
+
user_data: UserUpdate,
|
|
73
|
+
db: AsyncSession = Depends(get_db)
|
|
74
|
+
):
|
|
75
|
+
"""
|
|
76
|
+
Update a user by ID
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
user_id: ID of the user to update
|
|
80
|
+
user_data: User update data
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Updated UserResponse object
|
|
84
|
+
"""
|
|
85
|
+
user_service = UserService()
|
|
86
|
+
try:
|
|
87
|
+
user = await user_service.update_user(db, user_id, user_data)
|
|
88
|
+
if not user:
|
|
89
|
+
raise HTTPException(
|
|
90
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
91
|
+
detail="User not found"
|
|
92
|
+
)
|
|
93
|
+
return user
|
|
94
|
+
except BadRequestException as e:
|
|
95
|
+
logger.error(f"Bad request updating user: {e}")
|
|
96
|
+
raise HTTPException(
|
|
97
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
98
|
+
detail=f"Failed to update user: {e.message if hasattr(e, 'message') else str(e)}"
|
|
99
|
+
) from e
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.error(f"Unexpected error updating user: {e}")
|
|
102
|
+
raise HTTPException(
|
|
103
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
104
|
+
detail="An unexpected error occurred while updating the user"
|
|
105
|
+
) from e
|
|
106
|
+
|
|
107
|
+
@router.delete(
|
|
108
|
+
"/{user_id}",
|
|
109
|
+
summary="Delete user",
|
|
110
|
+
description="Delete a user by ID",
|
|
111
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
112
|
+
responses={
|
|
113
|
+
404: {"description": "User not found"},
|
|
114
|
+
204: {"description": "User deleted successfully"}
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
async def delete_user(
|
|
118
|
+
user_id: str,
|
|
119
|
+
db: AsyncSession = Depends(get_db)
|
|
120
|
+
):
|
|
121
|
+
"""
|
|
122
|
+
Delete a user by ID
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
user_id: ID of the user to delete
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
None
|
|
129
|
+
"""
|
|
130
|
+
user_service = UserService()
|
|
131
|
+
try:
|
|
132
|
+
deleted = await user_service.delete_user(db, user_id)
|
|
133
|
+
if not deleted:
|
|
134
|
+
raise HTTPException(
|
|
135
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
136
|
+
detail="User not found"
|
|
137
|
+
)
|
|
138
|
+
return None
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.error(f"Unexpected error deleting user: {e}")
|
|
141
|
+
raise HTTPException(
|
|
142
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
143
|
+
detail="An unexpected error occurred while deleting the user"
|
|
144
|
+
) from e
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from app.api.v1.schemas.user import WorkOSUserResponse
|
|
6
|
+
|
|
7
|
+
class EmailVerificationRequiredResponse(BaseModel):
|
|
8
|
+
message: str
|
|
9
|
+
pending_authentication_token: str
|
|
10
|
+
email_verification_id: str
|
|
11
|
+
email: str
|
|
12
|
+
requires_verification: bool = True
|
|
13
|
+
|
|
14
|
+
class VerifyEmailRequest(BaseModel):
|
|
15
|
+
pending_authentication_token: str = Field(..., description="Pending authentication token")
|
|
16
|
+
code: str = Field(..., description="Code")
|
|
17
|
+
|
|
18
|
+
class WorkOsVerifyEmailRequest(VerifyEmailRequest):
|
|
19
|
+
ip_address: str = Field(..., description="User IP address")
|
|
20
|
+
user_agent: str = Field(..., description="User user agent")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LoginRequest(BaseModel):
|
|
25
|
+
email: str = Field(..., description="User email")
|
|
26
|
+
password: str = Field(..., description="User password")
|
|
27
|
+
|
|
28
|
+
class SignupRequest(BaseModel):
|
|
29
|
+
"""
|
|
30
|
+
Schema for user signup/registration.
|
|
31
|
+
|
|
32
|
+
Used for public self-registration endpoint.
|
|
33
|
+
Includes password validation and confirmation.
|
|
34
|
+
"""
|
|
35
|
+
email: str = Field(..., min_length=1, max_length=255, description="User email")
|
|
36
|
+
password: str = Field(..., min_length=8, max_length=255, description="User password")
|
|
37
|
+
confirm_password: str = Field(..., min_length=8, max_length=255, description="Password confirmation")
|
|
38
|
+
first_name: Optional[str] = Field(None, max_length=255, description="User first name")
|
|
39
|
+
last_name: Optional[str] = Field(None, max_length=255, description="User last name")
|
|
40
|
+
|
|
41
|
+
@field_validator('password')
|
|
42
|
+
def validate_password(cls, v: str) -> str:
|
|
43
|
+
"""Validate password strength."""
|
|
44
|
+
if len(v) < 8:
|
|
45
|
+
raise ValueError("Password must be at least 8 characters long")
|
|
46
|
+
if not any(char.isdigit() for char in v):
|
|
47
|
+
raise ValueError("Password must contain at least one number")
|
|
48
|
+
if not any(char.isalpha() for char in v):
|
|
49
|
+
raise ValueError("Password must contain at least one letter")
|
|
50
|
+
if not any(char.isupper() for char in v):
|
|
51
|
+
raise ValueError("Password must contain at least one uppercase letter")
|
|
52
|
+
if not any(char.islower() for char in v):
|
|
53
|
+
raise ValueError("Password must contain at least one lowercase letter")
|
|
54
|
+
return v
|
|
55
|
+
|
|
56
|
+
@model_validator(mode='after')
|
|
57
|
+
def validate_confirm_password(self) -> 'SignupRequest':
|
|
58
|
+
"""Ensure password and confirm_password match."""
|
|
59
|
+
if self.password != self.confirm_password:
|
|
60
|
+
raise ValueError("Password and confirm password do not match")
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
class WorkOSLoginRequest(LoginRequest):
|
|
64
|
+
ip_address: str = Field(..., description="User IP address")
|
|
65
|
+
user_agent: str = Field(..., description="User user agent")
|
|
66
|
+
|
|
67
|
+
class LoginResponse(BaseModel):
|
|
68
|
+
user: WorkOSUserResponse = Field(..., description="User")
|
|
69
|
+
organization_id: str | None = Field(None, description="Organization ID")
|
|
70
|
+
access_token: str = Field(..., description="Access token")
|
|
71
|
+
refresh_token: str = Field(..., description="Refresh token")
|
|
72
|
+
|
|
73
|
+
class SignupResponse(BaseModel):
|
|
74
|
+
"""
|
|
75
|
+
Response for user signup.
|
|
76
|
+
|
|
77
|
+
Returns user information without tokens since email verification is required.
|
|
78
|
+
User must verify email and then login to get tokens.
|
|
79
|
+
"""
|
|
80
|
+
user: WorkOSUserResponse = Field(..., description="Created user")
|
|
81
|
+
message: str = Field(default="User created successfully. Please verify your email to login.", description="Success message")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ForgotPasswordRequest(BaseModel):
|
|
85
|
+
email: str = Field(..., description="User email")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ForgotPasswordResponse(BaseModel):
|
|
89
|
+
message: str = Field(
|
|
90
|
+
default="If an account exists with this email address, a password reset link has been sent.",
|
|
91
|
+
description="Generic success message"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
class WorkOSResetPasswordRequest(BaseModel):
|
|
95
|
+
token: str = Field(..., description="Reset password token")
|
|
96
|
+
new_password: str = Field(..., description="New password")
|
|
97
|
+
|
|
98
|
+
class ResetPasswordRequest(WorkOSResetPasswordRequest):
|
|
99
|
+
confirm_new_password: str = Field(..., description="Confirm new password")
|
|
100
|
+
|
|
101
|
+
@field_validator('new_password')
|
|
102
|
+
def validate_new_password(cls, v: str) -> str:
|
|
103
|
+
"""Validate new password strength."""
|
|
104
|
+
if len(v) < 8:
|
|
105
|
+
raise ValueError("Password must be at least 8 characters long")
|
|
106
|
+
if not any(char.isdigit() for char in v):
|
|
107
|
+
raise ValueError("Password must contain at least one number")
|
|
108
|
+
if not any(char.isalpha() for char in v):
|
|
109
|
+
raise ValueError("Password must contain at least one letter")
|
|
110
|
+
if not any(char.isupper() for char in v):
|
|
111
|
+
raise ValueError("Password must contain at least one uppercase letter")
|
|
112
|
+
if not any(char.islower() for char in v):
|
|
113
|
+
raise ValueError("Password must contain at least one lowercase letter")
|
|
114
|
+
return v
|
|
115
|
+
|
|
116
|
+
@model_validator(mode='after')
|
|
117
|
+
def validate_confirm_new_password(self) -> 'ResetPasswordRequest':
|
|
118
|
+
"""Ensure new password and confirm_new_password match."""
|
|
119
|
+
if self.new_password != self.confirm_new_password:
|
|
120
|
+
raise ValueError("New password and confirm new password do not match")
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
class WorkOSImpersonatorResponse(BaseModel):
|
|
124
|
+
email: str | None = Field(None, description="Impersonator email")
|
|
125
|
+
reason: str | None = Field(None, description="Impersonation reason")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class VerifyEmailResponse(BaseModel):
|
|
129
|
+
access_token: str | None = Field(None, description="Access token")
|
|
130
|
+
refresh_token: str | None = Field(None, description="Refresh token")
|
|
131
|
+
authentication_method: str | None = Field(None, description="Authentication method")
|
|
132
|
+
impersonator: WorkOSImpersonatorResponse | None = Field(None, description="Impersonator")
|
|
133
|
+
organization_id: str | None = Field(None, description="Organization ID")
|
|
134
|
+
user: WorkOSUserResponse | None = Field(None, description="User")
|
|
135
|
+
sealed_session: str | None = Field(None, description="Sealed session")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class AuthorizationRequest(BaseModel):
|
|
139
|
+
"""
|
|
140
|
+
Request to generate OAuth2 authorization URL.
|
|
141
|
+
|
|
142
|
+
Supports two patterns:
|
|
143
|
+
1. AuthKit: Use provider="authkit" (unified interface)
|
|
144
|
+
2. SSO: Use connection_id (direct provider connection)
|
|
145
|
+
|
|
146
|
+
Exactly one of provider or connection_id must be provided.
|
|
147
|
+
"""
|
|
148
|
+
provider: str | None = Field(
|
|
149
|
+
None,
|
|
150
|
+
description="AuthKit provider (use 'authkit' for unified interface). Mutually exclusive with connection_id."
|
|
151
|
+
)
|
|
152
|
+
connection_id: str | None = Field(
|
|
153
|
+
None,
|
|
154
|
+
description="WorkOS SSO connection ID for direct provider (e.g., Google, Microsoft). Mutually exclusive with provider."
|
|
155
|
+
)
|
|
156
|
+
redirect_uri: str = Field(
|
|
157
|
+
...,
|
|
158
|
+
description="URI to redirect to after authentication. Must be in allowed list."
|
|
159
|
+
)
|
|
160
|
+
state: str | None = Field(
|
|
161
|
+
None,
|
|
162
|
+
description="Optional state parameter for CSRF protection"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@model_validator(mode='after')
|
|
166
|
+
def validate_provider_or_connection(self):
|
|
167
|
+
"""Ensure exactly one of provider or connection_id is provided"""
|
|
168
|
+
has_provider = self.provider is not None
|
|
169
|
+
has_connection = self.connection_id is not None
|
|
170
|
+
|
|
171
|
+
if not has_provider and not has_connection:
|
|
172
|
+
raise ValueError("Either 'provider' or 'connection_id' must be provided")
|
|
173
|
+
|
|
174
|
+
if has_provider and has_connection:
|
|
175
|
+
raise ValueError("'provider' and 'connection_id' are mutually exclusive. Provide only one.")
|
|
176
|
+
|
|
177
|
+
return self
|
|
178
|
+
|
|
179
|
+
class AuthorizationUrlResponse(BaseModel):
|
|
180
|
+
authorization_url: str = Field(..., description="Authorization URL")
|
|
181
|
+
|
|
182
|
+
class WorkOSAuthorizationRequest(AuthorizationRequest):
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
class OAuthCallbackRequest(BaseModel):
|
|
186
|
+
code: str = Field(..., description="Authorization code from OAuth callback")
|
|
187
|
+
state: str | None = Field(None, description="State parameter for CSRF verification")
|
|
188
|
+
|
|
189
|
+
class RefreshTokenRequest(BaseModel):
|
|
190
|
+
refresh_token: str = Field(..., description="Refresh token")
|
|
191
|
+
|
|
192
|
+
class WorkOSRefreshTokenRequest(RefreshTokenRequest):
|
|
193
|
+
ip_address: str = Field(..., description="User IP address")
|
|
194
|
+
user_agent: str = Field(..., description="User user agent")
|
|
195
|
+
|
|
196
|
+
class RefreshTokenResponse(BaseModel):
|
|
197
|
+
access_token: str = Field(..., description="Access token")
|
|
198
|
+
refresh_token: str = Field(..., description="Refresh token")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Task Pydantic schemas
|
|
3
|
+
Request and response models for Task API endpoints
|
|
4
|
+
Reference: https://fastapi.tiangolo.com/tutorial/body/
|
|
5
|
+
"""
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TaskBase(BaseModel):
|
|
12
|
+
"""
|
|
13
|
+
Base schema with common Task fields
|
|
14
|
+
Used as base for create and update schemas
|
|
15
|
+
"""
|
|
16
|
+
title: str = Field(..., min_length=1, max_length=200, description="Task title")
|
|
17
|
+
description: Optional[str] = Field(None, max_length=1000, description="Task description")
|
|
18
|
+
completed: bool = Field(default=False, description="Whether the task is completed")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TaskCreate(TaskBase):
|
|
22
|
+
"""
|
|
23
|
+
Schema for creating a new task
|
|
24
|
+
Inherits from TaskBase
|
|
25
|
+
"""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TaskUpdate(BaseModel):
|
|
30
|
+
"""
|
|
31
|
+
Schema for updating a task
|
|
32
|
+
All fields are optional for partial updates
|
|
33
|
+
Reference: https://fastapi.tiangolo.com/tutorial/body-updates/
|
|
34
|
+
"""
|
|
35
|
+
title: Optional[str] = Field(None, min_length=1, max_length=200, description="Task title")
|
|
36
|
+
description: Optional[str] = Field(None, max_length=1000, description="Task description")
|
|
37
|
+
completed: Optional[bool] = Field(None, description="Whether the task is completed")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TaskResponse(TaskBase):
|
|
41
|
+
"""
|
|
42
|
+
Schema for task response
|
|
43
|
+
Includes all fields from TaskBase plus database-generated fields
|
|
44
|
+
"""
|
|
45
|
+
id: int = Field(..., description="Task ID")
|
|
46
|
+
# Timestamps are optional because they may not be immediately available
|
|
47
|
+
# after flush() in serverless environments (database sets them, but asyncpg
|
|
48
|
+
# may not return them without refresh which can cause connection issues)
|
|
49
|
+
created_at: Optional[datetime] = Field(None, description="Timestamp when task was created")
|
|
50
|
+
updated_at: Optional[datetime] = Field(None, description="Timestamp when task was last updated")
|
|
51
|
+
|
|
52
|
+
class Config:
|
|
53
|
+
"""
|
|
54
|
+
Pydantic configuration
|
|
55
|
+
Reference: https://docs.pydantic.dev/latest/concepts/config/
|
|
56
|
+
"""
|
|
57
|
+
from_attributes = True # Allow creation from ORM objects (SQLAlchemy models)
|
|
58
|
+
json_encoders = {
|
|
59
|
+
datetime: lambda v: v.isoformat() # Convert datetime to ISO format in JSON
|
|
60
|
+
}
|
|
61
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_serializer, model_validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class UserBase(BaseModel):
|
|
8
|
+
"""
|
|
9
|
+
Base schema with common User fields
|
|
10
|
+
Used as base for create schemas
|
|
11
|
+
"""
|
|
12
|
+
first_name: Optional[str] = Field(None, max_length=255, description="User first name")
|
|
13
|
+
last_name: Optional[str] = Field(None, max_length=255, description="User last name")
|
|
14
|
+
email: str = Field(..., min_length=1, max_length=255, description="User email")
|
|
15
|
+
password: str = Field(..., min_length=8, max_length=255, description="User password")
|
|
16
|
+
|
|
17
|
+
class UserCreate(UserBase):
|
|
18
|
+
"""
|
|
19
|
+
Schema for creating a new user
|
|
20
|
+
Inherits from UserBase
|
|
21
|
+
Attributes:
|
|
22
|
+
first_name: User first name (optional)
|
|
23
|
+
last_name: User last name (optional)
|
|
24
|
+
email: User email (required)
|
|
25
|
+
password: User password (required)
|
|
26
|
+
confirm_password: User confirm password (required)
|
|
27
|
+
Reference: https://fastapi.tiangolo.com/tutorial/body/
|
|
28
|
+
"""
|
|
29
|
+
confirm_password: str = Field(..., min_length=8, max_length=255, description="User confirm password")
|
|
30
|
+
|
|
31
|
+
@field_validator('password')
|
|
32
|
+
def validate_password(cls, v: str) -> str:
|
|
33
|
+
if len(v) < 8:
|
|
34
|
+
raise ValueError("Password must be at least 8 characters long")
|
|
35
|
+
if not any(char.isdigit() for char in v):
|
|
36
|
+
raise ValueError("Password must contain at least one number")
|
|
37
|
+
if not any(char.isalpha() for char in v):
|
|
38
|
+
raise ValueError("Password must contain at least one letter")
|
|
39
|
+
if not any(char.isupper() for char in v):
|
|
40
|
+
raise ValueError("Password must contain at least one uppercase letter")
|
|
41
|
+
if not any(char.islower() for char in v):
|
|
42
|
+
raise ValueError("Password must contain at least one lowercase letter")
|
|
43
|
+
return v
|
|
44
|
+
|
|
45
|
+
@model_validator(mode='after')
|
|
46
|
+
def validate_confirm_password(self) -> 'UserCreate':
|
|
47
|
+
if self.password != self.confirm_password:
|
|
48
|
+
raise ValueError("Password and confirm password do not match")
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class UserResponse(BaseModel):
|
|
54
|
+
"""
|
|
55
|
+
Schema for user response
|
|
56
|
+
Includes all fields from UserBase plus database-generated fields
|
|
57
|
+
Attributes:
|
|
58
|
+
id: User ID (required)
|
|
59
|
+
email: User email (required)
|
|
60
|
+
first_name: User first name (optional)
|
|
61
|
+
last_name: User last name (optional)
|
|
62
|
+
created_at: Timestamp when user was created (optional)
|
|
63
|
+
updated_at: Timestamp when user was last updated (optional)
|
|
64
|
+
Reference: https://fastapi.tiangolo.com/tutorial/response-model/
|
|
65
|
+
"""
|
|
66
|
+
id: str = Field(..., description="User ID")
|
|
67
|
+
first_name: Optional[str] = Field(None, max_length=255, description="User first name")
|
|
68
|
+
last_name: Optional[str] = Field(None, max_length=255, description="User last name")
|
|
69
|
+
email: str = Field(..., min_length=1, max_length=255, description="User email")
|
|
70
|
+
created_at: Optional[datetime] = Field(None, description="Timestamp when user was created")
|
|
71
|
+
updated_at: Optional[datetime] = Field(None, description="Timestamp when user was last updated")
|
|
72
|
+
|
|
73
|
+
model_config = ConfigDict(from_attributes=True)
|
|
74
|
+
|
|
75
|
+
class UserUpdate(BaseModel):
|
|
76
|
+
"""
|
|
77
|
+
Schema for updating a user
|
|
78
|
+
All fields are optional for partial updates
|
|
79
|
+
"""
|
|
80
|
+
first_name: Optional[str] = Field(None, max_length=255, description="User first name")
|
|
81
|
+
last_name: Optional[str] = Field(None, max_length=255, description="User last name")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class WorkOSUserResponse(BaseModel):
|
|
85
|
+
object: str = Field(..., description="Object")
|
|
86
|
+
id: str = Field(..., description="User ID")
|
|
87
|
+
email: str = Field(..., description="User email")
|
|
88
|
+
first_name: str | None = Field(None, description="User first name")
|
|
89
|
+
last_name: str | None = Field(None, description="User last name")
|
|
90
|
+
email_verified: bool = Field(..., description="User email verified")
|
|
91
|
+
profile_picture_url: str | None = Field(None, description="User profile picture URL")
|
|
92
|
+
created_at: datetime = Field(..., description="User created at")
|
|
93
|
+
updated_at: datetime = Field(..., description="User updated at")
|
|
94
|
+
|
|
95
|
+
class Config:
|
|
96
|
+
from_attributes = True
|