Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:29:15 +08:00
commit be476a3fea
76 changed files with 12812 additions and 0 deletions

View File

@@ -0,0 +1,288 @@
---
name: grey-haven-api-design
description: "Design RESTful APIs following Grey Haven standards - FastAPI routes, Pydantic schemas, HTTP status codes, pagination, filtering, error responses, OpenAPI docs, and multi-tenant patterns. Use when creating API endpoints, designing REST resources, implementing server functions, configuring FastAPI, writing Pydantic schemas, setting up error handling, implementing pagination, or when user mentions 'API', 'endpoint', 'REST', 'FastAPI', 'Pydantic', 'server function', 'OpenAPI', 'pagination', 'validation', 'error handling', 'rate limiting', 'CORS', or 'authentication'."
---
# Grey Haven API Design Standards
**RESTful API design for FastAPI backends and TanStack Start server functions.**
Follow these standards when creating API endpoints, defining schemas, and handling errors in Grey Haven projects.
## Supporting Documentation
- **[examples/](examples/)** - Complete endpoint examples (all files <500 lines)
- [fastapi-crud.md](examples/fastapi-crud.md) - CRUD endpoints with repository pattern
- [pydantic-schemas.md](examples/pydantic-schemas.md) - Request/response schemas
- [tanstack-start.md](examples/tanstack-start.md) - Server functions
- [pagination.md](examples/pagination.md) - Pagination patterns
- [testing.md](examples/testing.md) - API testing
- **[reference/](reference/)** - Configuration references (all files <500 lines)
- [fastapi-setup.md](reference/fastapi-setup.md) - Main app configuration
- [openapi.md](reference/openapi.md) - OpenAPI customization
- [error-handlers.md](reference/error-handlers.md) - Exception handlers
- [authentication.md](reference/authentication.md) - JWT configuration
- [cors-rate-limiting.md](reference/cors-rate-limiting.md) - CORS and rate limiting
- **[templates/](templates/)** - Copy-paste ready endpoint templates
- **[checklists/](checklists/)** - API design and security checklists
## Quick Reference
### RESTful Resource Design
**URL Patterns:**
-`/api/v1/users` (plural nouns, lowercase with hyphens)
-`/api/v1/organizations/{org_id}/teams` (hierarchical)
-`/api/v1/getUsers` (no verbs in URLs)
-`/api/v1/user_profiles` (no underscores)
**HTTP Verbs:**
- `GET` - Retrieve resources
- `POST` - Create new resources
- `PUT` - Update entire resource
- `PATCH` - Update partial resource
- `DELETE` - Remove resource
### HTTP Status Codes
**Success:**
- `200 OK` - GET, PUT, PATCH requests
- `201 Created` - POST request (resource created)
- `204 No Content` - DELETE request
**Client Errors:**
- `400 Bad Request` - Invalid request data
- `401 Unauthorized` - Missing/invalid authentication
- `403 Forbidden` - Insufficient permissions
- `404 Not Found` - Resource doesn't exist
- `409 Conflict` - Duplicate resource, concurrent update
- `422 Unprocessable Entity` - Validation errors
**Server Errors:**
- `500 Internal Server Error` - Unhandled exception
- `503 Service Unavailable` - Database/service down
### Multi-Tenant Isolation
**Always enforce tenant isolation:**
```python
# Extract tenant_id from JWT
repository = UserRepository(db, tenant_id=current_user.tenant_id)
# All queries automatically filtered by tenant_id
users = await repository.list() # Only returns users in this tenant
```
### FastAPI Route Pattern
```python
from fastapi import APIRouter, Depends, HTTPException, status
router = APIRouter(prefix="/api/v1/users", tags=["users"])
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_user(
user_data: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> UserRead:
"""Create a new user in the current tenant."""
repository = UserRepository(db, tenant_id=current_user.tenant_id)
user = await repository.create(user_data)
return user
```
**See [examples/fastapi-crud.md](examples/fastapi-crud.md) for complete CRUD endpoints.**
### Pydantic Schema Pattern
```python
from pydantic import BaseModel, EmailStr, Field, ConfigDict
class UserCreate(BaseModel):
"""Schema for creating a new user."""
email: EmailStr
full_name: str = Field(..., min_length=1, max_length=255)
password: str = Field(..., min_length=8)
class UserRead(BaseModel):
"""Schema for reading user data (public fields only)."""
id: str
tenant_id: str
email: EmailStr
full_name: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
```
**See [examples/pydantic-schemas.md](examples/pydantic-schemas.md) for validation patterns.**
### TanStack Start Server Functions
```typescript
// app/routes/api/users.ts
import { createServerFn } from "@tanstack/start";
import { z } from "zod";
const createUserSchema = z.object({
email: z.string().email(),
fullName: z.string().min(1).max(255),
});
export const createUser = createServerFn({ method: "POST" })
.validator(createUserSchema)
.handler(async ({ data, context }) => {
const authUser = await getAuthUser(context);
// Create user with tenant isolation
});
```
**See [examples/tanstack-start.md](examples/tanstack-start.md) for complete examples.**
### Error Response Format
```json
{
"error": "User with ID abc123 not found",
"status_code": 404
}
```
**Validation errors:**
```json
{
"error": "Validation error",
"detail": [
{
"field": "email",
"message": "value is not a valid email address",
"code": "value_error.email"
}
],
"status_code": 422
}
```
**See [reference/error-handlers.md](reference/error-handlers.md) for exception handlers.**
### Pagination
**Offset-based (simple):**
```python
@router.get("", response_model=PaginatedResponse[UserRead])
async def list_users(skip: int = 0, limit: int = 100):
users = await repository.list(skip=skip, limit=limit)
total = await repository.count()
return PaginatedResponse(items=users, total=total, skip=skip, limit=limit)
```
**Cursor-based (recommended for large datasets):**
```python
@router.get("")
async def list_users(cursor: Optional[str] = None, limit: int = 100):
users = await repository.list_cursor(cursor=cursor, limit=limit)
next_cursor = users[-1].id if len(users) == limit else None
return {"items": users, "next_cursor": next_cursor}
```
**See [examples/pagination.md](examples/pagination.md) for complete implementations.**
## Core Principles
### 1. Repository Pattern
**Always use tenant-aware repositories:**
- Extract `tenant_id` from JWT claims
- Pass to repository constructor
- All queries automatically filtered
- Prevents cross-tenant data leaks
### 2. Pydantic Validation
**Define schemas for all requests/responses:**
- `{Model}Create` - Fields for creation
- `{Model}Read` - Public fields for responses
- `{Model}Update` - Optional fields for updates
- Never return password hashes or sensitive data
### 3. OpenAPI Documentation
**FastAPI auto-generates docs:**
- Add docstrings to all endpoints
- Use `summary`, `description`, `response_description`
- Document all parameters and responses
- Available at `/docs` (Swagger UI) and `/redoc` (ReDoc)
**See [reference/openapi.md](reference/openapi.md) for customization.**
### 4. Rate Limiting
**Protect public endpoints:**
```python
from app.core.rate_limit import rate_limit
@router.get("", dependencies=[Depends(rate_limit)])
async def list_users():
"""List users (rate limited to 100 req/min)."""
pass
```
**See [templates/rate-limiter.py](templates/rate-limiter.py) for Upstash Redis implementation.**
### 5. CORS Configuration
**Use Doppler for allowed origins:**
```python
# NEVER hardcode origins in production!
allowed_origins = os.getenv("CORS_ALLOWED_ORIGINS", "").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
)
```
**See [reference/cors-rate-limiting.md](reference/cors-rate-limiting.md) for complete setup.**
## When to Apply This Skill
Use this skill when:
- ✅ Creating new FastAPI endpoints or TanStack Start server functions
- ✅ Designing RESTful resource hierarchies
- ✅ Writing Pydantic schemas for validation
- ✅ Implementing pagination, filtering, or sorting
- ✅ Configuring error response formats
- ✅ Setting up OpenAPI documentation
- ✅ Implementing rate limiting or CORS
- ✅ Designing multi-tenant API isolation
- ✅ Testing API endpoints with pytest
- ✅ Reviewing API design in pull requests
- ✅ User mentions: "API", "endpoint", "REST", "FastAPI", "Pydantic", "server function", "OpenAPI", "pagination", "validation"
## Template References
These API design patterns come from Grey Haven's actual templates:
- **Backend**: `cvi-backend-template` (FastAPI + SQLModel + Repository Pattern)
- **Frontend**: `cvi-template` (TanStack Start server functions)
## Critical Reminders
1. **Repository pattern** - Always use tenant-aware repositories for multi-tenant isolation
2. **Pydantic schemas** - Never return password hashes or sensitive fields in responses
3. **HTTP status codes** - 201 for create, 204 for delete, 404 for not found, 422 for validation errors
4. **Pagination** - Use cursor-based for large datasets (better performance than offset)
5. **Error format** - Consistent error structure with `error`, `detail`, and `status_code` fields
6. **OpenAPI docs** - Document all parameters, responses, and errors with docstrings
7. **Rate limiting** - Protect public endpoints with Upstash Redis (100 req/min default)
8. **CORS** - Use Doppler for allowed origins, never hardcode in production
9. **JWT authentication** - Extract `tenant_id` from JWT claims for multi-tenant isolation
10. **Testing** - Use FastAPI TestClient with `doppler run --config test -- pytest`
## Next Steps
- **Need endpoint examples?** See [examples/](examples/) for FastAPI CRUD and TanStack Start
- **Need configurations?** See [reference/](reference/) for OpenAPI, CORS, error handlers
- **Need templates?** See [templates/](templates/) for copy-paste ready endpoint code
- **Need checklists?** Use [checklists/](checklists/) for systematic API design reviews

