red64-cli 0.1.0 → 0.3.0
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.
- package/README.md +1 -2
- package/dist/cli/parseArgs.d.ts.map +1 -1
- package/dist/cli/parseArgs.js +5 -0
- package/dist/cli/parseArgs.js.map +1 -1
- package/dist/components/init/CompleteStep.d.ts.map +1 -1
- package/dist/components/init/CompleteStep.js +2 -2
- package/dist/components/init/CompleteStep.js.map +1 -1
- package/dist/components/init/TestCheckStep.d.ts +16 -0
- package/dist/components/init/TestCheckStep.d.ts.map +1 -0
- package/dist/components/init/TestCheckStep.js +120 -0
- package/dist/components/init/TestCheckStep.js.map +1 -0
- package/dist/components/init/index.d.ts +1 -0
- package/dist/components/init/index.d.ts.map +1 -1
- package/dist/components/init/index.js +1 -0
- package/dist/components/init/index.js.map +1 -1
- package/dist/components/init/types.d.ts +9 -0
- package/dist/components/init/types.d.ts.map +1 -1
- package/dist/components/screens/InitScreen.d.ts.map +1 -1
- package/dist/components/screens/InitScreen.js +69 -6
- package/dist/components/screens/InitScreen.js.map +1 -1
- package/dist/components/screens/ListScreen.d.ts.map +1 -1
- package/dist/components/screens/ListScreen.js +28 -3
- package/dist/components/screens/ListScreen.js.map +1 -1
- package/dist/components/screens/StartScreen.d.ts.map +1 -1
- package/dist/components/screens/StartScreen.js +212 -13
- package/dist/components/screens/StartScreen.js.map +1 -1
- package/dist/components/ui/ArtifactsSidebar.d.ts +19 -0
- package/dist/components/ui/ArtifactsSidebar.d.ts.map +1 -0
- package/dist/components/ui/ArtifactsSidebar.js +51 -0
- package/dist/components/ui/ArtifactsSidebar.js.map +1 -0
- package/dist/components/ui/FeatureSidebar.d.ts.map +1 -1
- package/dist/components/ui/FeatureSidebar.js +1 -1
- package/dist/components/ui/FeatureSidebar.js.map +1 -1
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/services/ClaudeErrorDetector.js +3 -3
- package/dist/services/ClaudeErrorDetector.js.map +1 -1
- package/dist/services/ConfigService.d.ts +1 -0
- package/dist/services/ConfigService.d.ts.map +1 -1
- package/dist/services/ConfigService.js.map +1 -1
- package/dist/services/ProjectDetector.d.ts +28 -0
- package/dist/services/ProjectDetector.d.ts.map +1 -0
- package/dist/services/ProjectDetector.js +236 -0
- package/dist/services/ProjectDetector.js.map +1 -0
- package/dist/services/TestRunner.d.ts +46 -0
- package/dist/services/TestRunner.d.ts.map +1 -0
- package/dist/services/TestRunner.js +85 -0
- package/dist/services/TestRunner.js.map +1 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +2 -0
- package/dist/services/index.js.map +1 -1
- package/dist/types/index.d.ts +13 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/framework/.red64/settings/templates/specs/gap-analysis.md +163 -0
- package/framework/agents/claude/.claude/agents/red64/spec-impl.md +131 -2
- package/framework/agents/claude/.claude/agents/red64/validate-gap.md +13 -7
- package/framework/agents/claude/.claude/commands/red64/spec-impl.md +24 -0
- package/framework/agents/claude/.claude/commands/red64/validate-gap.md +4 -0
- package/framework/agents/codex/.codex/agents/red64/spec-impl.md +131 -2
- package/framework/agents/codex/.codex/agents/red64/validate-gap.md +13 -7
- package/framework/agents/codex/.codex/commands/red64/spec-impl.md +24 -0
- package/framework/agents/codex/.codex/commands/red64/validate-gap.md +4 -0
- package/framework/stacks/generic/feedback.md +80 -0
- package/framework/stacks/nextjs/accessibility.md +437 -0
- package/framework/stacks/nextjs/api.md +431 -0
- package/framework/stacks/nextjs/coding-style.md +282 -0
- package/framework/stacks/nextjs/commenting.md +226 -0
- package/framework/stacks/nextjs/components.md +411 -0
- package/framework/stacks/nextjs/conventions.md +333 -0
- package/framework/stacks/nextjs/css.md +310 -0
- package/framework/stacks/nextjs/error-handling.md +442 -0
- package/framework/stacks/nextjs/feedback.md +124 -0
- package/framework/stacks/nextjs/migrations.md +332 -0
- package/framework/stacks/nextjs/models.md +362 -0
- package/framework/stacks/nextjs/queries.md +410 -0
- package/framework/stacks/nextjs/responsive.md +338 -0
- package/framework/stacks/nextjs/tech-stack.md +177 -0
- package/framework/stacks/nextjs/test-writing.md +475 -0
- package/framework/stacks/nextjs/validation.md +467 -0
- package/framework/stacks/python/api.md +468 -0
- package/framework/stacks/python/authentication.md +342 -0
- package/framework/stacks/python/code-quality.md +283 -0
- package/framework/stacks/python/code-refactoring.md +315 -0
- package/framework/stacks/python/coding-style.md +462 -0
- package/framework/stacks/python/conventions.md +399 -0
- package/framework/stacks/python/error-handling.md +512 -0
- package/framework/stacks/python/feedback.md +92 -0
- package/framework/stacks/python/implement-ai-llm.md +468 -0
- package/framework/stacks/python/migrations.md +388 -0
- package/framework/stacks/python/models.md +399 -0
- package/framework/stacks/python/python.md +232 -0
- package/framework/stacks/python/queries.md +451 -0
- package/framework/stacks/python/structure.md +245 -58
- package/framework/stacks/python/tech.md +92 -35
- package/framework/stacks/python/testing.md +380 -0
- package/framework/stacks/python/validation.md +471 -0
- package/framework/stacks/rails/authentication.md +176 -0
- package/framework/stacks/rails/code-quality.md +287 -0
- package/framework/stacks/rails/code-refactoring.md +299 -0
- package/framework/stacks/rails/feedback.md +130 -0
- package/framework/stacks/rails/implement-ai-llm-with-rubyllm.md +342 -0
- package/framework/stacks/rails/rails.md +301 -0
- package/framework/stacks/rails/rails8-best-practices.md +498 -0
- package/framework/stacks/rails/rails8-css.md +573 -0
- package/framework/stacks/rails/structure.md +140 -0
- package/framework/stacks/rails/tech.md +108 -0
- package/framework/stacks/react/code-quality.md +521 -0
- package/framework/stacks/react/components.md +625 -0
- package/framework/stacks/react/data-fetching.md +586 -0
- package/framework/stacks/react/feedback.md +110 -0
- package/framework/stacks/react/forms.md +694 -0
- package/framework/stacks/react/performance.md +640 -0
- package/framework/stacks/react/product.md +22 -9
- package/framework/stacks/react/state-management.md +472 -0
- package/framework/stacks/react/structure.md +351 -44
- package/framework/stacks/react/tech.md +219 -30
- package/framework/stacks/react/testing.md +690 -0
- package/package.json +1 -1
- package/framework/stacks/node/product.md +0 -27
- package/framework/stacks/node/structure.md +0 -82
- package/framework/stacks/node/tech.md +0 -63
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# Validation Patterns
|
|
2
|
+
|
|
3
|
+
Input validation with Pydantic v2 and FastAPI for type-safe, self-documenting APIs.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Philosophy
|
|
8
|
+
|
|
9
|
+
- **Server-side is the source of truth**: Never trust client-side validation alone
|
|
10
|
+
- **Fail early**: Reject invalid data before it reaches business logic
|
|
11
|
+
- **Descriptive errors**: Field-specific messages that help users fix input
|
|
12
|
+
- **Schema as documentation**: Pydantic models generate OpenAPI specs automatically
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Pydantic Model Basics
|
|
17
|
+
|
|
18
|
+
### Request Schemas
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
# app/schemas/user.py
|
|
22
|
+
from pydantic import BaseModel, EmailStr, Field
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CreateUserRequest(BaseModel):
|
|
26
|
+
email: EmailStr
|
|
27
|
+
name: str = Field(min_length=1, max_length=255)
|
|
28
|
+
password: str = Field(min_length=8, max_length=128)
|
|
29
|
+
bio: str | None = Field(default=None, max_length=2000)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UpdateUserRequest(BaseModel):
|
|
33
|
+
name: str | None = Field(default=None, min_length=1, max_length=255)
|
|
34
|
+
bio: str | None = Field(default=None, max_length=2000)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Response Schemas
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
from pydantic import BaseModel, ConfigDict
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UserResponse(BaseModel):
|
|
45
|
+
model_config = ConfigDict(from_attributes=True)
|
|
46
|
+
|
|
47
|
+
id: int
|
|
48
|
+
email: str
|
|
49
|
+
name: str
|
|
50
|
+
bio: str | None
|
|
51
|
+
is_active: bool
|
|
52
|
+
created_at: datetime
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Rule**: Always use separate request and response schemas. Never expose internal fields (password hashes, soft-delete timestamps) in responses.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Field Validators
|
|
60
|
+
|
|
61
|
+
### field_validator (Single Field)
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from pydantic import field_validator
|
|
65
|
+
import re
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CreateUserRequest(BaseModel):
|
|
69
|
+
email: EmailStr
|
|
70
|
+
name: str = Field(min_length=1, max_length=255)
|
|
71
|
+
password: str = Field(min_length=8, max_length=128)
|
|
72
|
+
username: str = Field(min_length=3, max_length=30)
|
|
73
|
+
|
|
74
|
+
@field_validator("password")
|
|
75
|
+
@classmethod
|
|
76
|
+
def password_complexity(cls, v: str) -> str:
|
|
77
|
+
if not re.search(r"[A-Z]", v):
|
|
78
|
+
raise ValueError("Must contain at least one uppercase letter")
|
|
79
|
+
if not re.search(r"[0-9]", v):
|
|
80
|
+
raise ValueError("Must contain at least one digit")
|
|
81
|
+
return v
|
|
82
|
+
|
|
83
|
+
@field_validator("username")
|
|
84
|
+
@classmethod
|
|
85
|
+
def username_format(cls, v: str) -> str:
|
|
86
|
+
if not re.match(r"^[a-z0-9_-]+$", v):
|
|
87
|
+
raise ValueError("Only lowercase letters, numbers, hyphens, and underscores")
|
|
88
|
+
return v
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### model_validator (Cross-Field)
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from pydantic import model_validator
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DateRangeRequest(BaseModel):
|
|
98
|
+
start_date: date
|
|
99
|
+
end_date: date
|
|
100
|
+
|
|
101
|
+
@model_validator(mode="after")
|
|
102
|
+
def validate_date_range(self) -> "DateRangeRequest":
|
|
103
|
+
if self.end_date <= self.start_date:
|
|
104
|
+
raise ValueError("end_date must be after start_date")
|
|
105
|
+
if (self.end_date - self.start_date).days > 365:
|
|
106
|
+
raise ValueError("Date range cannot exceed one year")
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ChangePasswordRequest(BaseModel):
|
|
111
|
+
current_password: str
|
|
112
|
+
new_password: str = Field(min_length=8)
|
|
113
|
+
confirm_password: str
|
|
114
|
+
|
|
115
|
+
@model_validator(mode="after")
|
|
116
|
+
def passwords_match(self) -> "ChangePasswordRequest":
|
|
117
|
+
if self.new_password != self.confirm_password:
|
|
118
|
+
raise ValueError("new_password and confirm_password must match")
|
|
119
|
+
if self.new_password == self.current_password:
|
|
120
|
+
raise ValueError("New password must differ from current password")
|
|
121
|
+
return self
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## FastAPI Request Validation
|
|
127
|
+
|
|
128
|
+
### Automatic Validation
|
|
129
|
+
|
|
130
|
+
FastAPI validates request bodies, query params, and path params automatically:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from fastapi import APIRouter, Query, Path
|
|
134
|
+
|
|
135
|
+
router = APIRouter()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.post("/users", status_code=201)
|
|
139
|
+
async def create_user(data: CreateUserRequest) -> UserResponse:
|
|
140
|
+
# data is already validated by Pydantic
|
|
141
|
+
...
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@router.get("/users")
|
|
145
|
+
async def list_users(
|
|
146
|
+
page: int = Query(default=1, ge=1, description="Page number"),
|
|
147
|
+
per_page: int = Query(default=20, ge=1, le=100, description="Items per page"),
|
|
148
|
+
search: str | None = Query(default=None, min_length=1, max_length=100),
|
|
149
|
+
status: str | None = Query(default=None, pattern="^(active|inactive|all)$"),
|
|
150
|
+
) -> PaginatedResponse[UserResponse]:
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@router.get("/users/{user_id}")
|
|
155
|
+
async def get_user(
|
|
156
|
+
user_id: int = Path(ge=1, description="User ID"),
|
|
157
|
+
) -> UserResponse:
|
|
158
|
+
...
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Custom Error Messages for FastAPI
|
|
162
|
+
|
|
163
|
+
Override the default Pydantic validation error format:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from fastapi import FastAPI, Request
|
|
167
|
+
from fastapi.exceptions import RequestValidationError
|
|
168
|
+
from fastapi.responses import JSONResponse
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.exception_handler(RequestValidationError)
|
|
172
|
+
async def validation_exception_handler(
|
|
173
|
+
request: Request, exc: RequestValidationError,
|
|
174
|
+
) -> JSONResponse:
|
|
175
|
+
errors = {}
|
|
176
|
+
for error in exc.errors():
|
|
177
|
+
field = ".".join(str(loc) for loc in error["loc"] if loc != "body")
|
|
178
|
+
errors[field] = error["msg"]
|
|
179
|
+
|
|
180
|
+
return JSONResponse(
|
|
181
|
+
status_code=422,
|
|
182
|
+
content={
|
|
183
|
+
"error": {
|
|
184
|
+
"code": "VALIDATION_ERROR",
|
|
185
|
+
"message": "Request validation failed",
|
|
186
|
+
"details": {"field_errors": errors},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Custom Types
|
|
195
|
+
|
|
196
|
+
### Reusable Annotated Types
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from typing import Annotated
|
|
200
|
+
from pydantic import Field, AfterValidator
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def validate_slug(v: str) -> str:
|
|
204
|
+
import re
|
|
205
|
+
if not re.match(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", v):
|
|
206
|
+
raise ValueError("Invalid slug format (lowercase, hyphens only)")
|
|
207
|
+
return v
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Reusable types
|
|
211
|
+
Slug = Annotated[str, Field(min_length=1, max_length=200), AfterValidator(validate_slug)]
|
|
212
|
+
NonEmptyStr = Annotated[str, Field(min_length=1, strip_whitespace=True)]
|
|
213
|
+
PositiveInt = Annotated[int, Field(gt=0)]
|
|
214
|
+
PageSize = Annotated[int, Field(ge=1, le=100)]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# Usage
|
|
218
|
+
class CreatePostRequest(BaseModel):
|
|
219
|
+
title: NonEmptyStr = Field(max_length=500)
|
|
220
|
+
slug: Slug
|
|
221
|
+
body: str = Field(min_length=1)
|
|
222
|
+
category_id: PositiveInt
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Coercion vs Strict Mode
|
|
228
|
+
|
|
229
|
+
### Default (Coercing)
|
|
230
|
+
|
|
231
|
+
Pydantic coerces compatible types by default:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
class Item(BaseModel):
|
|
235
|
+
count: int
|
|
236
|
+
price: float
|
|
237
|
+
|
|
238
|
+
# These all work:
|
|
239
|
+
Item(count="5", price="9.99") # Strings coerced to numbers
|
|
240
|
+
Item(count=5.0, price=9) # Float to int, int to float
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Strict Mode
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
from pydantic import BaseModel, ConfigDict
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class StrictItem(BaseModel):
|
|
250
|
+
model_config = ConfigDict(strict=True)
|
|
251
|
+
|
|
252
|
+
count: int
|
|
253
|
+
price: float
|
|
254
|
+
|
|
255
|
+
# StrictItem(count="5", price="9.99") # ValidationError: int expected
|
|
256
|
+
StrictItem(count=5, price=9.99) # OK
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Per-Field Strict
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
from pydantic import Field
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class MixedItem(BaseModel):
|
|
266
|
+
id: int = Field(strict=True) # Must be int
|
|
267
|
+
quantity: int # Coercion allowed
|
|
268
|
+
label: str # Coercion allowed
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Guidance**: Use strict mode for IDs and critical fields. Allow coercion for user-facing inputs where `"5"` meaning `5` is reasonable.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Nested Model Validation
|
|
276
|
+
|
|
277
|
+
### Composable Schemas
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
class Address(BaseModel):
|
|
281
|
+
street: str = Field(min_length=1, max_length=500)
|
|
282
|
+
city: str = Field(min_length=1, max_length=100)
|
|
283
|
+
state: str = Field(min_length=2, max_length=2)
|
|
284
|
+
zip_code: str = Field(pattern=r"^\d{5}(-\d{4})?$")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class CreateOrderRequest(BaseModel):
|
|
288
|
+
items: list[OrderItemRequest] = Field(min_length=1, max_length=50)
|
|
289
|
+
shipping_address: Address
|
|
290
|
+
billing_address: Address | None = None
|
|
291
|
+
notes: str | None = Field(default=None, max_length=1000)
|
|
292
|
+
|
|
293
|
+
@model_validator(mode="after")
|
|
294
|
+
def set_billing_default(self) -> "CreateOrderRequest":
|
|
295
|
+
if self.billing_address is None:
|
|
296
|
+
self.billing_address = self.shipping_address
|
|
297
|
+
return self
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class OrderItemRequest(BaseModel):
|
|
301
|
+
product_id: int = Field(gt=0)
|
|
302
|
+
quantity: int = Field(ge=1, le=999)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Discriminated Unions
|
|
308
|
+
|
|
309
|
+
### Tagged Unions for Polymorphic Data
|
|
310
|
+
|
|
311
|
+
```python
|
|
312
|
+
from typing import Literal, Annotated
|
|
313
|
+
from pydantic import BaseModel, Field
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class EmailNotification(BaseModel):
|
|
317
|
+
type: Literal["email"]
|
|
318
|
+
to: EmailStr
|
|
319
|
+
subject: str = Field(max_length=200)
|
|
320
|
+
body: str
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class SmsNotification(BaseModel):
|
|
324
|
+
type: Literal["sms"]
|
|
325
|
+
phone: str = Field(pattern=r"^\+[1-9]\d{1,14}$")
|
|
326
|
+
message: str = Field(max_length=160)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class WebhookNotification(BaseModel):
|
|
330
|
+
type: Literal["webhook"]
|
|
331
|
+
url: str
|
|
332
|
+
payload: dict
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
Notification = Annotated[
|
|
336
|
+
EmailNotification | SmsNotification | WebhookNotification,
|
|
337
|
+
Field(discriminator="type"),
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class SendNotificationRequest(BaseModel):
|
|
342
|
+
notifications: list[Notification] = Field(min_length=1, max_length=10)
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Pydantic uses the `type` field to determine which model to validate against, giving precise error messages.
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Serialization Aliases
|
|
350
|
+
|
|
351
|
+
### Different Names for API vs Internal
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class UserResponse(BaseModel):
|
|
358
|
+
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
|
359
|
+
|
|
360
|
+
id: int
|
|
361
|
+
email_address: str = Field(alias="email", serialization_alias="email_address")
|
|
362
|
+
full_name: str = Field(alias="name")
|
|
363
|
+
is_active: bool
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# From SQLAlchemy model (uses alias to read "email" attribute)
|
|
367
|
+
user_response = UserResponse.model_validate(user_model)
|
|
368
|
+
|
|
369
|
+
# Serialized output uses serialization_alias
|
|
370
|
+
# {"id": 1, "email_address": "a@b.com", "full_name": "Test", "is_active": true}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Snake Case to Camel Case
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
from pydantic import BaseModel, ConfigDict
|
|
377
|
+
from pydantic.alias_generators import to_camel
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class CamelCaseModel(BaseModel):
|
|
381
|
+
model_config = ConfigDict(
|
|
382
|
+
alias_generator=to_camel,
|
|
383
|
+
populate_by_name=True,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class UserResponse(CamelCaseModel):
|
|
388
|
+
id: int
|
|
389
|
+
email: str
|
|
390
|
+
full_name: str
|
|
391
|
+
is_active: bool
|
|
392
|
+
|
|
393
|
+
# Serializes to: {"id": 1, "email": "...", "fullName": "...", "isActive": true}
|
|
394
|
+
# Accepts both: {"full_name": "..."} and {"fullName": "..."}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## OpenAPI Schema Generation
|
|
400
|
+
|
|
401
|
+
### Automatic from Pydantic
|
|
402
|
+
|
|
403
|
+
FastAPI generates OpenAPI schemas from Pydantic models automatically:
|
|
404
|
+
|
|
405
|
+
```python
|
|
406
|
+
class CreateUserRequest(BaseModel):
|
|
407
|
+
"""Create a new user account."""
|
|
408
|
+
|
|
409
|
+
email: EmailStr = Field(description="User's email address", examples=["user@example.com"])
|
|
410
|
+
name: str = Field(
|
|
411
|
+
min_length=1,
|
|
412
|
+
max_length=255,
|
|
413
|
+
description="Display name",
|
|
414
|
+
examples=["Jane Doe"],
|
|
415
|
+
)
|
|
416
|
+
password: str = Field(
|
|
417
|
+
min_length=8,
|
|
418
|
+
max_length=128,
|
|
419
|
+
description="Account password",
|
|
420
|
+
)
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### JSON Schema Examples
|
|
424
|
+
|
|
425
|
+
```python
|
|
426
|
+
class CreateUserRequest(BaseModel):
|
|
427
|
+
model_config = ConfigDict(
|
|
428
|
+
json_schema_extra={
|
|
429
|
+
"examples": [
|
|
430
|
+
{
|
|
431
|
+
"email": "jane@example.com",
|
|
432
|
+
"name": "Jane Doe",
|
|
433
|
+
"password": "SecurePass1",
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
email: EmailStr
|
|
440
|
+
name: str = Field(min_length=1, max_length=255)
|
|
441
|
+
password: str = Field(min_length=8)
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
## Validation Layers
|
|
447
|
+
|
|
448
|
+
| Layer | Tool | Purpose |
|
|
449
|
+
|---|---|---|
|
|
450
|
+
| HTTP request | FastAPI + Pydantic | Type, format, range validation |
|
|
451
|
+
| Business rules | Service layer | Domain logic (duplicates, permissions) |
|
|
452
|
+
| Database | SQLAlchemy constraints | Data integrity (unique, foreign keys, checks) |
|
|
453
|
+
|
|
454
|
+
**Rule**: Each layer validates its own concerns. Do not rely on a single layer.
|
|
455
|
+
|
|
456
|
+
---
|
|
457
|
+
|
|
458
|
+
## Anti-Patterns
|
|
459
|
+
|
|
460
|
+
| Anti-Pattern | Problem | Correct Approach |
|
|
461
|
+
|---|---|---|
|
|
462
|
+
| Validating in route handlers | Scattered logic, hard to test | Use Pydantic schemas |
|
|
463
|
+
| Same schema for create/read | Exposes internal fields | Separate request/response schemas |
|
|
464
|
+
| No `max_length` on strings | Unbounded input, DoS risk | Always set `max_length` |
|
|
465
|
+
| Bare `dict` for request body | No validation, no docs | Use typed Pydantic model |
|
|
466
|
+
| Validating in the database only | Late failure, poor error messages | Validate at entry point too |
|
|
467
|
+
| Mutable default values | Shared state bugs | Use `Field(default_factory=list)` |
|
|
468
|
+
|
|
469
|
+
---
|
|
470
|
+
|
|
471
|
+
_Validation is the first line of defense. If the data is wrong, reject it immediately with a clear explanation._
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Authentication Patterns
|
|
2
|
+
|
|
3
|
+
Rails 8 built-in authentication using session-based auth with bcrypt password hashing.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Philosophy
|
|
8
|
+
|
|
9
|
+
- **Session-based authentication**: Database-backed sessions, not JWTs
|
|
10
|
+
- **Secure by default**: httpOnly cookies, signed session IDs, rate limiting
|
|
11
|
+
- **Device tracking**: Sessions store IP address and user agent for security auditing
|
|
12
|
+
- **Simple model**: User has many Sessions; each login creates a new Session record
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Authentication Flow
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
1) User submits email + password to SessionsController#create
|
|
20
|
+
2) User.authenticate_by verifies credentials (bcrypt)
|
|
21
|
+
3) Server creates Session record with device info
|
|
22
|
+
4) Signed, permanent, httpOnly cookie stores session_id
|
|
23
|
+
5) Subsequent requests resume session via cookie lookup
|
|
24
|
+
6) Current.session provides request-scoped access to authenticated session/user
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Core Components
|
|
30
|
+
|
|
31
|
+
### Authentication Concern (`app/controllers/concerns/authentication.rb`)
|
|
32
|
+
- Included in `ApplicationController` - all controllers require auth by default
|
|
33
|
+
- `allow_unauthenticated_access` - opt-out for public actions
|
|
34
|
+
- `authenticated?` - helper method for views
|
|
35
|
+
- `Current.session` / `Current.user` - request-scoped user access
|
|
36
|
+
|
|
37
|
+
### Key Methods
|
|
38
|
+
|
|
39
|
+
| Method | Purpose |
|
|
40
|
+
|--------|---------|
|
|
41
|
+
| `require_authentication` | Before action, redirects to login if no session |
|
|
42
|
+
| `start_new_session_for(user)` | Creates session, sets cookie, tracks device |
|
|
43
|
+
| `terminate_session` | Destroys session, clears cookie |
|
|
44
|
+
| `after_authentication_url` | Return-to URL after login |
|
|
45
|
+
|
|
46
|
+
### Usage Pattern
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# Controller requiring authentication (default)
|
|
50
|
+
class ProjectsController < ApplicationController
|
|
51
|
+
# All actions require auth automatically
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Controller with public actions
|
|
55
|
+
class PagesController < ApplicationController
|
|
56
|
+
allow_unauthenticated_access only: %i[home about]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Accessing current user
|
|
60
|
+
def index
|
|
61
|
+
@projects = Current.user.projects
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check in views
|
|
65
|
+
<% if authenticated? %>
|
|
66
|
+
Welcome, <%= Current.user.email_address %>
|
|
67
|
+
<% end %>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Session Management
|
|
73
|
+
|
|
74
|
+
### Session Model
|
|
75
|
+
- `belongs_to :user`
|
|
76
|
+
- Tracks `ip_address` and `user_agent` for security
|
|
77
|
+
- Destroyed on logout or password reset
|
|
78
|
+
|
|
79
|
+
### Session Cookie
|
|
80
|
+
- **Signed**: Tamper-proof via Rails signing
|
|
81
|
+
- **Permanent**: Long-lived (browser remembers)
|
|
82
|
+
- **httpOnly**: Not accessible to JavaScript
|
|
83
|
+
- **SameSite: Lax**: CSRF protection
|
|
84
|
+
|
|
85
|
+
### Multi-Device Sessions
|
|
86
|
+
Users can have multiple active sessions. Password reset destroys all sessions:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
@user.sessions.destroy_all # Force re-login on all devices
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Password Management
|
|
95
|
+
|
|
96
|
+
### Password Storage
|
|
97
|
+
- `has_secure_password` with bcrypt (`bcrypt` gem)
|
|
98
|
+
- `password_digest` column stores hash
|
|
99
|
+
- Never store or log plaintext passwords
|
|
100
|
+
|
|
101
|
+
### Password Reset Flow
|
|
102
|
+
```
|
|
103
|
+
1) User requests reset via email
|
|
104
|
+
2) PasswordsMailer sends time-limited token
|
|
105
|
+
3) User clicks link with token
|
|
106
|
+
4) Token verified via find_by_password_reset_token!
|
|
107
|
+
5) Password updated, all sessions destroyed
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Rate Limiting
|
|
111
|
+
Login and password reset endpoints are rate-limited:
|
|
112
|
+
```ruby
|
|
113
|
+
rate_limit to: 10, within: 3.minutes, only: :create
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Extending Authentication
|
|
119
|
+
|
|
120
|
+
### Adding OAuth (Future)
|
|
121
|
+
When adding OAuth providers:
|
|
122
|
+
- Create `Identity` model linking external providers to User
|
|
123
|
+
- Use OmniAuth gem for provider strategies
|
|
124
|
+
- Keep session creation logic in Authentication concern
|
|
125
|
+
|
|
126
|
+
### Adding API Authentication (Future)
|
|
127
|
+
For API endpoints:
|
|
128
|
+
- Create `ApiToken` model with `authenticate_by_token` method
|
|
129
|
+
- Use separate controller concern for token auth
|
|
130
|
+
- Never mix cookie and token auth in same controller
|
|
131
|
+
|
|
132
|
+
### Adding MFA (Future)
|
|
133
|
+
For multi-factor authentication:
|
|
134
|
+
- Add `otp_secret` to User model
|
|
135
|
+
- Step-up verification for sensitive actions
|
|
136
|
+
- Consider WebAuthn for hardware key support
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Authorization (Not Yet Implemented)
|
|
141
|
+
|
|
142
|
+
Authentication verifies identity; authorization controls access. When adding:
|
|
143
|
+
|
|
144
|
+
### Recommended Pattern
|
|
145
|
+
```ruby
|
|
146
|
+
# Policy-based (e.g., Pundit)
|
|
147
|
+
authorize @project
|
|
148
|
+
|
|
149
|
+
# Or ownership checks
|
|
150
|
+
redirect_to root_path unless @project.user == Current.user
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Scoping Resources
|
|
154
|
+
```ruby
|
|
155
|
+
# Always scope to current user
|
|
156
|
+
def set_project
|
|
157
|
+
@project = Current.user.projects.find(params[:id])
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Security Checklist
|
|
164
|
+
|
|
165
|
+
- [x] Passwords hashed with bcrypt
|
|
166
|
+
- [x] Session cookies httpOnly and signed
|
|
167
|
+
- [x] Rate limiting on login/reset endpoints
|
|
168
|
+
- [x] Device tracking (IP, user agent)
|
|
169
|
+
- [x] Session invalidation on password change
|
|
170
|
+
- [ ] Account lockout after failed attempts (implement as needed)
|
|
171
|
+
- [ ] Email verification (implement as needed)
|
|
172
|
+
- [ ] MFA support (implement as needed)
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
_Document patterns and extension points, not implementation details._
|