View File

@@ -0,0 +1,118 @@
# API Design Checklist
**Use this checklist before creating PR for new API endpoints.**
## RESTful Design
- [ ] URLs use plural nouns (`/users` not `/user`)
- [ ] URLs use lowercase with hyphens (`/user-profiles` not `/userProfiles`)
- [ ] No verbs in URLs (`/users` not `/getUsers`)
- [ ] Hierarchical resources follow pattern `/parent/{id}/child`
- [ ] HTTP verbs used correctly (GET=read, POST=create, PUT=update, DELETE=delete)
## Multi-Tenant Isolation
- [ ] All queries filtered by `tenant_id` from JWT
- [ ] Repository pattern used with automatic tenant filtering
- [ ] No direct database queries bypassing repository
- [ ] Cross-tenant access blocked (except superuser endpoints)
- [ ] Test cases verify tenant isolation
## Request/Response Schemas
- [ ] Pydantic schemas defined for all requests
- [ ] Pydantic schemas defined for all responses
- [ ] Password hashes never returned in responses
- [ ] Sensitive fields excluded from public schemas
- [ ] Validation rules enforced (min/max length, format, etc.)
- [ ] Field-level validation implemented where needed
- [ ] Model-level validation for cross-field rules
## HTTP Status Codes
- [ ] 200 OK for successful GET/PUT/PATCH
- [ ] 201 Created for successful POST
- [ ] 204 No Content for successful DELETE
- [ ] 400 Bad Request for invalid data
- [ ] 401 Unauthorized for missing/invalid auth
- [ ] 403 Forbidden for insufficient permissions
- [ ] 404 Not Found for missing resources
- [ ] 409 Conflict for duplicates
- [ ] 422 Validation Error for schema failures
## Error Handling
- [ ] Consistent error response format (`error`, `status_code`, `detail`)
- [ ] Custom exception handlers registered
- [ ] Validation errors return field-level details
- [ ] Integrity errors handled gracefully (409 Conflict)
- [ ] Generic exceptions caught and logged
## Pagination
- [ ] List endpoints support pagination (`skip`, `limit`)
- [ ] Maximum limit enforced (100 default)
- [ ] Paginated response includes `total`, `has_more`
- [ ] Cursor-based pagination for large datasets
- [ ] Pagination tested with edge cases
## Authentication
- [ ] JWT authentication required on protected endpoints
- [ ] `tenant_id` extracted from JWT claims
- [ ] Superuser flag checked for admin endpoints
- [ ] Public endpoints explicitly marked (no auth)
- [ ] Authentication tested in integration tests
## OpenAPI Documentation
- [ ] Endpoint docstrings describe purpose
- [ ] All parameters documented
- [ ] Response schemas documented
- [ ] Error responses documented (409, 422, etc.)
- [ ] Examples provided for complex schemas
- [ ] Tags assigned for logical grouping
## Rate Limiting
- [ ] Public endpoints have rate limiting
- [ ] Critical endpoints (create, update) have stricter limits
- [ ] Rate limit uses Upstash Redis
- [ ] Rate limit headers included in response
## Testing
- [ ] Unit tests for repository methods
- [ ] Integration tests for all CRUD operations
- [ ] Tenant isolation verified in tests
- [ ] Duplicate detection tested (409 Conflict)
- [ ] Validation errors tested (422 Unprocessable)
- [ ] Authentication tested (401 Unauthorized)
- [ ] Tests run with Doppler (`doppler run --config test`)
## Security
- [ ] No SQL injection vulnerabilities (using ORM)
- [ ] No hardcoded secrets or credentials
- [ ] CORS origins from Doppler (not hardcoded)
- [ ] Input validation on all fields
- [ ] Output encoding prevents XSS
- [ ] Rate limiting protects against abuse
## Performance
- [ ] Database queries use indexes
- [ ] N+1 queries avoided
- [ ] Pagination prevents loading all records
- [ ] Heavy operations use background jobs
- [ ] Caching implemented where appropriate
## Before Merging
- [ ] All tests pass (`pytest`)
- [ ] Coverage >80% for new code
- [ ] OpenAPI docs reviewed at `/docs`
- [ ] Tested locally with Doppler
- [ ] Code reviewed by teammate
- [ ] No console.log or debug prints
- [ ] Migration created if schema changed

View File

@@ -0,0 +1,84 @@
# API Security Review Checklist
**Security-focused review for API endpoints.**
## Authentication & Authorization
- [ ] JWT required on all protected endpoints
- [ ] Token expiration enforced
- [ ] Superuser flag checked for admin operations
- [ ] No authentication bypass vulnerabilities
- [ ] Session management secure
## Multi-Tenant Security
- [ ] All queries filter by `tenant_id`
- [ ] No cross-tenant data leaks possible
- [ ] Repository pattern enforces isolation
- [ ] Admin endpoints verify superuser
- [ ] Test cases prove isolation
## Input Validation
- [ ] All inputs validated with Pydantic
- [ ] SQL injection prevented (using ORM)
- [ ] XSS prevented (output encoding)
- [ ] File uploads validated (type, size)
- [ ] Email format validated
- [ ] URL format validated
- [ ] Integer ranges validated
- [ ] String lengths validated
## Sensitive Data
- [ ] Passwords hashed with bcrypt
- [ ] Password hashes never returned
- [ ] Secrets from Doppler (not hardcoded)
- [ ] PII properly handled
- [ ] No sensitive data in logs
- [ ] No sensitive data in error messages
## Rate Limiting
- [ ] Public endpoints rate limited
- [ ] Login endpoints strictly rate limited
- [ ] Rate limit uses Redis
- [ ] Rate limit tested
## CORS
- [ ] Allowed origins from Doppler
- [ ] No `allow_origins=["*"]` in production
- [ ] Credentials allowed only for trusted origins
- [ ] Preflight requests handled
## Error Handling
- [ ] No stack traces in production responses
- [ ] Generic errors for security issues
- [ ] Detailed errors only in dev/test
- [ ] Errors logged server-side
## OWASP Top 10
- [ ] A01: Broken Access Control - ✅ Tenant isolation
- [ ] A02: Cryptographic Failures - ✅ Bcrypt, Doppler
- [ ] A03: Injection - ✅ ORM, validation
- [ ] A04: Insecure Design - ✅ Repository pattern
- [ ] A05: Security Misconfiguration - ✅ CORS, secrets
- [ ] A06: Vulnerable Components - ✅ Updated dependencies
- [ ] A07: Identification Failures - ✅ JWT, rate limiting
- [ ] A08: Integrity Failures - ✅ Input validation
- [ ] A09: Logging Failures - ✅ Error logging
- [ ] A10: SSRF - ✅ URL validation
## Before Production Deploy
- [ ] Security scan passed
- [ ] No hardcoded secrets
- [ ] Doppler secrets configured
- [ ] CORS origins configured
- [ ] Rate limiting enabled
- [ ] HTTPS enforced
- [ ] Error handling tested
- [ ] Penetration test completed

View File

@@ -0,0 +1,17 @@
# API Design Examples Index
**All example files are under 500 lines for optimal loading.**
## Available Examples
- **[fastapi-crud.md](fastapi-crud.md)** - Complete CRUD endpoints with repository pattern (332 lines)
- **[pydantic-schemas.md](pydantic-schemas.md)** - Request/response schemas with validation
- **[tanstack-start.md](tanstack-start.md)** - TanStack Start server functions
- **[pagination.md](pagination.md)** - Offset and cursor-based pagination
- **[testing.md](testing.md)** - API endpoint testing with pytest
## Quick Links
- **Templates**: See [../templates/](../templates/) for copy-paste ready code
- **Reference**: See [../reference/](../reference/) for complete configurations
- **Checklists**: See [../checklists/](../checklists/) for API design validation

View File

@@ -0,0 +1,332 @@
# FastAPI CRUD Endpoints
**Complete CRUD endpoint examples with repository pattern and tenant isolation.**
## Complete User CRUD
```python
# app/api/routes/users.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session
from typing import Optional
from app.core.dependencies import get_current_user, get_db
from app.models.user import User
from app.repositories.user_repository import UserRepository
from app.schemas.user import UserCreate, UserRead, UserUpdate, PaginatedResponse
router = APIRouter(prefix="/api/v1/users", tags=["users"])
@router.get("", response_model=PaginatedResponse[UserRead], status_code=status.HTTP_200_OK)
async def list_users(
skip: int = 0,
limit: int = 100,
is_active: Optional[bool] = None,
email_contains: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> PaginatedResponse[UserRead]:
"""
List all users in the current tenant with pagination and filtering.
- **skip**: Number of records to skip (pagination offset)
- **limit**: Maximum number of records to return (max 100)
- **is_active**: Filter by active status (optional)
- **email_contains**: Filter by email substring (optional)
- **Returns**: Paginated list of users with public fields only
"""
repository = UserRepository(db, tenant_id=current_user.tenant_id)
# Build filter criteria
filters = {}
if is_active is not None:
filters["is_active"] = is_active
if email_contains:
filters["email_contains"] = email_contains
users = await repository.list(filters=filters, skip=skip, limit=limit)
total = await repository.count(filters=filters)
return PaginatedResponse(
items=users,
total=total,
skip=skip,
limit=limit,
has_more=(skip + limit) < total,
)
@router.get("/{user_id}", response_model=UserRead, status_code=status.HTTP_200_OK)
async def get_user(
user_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> UserRead:
"""Get a single user by ID (tenant-isolated)."""
repository = UserRepository(db, tenant_id=current_user.tenant_id)
user = await repository.get_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {user_id} not found",
)
return user
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_user(
user_data: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> UserRead:
"""
Create a new user in the current tenant.
- **email**: Valid email address (unique per tenant)
- **full_name**: User's full name (1-255 characters)
- **password**: At least 8 characters
- **Returns**: Created user with ID, timestamps, and public fields
**Errors**:
- 409 Conflict: Email already exists in tenant
- 422 Validation Error: Invalid email or weak password
"""
repository = UserRepository(db, tenant_id=current_user.tenant_id)
try:
user = await repository.create(user_data)
return user
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"User with email {user_data.email} already exists",
)
@router.put("/{user_id}", response_model=UserRead, status_code=status.HTTP_200_OK)
async def update_user(
user_id: str,
user_data: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> UserRead:
"""
Update an existing user (tenant-isolated).
All fields are optional - only provided fields will be updated.
"""
repository = UserRepository(db, tenant_id=current_user.tenant_id)
user = await repository.update(user_id, user_data)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {user_id} not found",
)
return user
@router.patch("/{user_id}", response_model=UserRead, status_code=status.HTTP_200_OK)
async def partial_update_user(
user_id: str,
user_data: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> UserRead:
"""
Partially update an existing user (tenant-isolated).
Same as PUT but semantically indicates partial update.
"""
repository = UserRepository(db, tenant_id=current_user.tenant_id)
user = await repository.update(user_id, user_data)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {user_id} not found",
)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
"""
Soft-delete a user (tenant-isolated).
Returns 204 No Content on success (no response body).
"""
repository = UserRepository(db, tenant_id=current_user.tenant_id)
success = await repository.delete(user_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {user_id} not found",
)
```
## Repository Pattern with Tenant Isolation
```python
# app/repositories/user_repository.py
from sqlmodel import Session, select, func
from typing import Optional
from datetime import datetime
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
class UserRepository:
"""Repository with automatic tenant filtering."""
def __init__(self, db: Session, tenant_id: str):
self.db = db
self.tenant_id = tenant_id
async def list(
self, filters: dict = None, skip: int = 0, limit: int = 100
) -> list[User]:
"""List users (automatically filtered by tenant_id)."""
statement = select(User).where(User.tenant_id == self.tenant_id)
# Apply additional filters
if filters:
if "is_active" in filters:
statement = statement.where(User.is_active == filters["is_active"])
if "email_contains" in filters:
statement = statement.where(User.email.contains(filters["email_contains"]))
statement = statement.offset(skip).limit(limit).order_by(User.created_at.desc())
result = await self.db.execute(statement)
return result.scalars().all()
async def count(self, filters: dict = None) -> int:
"""Count users (tenant-isolated)."""
statement = select(func.count(User.id)).where(User.tenant_id == self.tenant_id)
# Apply same filters as list()
if filters:
if "is_active" in filters:
statement = statement.where(User.is_active == filters["is_active"])
if "email_contains" in filters:
statement = statement.where(User.email.contains(filters["email_contains"]))
result = await self.db.execute(statement)
return result.scalar_one()
async def get_by_id(self, user_id: str) -> User | None:
"""Get user by ID (tenant-isolated)."""
statement = select(User).where(
User.id == user_id,
User.tenant_id == self.tenant_id,
)
result = await self.db.execute(statement)
return result.scalar_one_or_none()
async def create(self, user_data: UserCreate) -> User:
"""Create a new user in the current tenant."""
from app.utils.password import hash_password
user = User(
email=user_data.email,
full_name=user_data.full_name,
hashed_password=hash_password(user_data.password),
tenant_id=self.tenant_id, # Automatic tenant assignment
)
self.db.add(user)
await self.db.commit()
await self.db.refresh(user)
return user
async def update(self, user_id: str, user_data: UserUpdate) -> User | None:
"""Update an existing user (tenant-isolated)."""
user = await self.get_by_id(user_id)
if not user:
return None
# Update only provided fields
update_data = user_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
await self.db.commit()
await self.db.refresh(user)
return user
async def delete(self, user_id: str) -> bool:
"""Soft-delete a user (tenant-isolated)."""
user = await self.get_by_id(user_id)
if not user:
return False
# Soft delete by setting deleted_at timestamp
user.deleted_at = datetime.utcnow()
await self.db.commit()
return True
```
## Nested Resources
```python
# app/api/routes/organizations.py
from fastapi import APIRouter, Depends, HTTPException, status
router = APIRouter(prefix="/api/v1/organizations", tags=["organizations"])
@router.get("/{org_id}/teams", response_model=list[TeamRead])
async def list_organization_teams(
org_id: str,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[TeamRead]:
"""List all teams in an organization (tenant-isolated)."""
# Verify organization exists and belongs to tenant
org_repo = OrganizationRepository(db, tenant_id=current_user.tenant_id)
org = await org_repo.get_by_id(org_id)
if not org:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Organization with ID {org_id} not found",
)
# Fetch teams for organization
team_repo = TeamRepository(db, tenant_id=current_user.tenant_id)
teams = await team_repo.list_by_organization(org_id, skip=skip, limit=limit)
return teams
@router.post("/{org_id}/teams", response_model=TeamRead, status_code=status.HTTP_201_CREATED)
async def create_organization_team(
org_id: str,
team_data: TeamCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> TeamRead:
"""Create a new team in an organization."""
# Verify organization exists
org_repo = OrganizationRepository(db, tenant_id=current_user.tenant_id)
org = await org_repo.get_by_id(org_id)
if not org:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Organization with ID {org_id} not found",
)
# Create team
team_repo = TeamRepository(db, tenant_id=current_user.tenant_id)
team = await team_repo.create(team_data, organization_id=org_id)
return team
```
**See also:**
- [pydantic-schemas.md](pydantic-schemas.md) - Request/response schema patterns
- [pagination.md](pagination.md) - Pagination and filtering examples
- [../templates/fastapi-crud-endpoint.py](../templates/fastapi-crud-endpoint.py) - CRUD template

View File

@@ -0,0 +1,38 @@
# Pagination Patterns
**Offset-based and cursor-based pagination examples.**
## Offset-Based Pagination
```python
class PaginatedResponse[T](BaseModel):
items: list[T]
total: int
skip: int
limit: int
has_more: bool
@router.get("", response_model=PaginatedResponse[UserRead])
async def list_users(skip: int = 0, limit: int = 100):
repository = UserRepository(db, tenant_id=current_user.tenant_id)
users = await repository.list(skip=skip, limit=limit)
total = await repository.count()
return PaginatedResponse(items=users, total=total, skip=skip, limit=limit, has_more=(skip + limit) < total)
```
## Cursor-Based Pagination (Recommended)
```python
class CursorPaginatedResponse[T](BaseModel):
items: list[T]
next_cursor: Optional[str]
has_more: bool
@router.get("")
async def list_users(cursor: Optional[str] = None, limit: int = 100):
users = await repository.list_cursor(cursor=cursor, limit=limit)
next_cursor = users[-1].id if len(users) == limit else None
return CursorPaginatedResponse(items=users, next_cursor=next_cursor, has_more=next_cursor is not None)
```
**See also:** [fastapi-crud.md](fastapi-crud.md) for complete examples

View File

@@ -0,0 +1,149 @@
# Pydantic Schema Examples
**Complete Pydantic schema patterns for request/response validation.**
## Request/Response Schemas
```python
# app/schemas/user.py
from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_validator, model_validator
from datetime import datetime
from typing import Optional
class UserBase(BaseModel):
"""Shared fields for User schemas."""
email: EmailStr
full_name: str = Field(..., min_length=1, max_length=255)
is_active: bool = True
class UserCreate(UserBase):
"""Schema for creating a new user."""
password: str = Field(..., min_length=8, max_length=100)
password_confirm: str = Field(..., min_length=8, max_length=100)
@field_validator("password")
@classmethod
def validate_password_strength(cls, v: str) -> str:
"""Ensure password meets complexity requirements."""
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(char.isdigit() for char in v):
raise ValueError("Password must contain at least one digit")
if not any(char.isupper() for char in v):
raise ValueError("Password must contain at least one uppercase letter")
if not any(char.islower() for char in v):
raise ValueError("Password must contain at least one lowercase letter")
return v
@model_validator(mode="after")
def passwords_match(self) -> "UserCreate":
"""Ensure password and password_confirm match."""
if self.password != self.password_confirm:
raise ValueError("Passwords do not match")
return self
class UserUpdate(BaseModel):
"""Schema for updating an existing user (all fields optional)."""
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, min_length=1, max_length=255)
is_active: Optional[bool] = None
class UserRead(UserBase):
"""Schema for reading user data (public fields only)."""
id: str
tenant_id: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class PaginatedResponse[T](BaseModel):
"""Generic paginated response."""
items: list[T]
total: int
skip: int
limit: int
has_more: bool
```
## Nested Schemas
```python
# app/schemas/organization.py
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
from typing import Optional
from app.schemas.team import TeamRead
class OrganizationBase(BaseModel):
"""Shared fields for Organization schemas."""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
class OrganizationCreate(OrganizationBase):
"""Schema for creating a new organization."""
pass
class OrganizationRead(OrganizationBase):
"""Organization with nested teams."""
id: str
tenant_id: str
teams: list[TeamRead] = [] # Nested teams
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
```
## Custom Validation
```python
# app/schemas/user.py
from pydantic import field_validator, model_validator
import re
class UserCreate(BaseModel):
email: EmailStr
username: str
password: str
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
"""Ensure username is alphanumeric."""
if not re.match(r"^[a-zA-Z0-9_-]+$", v):
raise ValueError("Username must contain only letters, numbers, hyphens, and underscores")
if len(v) < 3:
raise ValueError("Username must be at least 3 characters")
return v
@field_validator("email")
@classmethod
def validate_email_domain(cls, v: str) -> str:
"""Ensure email is from allowed domain."""
allowed_domains = ["example.com", "greyhaven.studio"]
domain = v.split("@")[1]
if domain not in allowed_domains:
raise ValueError(f"Email must be from {', '.join(allowed_domains)}")
return v
@model_validator(mode="after")
def validate_username_not_in_email(self) -> "UserCreate":
"""Ensure username is not part of email."""
if self.username.lower() in self.email.lower():
raise ValueError("Username cannot be part of email address")
return self
```
**See also:**
- [fastapi-crud.md](fastapi-crud.md) - CRUD endpoints using these schemas
- [../templates/pydantic-schemas.py](../templates/pydantic-schemas.py) - Schema template

View File

@@ -0,0 +1,71 @@
# TanStack Start Server Functions
**Complete server function examples with Drizzle ORM and tenant isolation.**
See [../templates/tanstack-server-function.ts](../templates/tanstack-server-function.ts) for full template.
## Complete CRUD Server Functions
```typescript
// app/routes/api/users.ts
import { createServerFn } from "@tanstack/start";
import { z } from "zod";
import { db } from "~/utils/db.server";
import { usersTable } from "~/db/schema";
import { getAuthUser } from "~/utils/auth.server";
import { eq, and, like, count, desc } from "drizzle-orm";
import { hashPassword } from "~/utils/password.server";
// Validation schemas
const createUserSchema = z.object({
email: z.string().email(),
fullName: z.string().min(1).max(255),
password: z.string().min(8),
});
const updateUserSchema = z.object({
email: z.string().email().optional(),
fullName: z.string().min(1).max(255).optional(),
isActive: z.boolean().optional(),
});
// List users
export const listUsers = createServerFn({ method: "GET" })
.validator(z.object({ skip: z.number().min(0).default(0), limit: z.number().min(1).max(100).default(100) }))
.handler(async ({ data, context }) => {
const authUser = await getAuthUser(context);
if (!authUser) throw new Error("Unauthorized", { status: 401 });
const users = await db.select().from(usersTable)
.where(eq(usersTable.tenantId, authUser.tenantId))
.orderBy(desc(usersTable.createdAt))
.limit(data.limit).offset(data.skip);
const [{ count: total }] = await db.select({ count: count() })
.from(usersTable).where(eq(usersTable.tenantId, authUser.tenantId));
return {
items: users.map(({ hashedPassword, ...user }) => user),
total, skip: data.skip, limit: data.limit,
hasMore: data.skip + data.limit < total,
};
});
// Create user
export const createUser = createServerFn({ method: "POST" })
.validator(createUserSchema)
.handler(async ({ data, context }) => {
const authUser = await getAuthUser(context);
if (!authUser) throw new Error("Unauthorized", { status: 401 });
const [user] = await db.insert(usersTable).values({
...data, hashedPassword: await hashPassword(data.password),
tenantId: authUser.tenantId,
}).returning();
const { hashedPassword: _, ...userPublic } = user;
return userPublic;
});
```
**See also:** [fastapi-crud.md](fastapi-crud.md) for FastAPI examples

View File

@@ -0,0 +1,22 @@
# API Testing Examples
**pytest examples for FastAPI endpoints.**
```python
# tests/api/test_users.py
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
def test_create_user(test_token: str):
response = client.post("/api/v1/users", json={"email": "test@example.com", "full_name": "Test", "password": "Pass123"}, headers={"Authorization": f"Bearer {test_token}"})
assert response.status_code == 201
assert "id" in response.json()
@pytest.mark.integration
def test_tenant_isolation(test_token: str):
response = client.get(f"/api/v1/users/{other_tenant_user_id}", headers={"Authorization": f"Bearer {test_token}"})
assert response.status_code == 404 # Cannot access other tenant's data
```
Run with: `doppler run --config test -- pytest tests/api/ -v`

View File

@@ -0,0 +1,11 @@
# API Design Reference Index
**All reference files are under 500 lines for optimal loading.**
## Available References
- **[fastapi-setup.md](fastapi-setup.md)** - Main FastAPI configuration
- **[openapi.md](openapi.md)** - OpenAPI customization
- **[error-handlers.md](error-handlers.md)** - Exception handlers
- **[authentication.md](authentication.md)** - JWT configuration
- **[cors-rate-limiting.md](cors-rate-limiting.md)** - CORS and rate limiting

View File

@@ -0,0 +1,24 @@
# Authentication Configuration
**JWT setup with bcrypt password hashing.**
```python
# app/core/auth.py
import jwt
from passlib.context import CryptContext
from datetime import datetime, timedelta
import os
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY")
JWT_ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def create_access_token(user_id: str, tenant_id: str) -> str:
expire = datetime.utcnow() + timedelta(minutes=30)
return jwt.encode({"sub": user_id, "tenant_id": tenant_id, "exp": expire}, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
```
**Doppler:** `JWT_SECRET_KEY` must be set in Doppler secrets.

View File

@@ -0,0 +1,26 @@
# CORS and Rate Limiting
**CORS middleware and Upstash Redis rate limiter.**
## CORS
```python
allowed_origins = os.getenv("CORS_ALLOWED_ORIGINS", "").split(",")
app.add_middleware(CORSMiddleware, allow_origins=allowed_origins, allow_credentials=True)
```
**Doppler:** `CORS_ALLOWED_ORIGINS="https://app.example.com"`
## Rate Limiting
See [../templates/rate-limiter.py](../templates/rate-limiter.py) for full implementation.
```python
from app.core.rate_limit import rate_limit_normal
@router.get("", dependencies=[Depends(rate_limit_normal)])
async def list_users():
pass # Rate limited to 100 req/min
```
**Doppler:** `REDIS_URL` must be set for Upstash Redis.

View File

@@ -0,0 +1,17 @@
# Error Handlers
**Complete exception handler configuration.**
See [../templates/error-handler.py](../templates/error-handler.py) for full implementation.
```python
async def http_exception_handler(request, exc):
return JSONResponse(status_code=exc.status_code, content={"error": exc.detail, "status_code": exc.status_code})
async def validation_exception_handler(request, exc):
errors = [{"field": ".".join(str(loc) for loc in e["loc"]), "message": e["msg"], "code": e["type"]} for e in exc.errors()]
return JSONResponse(status_code=422, content={"error": "Validation error", "detail": errors, "status_code": 422})
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
```

View File

@@ -0,0 +1,34 @@
# FastAPI Setup
**Complete main application configuration.**
```python
# app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import os
app = FastAPI(
title="Grey Haven API",
description="RESTful API for multi-tenant SaaS",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
)
# CORS
allowed_origins = os.getenv("CORS_ALLOWED_ORIGINS", "").split(",")
app.add_middleware(CORSMiddleware, allow_origins=allowed_origins, allow_credentials=True)
# Exception handlers (see error-handlers.md)
# app.add_exception_handler(...)
# Include routers
app.include_router(users.router)
@app.get("/health")
async def health_check():
return {"status": "healthy"}
```
**Doppler config:** `doppler run --config dev -- uvicorn app.main:app --reload`

View File

@@ -0,0 +1,17 @@
# OpenAPI Customization
**Custom OpenAPI schema with security.**
```python
def custom_openapi(app):
openapi_schema = get_openapi(title=app.title, version=app.version, routes=app.routes)
openapi_schema["components"]["securitySchemes"] = {
"BearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}
}
openapi_schema["security"] = [{"BearerAuth": []}]
return openapi_schema
app.openapi = lambda: custom_openapi(app)
```
Access docs at `/docs` (Swagger UI) or `/redoc` (ReDoc).

View File

@@ -0,0 +1,115 @@
# Grey Haven Studio - Error Handler Template
# Add this to app/core/exceptions.py
from fastapi import HTTPException, Request, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import IntegrityError, OperationalError
import logging
logger = logging.getLogger(__name__)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
"""Handle HTTPException with standard error format."""
logger.warning(f"HTTP exception: {exc.status_code} - {exc.detail}")
return JSONResponse(
status_code=exc.status_code,
content={
"error": exc.detail,
"status_code": exc.status_code,
},
headers=exc.headers,
)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Handle Pydantic validation errors."""
errors = []
for error in exc.errors():
field_path = ".".join(str(loc) for loc in error["loc"] if loc != "body")
errors.append(
{
"field": field_path if field_path else None,
"message": error["msg"],
"code": error["type"],
}
)
logger.warning(f"Validation error: {errors}")
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"error": "Validation error",
"detail": errors,
"status_code": 422,
},
)
async def integrity_error_handler(
request: Request, exc: IntegrityError
) -> JSONResponse:
"""Handle database integrity errors."""
logger.error(f"Integrity error: {exc}")
error_detail = "A resource with this unique value already exists"
if "foreign key" in str(exc).lower():
error_detail = "Referenced resource does not exist"
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={
"error": error_detail,
"status_code": 409,
},
)
async def operational_error_handler(
request: Request, exc: OperationalError
) -> JSONResponse:
"""Handle database operational errors."""
logger.error(f"Database operational error: {exc}")
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={
"error": "Service temporarily unavailable",
"detail": "Database connection error. Please try again later.",
"status_code": 503,
},
)
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Catch-all handler for unexpected exceptions."""
logger.exception(f"Unhandled exception: {exc}")
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": "Internal server error",
"status_code": 500,
},
)
# Register in main.py:
# from app.core.exceptions import (
# http_exception_handler,
# validation_exception_handler,
# integrity_error_handler,
# operational_error_handler,
# generic_exception_handler,
# )
#
# app.add_exception_handler(HTTPException, http_exception_handler)
# app.add_exception_handler(RequestValidationError, validation_exception_handler)
# app.add_exception_handler(IntegrityError, integrity_error_handler)
# app.add_exception_handler(OperationalError, operational_error_handler)
# app.add_exception_handler(Exception, generic_exception_handler)

View File

@@ -0,0 +1,130 @@
# Grey Haven Studio - FastAPI CRUD Endpoint Template
# Copy this template for new resource endpoints
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session
from typing import Optional
from app.core.dependencies import get_current_user, get_db
from app.models.user import User
from app.repositories.resource_repository import ResourceRepository # TODO: Update import
from app.schemas.resource import ResourceCreate, ResourceRead, ResourceUpdate, PaginatedResponse # TODO: Update import
# TODO: Update prefix, tags, and model name
router = APIRouter(prefix="/api/v1/resources", tags=["resources"])
@router.get("", response_model=PaginatedResponse[ResourceRead], status_code=status.HTTP_200_OK)
async def list_resources(
skip: int = 0,
limit: int = 100,
# TODO: Add filter parameters as needed
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> PaginatedResponse[ResourceRead]:
"""
List all resources in the current tenant with pagination.
- **skip**: Number of records to skip (pagination offset)
- **limit**: Maximum number of records to return (max 100)
- **Returns**: Paginated list of resources
"""
repository = ResourceRepository(db, tenant_id=current_user.tenant_id)
# TODO: Build filter criteria if needed
filters = {}
resources = await repository.list(filters=filters, skip=skip, limit=limit)
total = await repository.count(filters=filters)
return PaginatedResponse(
items=resources,
total=total,
skip=skip,
limit=limit,
has_more=(skip + limit) < total,
)
@router.get("/{resource_id}", response_model=ResourceRead, status_code=status.HTTP_200_OK)
async def get_resource(
resource_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ResourceRead:
"""Get a single resource by ID (tenant-isolated)."""
repository = ResourceRepository(db, tenant_id=current_user.tenant_id)
resource = await repository.get_by_id(resource_id)
if not resource:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Resource with ID {resource_id} not found",
)
return resource
@router.post("", response_model=ResourceRead, status_code=status.HTTP_201_CREATED)
async def create_resource(
resource_data: ResourceCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ResourceRead:
"""
Create a new resource in the current tenant.
**Errors**:
- 409 Conflict: Duplicate resource
- 422 Validation Error: Invalid data
"""
repository = ResourceRepository(db, tenant_id=current_user.tenant_id)
try:
resource = await repository.create(resource_data)
return resource
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Resource with this identifier already exists",
)
@router.put("/{resource_id}", response_model=ResourceRead, status_code=status.HTTP_200_OK)
async def update_resource(
resource_id: str,
resource_data: ResourceUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ResourceRead:
"""
Update an existing resource (tenant-isolated).
All fields are optional - only provided fields will be updated.
"""
repository = ResourceRepository(db, tenant_id=current_user.tenant_id)
resource = await repository.update(resource_id, resource_data)
if not resource:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Resource with ID {resource_id} not found",
)
return resource
@router.delete("/{resource_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_resource(
resource_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
"""
Soft-delete a resource (tenant-isolated).
Returns 204 No Content on success (no response body).
"""
repository = ResourceRepository(db, tenant_id=current_user.tenant_id)
success = await repository.delete(resource_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Resource with ID {resource_id} not found",
)

View File

@@ -0,0 +1,61 @@
# Grey Haven Studio - Pydantic Schema Template
# Copy this template for new resource schemas
from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_validator
from datetime import datetime
from typing import Optional
# TODO: Update model name
class ResourceBase(BaseModel):
"""Shared fields for Resource schemas."""
# TODO: Add your base fields here
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
is_active: bool = True
class ResourceCreate(ResourceBase):
"""Schema for creating a new resource."""
# TODO: Add creation-specific fields
# Example: password, external_id, etc.
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
"""Add custom validation if needed."""
if not v.strip():
raise ValueError("Name cannot be empty or whitespace")
return v.strip()
class ResourceUpdate(BaseModel):
"""Schema for updating an existing resource (all fields optional)."""
# TODO: Add updateable fields (all optional)
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
is_active: Optional[bool] = None
class ResourceRead(ResourceBase):
"""Schema for reading resource data (public fields only)."""
id: str
tenant_id: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class PaginatedResponse[T](BaseModel):
"""Generic paginated response."""
items: list[T]
total: int
skip: int
limit: int
has_more: bool

View File

@@ -0,0 +1,82 @@
# Grey Haven Studio - Rate Limiter Template
# Add this to app/core/rate_limit.py
from fastapi import Request, HTTPException, status
from upstash_redis import Redis
import os
# Doppler provides REDIS_URL
redis = Redis.from_url(os.getenv("REDIS_URL"))
class RateLimiter:
"""
Rate limiter using Upstash Redis.
Usage:
rate_limit_strict = RateLimiter(max_requests=10, window=60)
rate_limit_normal = RateLimiter(max_requests=100, window=60)
@router.post("", dependencies=[Depends(rate_limit_strict)])
async def create_resource():
pass
"""
def __init__(self, max_requests: int = 100, window: int = 60):
"""
Initialize rate limiter.
Args:
max_requests: Maximum requests allowed in window
window: Time window in seconds
"""
self.max_requests = max_requests
self.window = window
async def __call__(self, request: Request):
"""Check rate limit for current request."""
# Get client identifier (IP address or user ID from JWT)
client_id = self._get_client_id(request)
# Rate limit key
key = f"rate_limit:{client_id}:{request.url.path}"
# Increment counter
count = redis.incr(key)
# Set expiration on first request
if count == 1:
redis.expire(key, self.window)
# Check if limit exceeded
if count > self.max_requests:
retry_after = redis.ttl(key)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Rate limit exceeded. Try again in {retry_after} seconds.",
headers={"Retry-After": str(retry_after)},
)
def _get_client_id(self, request: Request) -> str:
"""Get client identifier (IP or user ID)."""
# Prefer user ID from JWT if available
if hasattr(request.state, "user") and request.state.user:
return f"user:{request.state.user.id}"
# Fallback to IP address
return f"ip:{request.client.host}"
# Rate limit configurations
rate_limit_strict = RateLimiter(max_requests=10, window=60) # 10 req/min
rate_limit_normal = RateLimiter(max_requests=100, window=60) # 100 req/min
rate_limit_relaxed = RateLimiter(max_requests=1000, window=60) # 1000 req/min
# Apply to routes:
# from app.core.rate_limit import rate_limit_strict, rate_limit_normal
#
# @router.post("", dependencies=[Depends(rate_limit_strict)])
# async def create_user():
# """Create user (10 req/min limit)."""
# pass

View File

@@ -0,0 +1,101 @@
# Grey Haven Studio - Repository Pattern Template
# Copy this template for new resource repositories
from sqlmodel import Session, select, func
from typing import Optional
from uuid import UUID
from app.models.resource import Resource # TODO: Update import
from app.schemas.resource import ResourceCreate, ResourceUpdate # TODO: Update import
# TODO: Update class name
class ResourceRepository:
"""Repository with automatic tenant filtering for Resource model."""
def __init__(self, db: Session, tenant_id: str):
self.db = db
self.tenant_id = tenant_id
async def list(
self, filters: dict = None, skip: int = 0, limit: int = 100
) -> list[Resource]:
"""List resources (automatically filtered by tenant_id)."""
statement = select(Resource).where(Resource.tenant_id == self.tenant_id)
# Apply additional filters
if filters:
# TODO: Add your filter logic here
# Example:
# if "is_active" in filters:
# statement = statement.where(Resource.is_active == filters["is_active"])
pass
statement = statement.offset(skip).limit(limit).order_by(Resource.created_at.desc())
result = await self.db.execute(statement)
return result.scalars().all()
async def count(self, filters: dict = None) -> int:
"""Count resources (tenant-isolated)."""
statement = select(func.count(Resource.id)).where(
Resource.tenant_id == self.tenant_id
)
# Apply same filters as list()
if filters:
# TODO: Add same filter logic as list()
pass
result = await self.db.execute(statement)
return result.scalar_one()
async def get_by_id(self, resource_id: str) -> Resource | None:
"""Get resource by ID (tenant-isolated)."""
statement = select(Resource).where(
Resource.id == resource_id,
Resource.tenant_id == self.tenant_id,
)
result = await self.db.execute(statement)
return result.scalar_one_or_none()
async def create(self, resource_data: ResourceCreate) -> Resource:
"""Create a new resource in the current tenant."""
resource = Resource(
**resource_data.model_dump(),
tenant_id=self.tenant_id, # Automatic tenant assignment
)
self.db.add(resource)
await self.db.commit()
await self.db.refresh(resource)
return resource
async def update(
self, resource_id: str, resource_data: ResourceUpdate
) -> Resource | None:
"""Update an existing resource (tenant-isolated)."""
resource = await self.get_by_id(resource_id)
if not resource:
return None
# Update only provided fields
update_data = resource_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(resource, field, value)
await self.db.commit()
await self.db.refresh(resource)
return resource
async def delete(self, resource_id: str) -> bool:
"""Soft-delete a resource (tenant-isolated)."""
resource = await self.get_by_id(resource_id)
if not resource:
return False
# Soft delete by setting deleted_at timestamp
from datetime import datetime
resource.deleted_at = datetime.utcnow()
await self.db.commit()
return True

View File

@@ -0,0 +1,186 @@
// Grey Haven Studio - TanStack Start Server Function Template
// Copy this template for new TanStack Start server functions
import { createServerFn } from "@tanstack/start";
import { z } from "zod";
import { db } from "~/utils/db.server";
import { resourcesTable } from "~/db/schema"; // TODO: Update import
import { getAuthUser } from "~/utils/auth.server";
import { eq, and, like, count, desc } from "drizzle-orm";
// TODO: Update validation schemas
const createResourceSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
});
const updateResourceSchema = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().optional(),
isActive: z.boolean().optional(),
});
const listResourcesSchema = z.object({
skip: z.number().min(0).default(0),
limit: z.number().min(1).max(100).default(100),
// TODO: Add filter fields
});
// List resources
export const listResources = createServerFn({ method: "GET" })
.validator(listResourcesSchema)
.handler(async ({ data, context }) => {
const authUser = await getAuthUser(context);
if (!authUser) {
throw new Error("Unauthorized", { status: 401 });
}
// Build query with tenant filter
let query = db
.select()
.from(resourcesTable)
.where(eq(resourcesTable.tenantId, authUser.tenantId));
// TODO: Apply additional filters
// Apply pagination
const resources = await query
.orderBy(desc(resourcesTable.createdAt))
.limit(data.limit)
.offset(data.skip);
// Get total count
const [{ count: total }] = await db
.select({ count: count() })
.from(resourcesTable)
.where(eq(resourcesTable.tenantId, authUser.tenantId));
return {
items: resources,
total,
skip: data.skip,
limit: data.limit,
hasMore: data.skip + data.limit < total,
};
});
// Get resource by ID
export const getResource = createServerFn({ method: "GET" })
.validator(z.object({ resourceId: z.string() }))
.handler(async ({ data, context }) => {
const authUser = await getAuthUser(context);
if (!authUser) {
throw new Error("Unauthorized", { status: 401 });
}
const [resource] = await db
.select()
.from(resourcesTable)
.where(
and(
eq(resourcesTable.id, data.resourceId),
eq(resourcesTable.tenantId, authUser.tenantId)
)
)
.limit(1);
if (!resource) {
throw new Error("Resource not found", { status: 404 });
}
return resource;
});
// Create resource
export const createResource = createServerFn({ method: "POST" })
.validator(createResourceSchema)
.handler(async ({ data, context }) => {
const authUser = await getAuthUser(context);
if (!authUser) {
throw new Error("Unauthorized", { status: 401 });
}
// TODO: Check for duplicates if needed
// Create resource (tenant_id from auth context)
const [resource] = await db
.insert(resourcesTable)
.values({
...data,
tenantId: authUser.tenantId,
})
.returning();
return resource;
});
// Update resource
export const updateResource = createServerFn({ method: "PUT" })
.validator(
z.object({
resourceId: z.string(),
data: updateResourceSchema,
})
)
.handler(async ({ data, context }) => {
const authUser = await getAuthUser(context);
if (!authUser) {
throw new Error("Unauthorized", { status: 401 });
}
// Verify resource exists and belongs to tenant
const [existing] = await db
.select()
.from(resourcesTable)
.where(
and(
eq(resourcesTable.id, data.resourceId),
eq(resourcesTable.tenantId, authUser.tenantId)
)
)
.limit(1);
if (!existing) {
throw new Error("Resource not found", { status: 404 });
}
// Update resource
const [resource] = await db
.update(resourcesTable)
.set({
...data.data,
updatedAt: new Date(),
})
.where(eq(resourcesTable.id, data.resourceId))
.returning();
return resource;
});
// Delete resource
export const deleteResource = createServerFn({ method: "DELETE" })
.validator(z.object({ resourceId: z.string() }))
.handler(async ({ data, context }) => {
const authUser = await getAuthUser(context);
if (!authUser) {
throw new Error("Unauthorized", { status: 401 });
}
// Soft delete by setting deletedAt
const result = await db
.update(resourcesTable)
.set({ deletedAt: new Date() })
.where(
and(
eq(resourcesTable.id, data.resourceId),
eq(resourcesTable.tenantId, authUser.tenantId)
)
)
.returning();
if (result.length === 0) {
throw new Error("Resource not found", { status: 404 });
}
return { success: true };
});