Initial commit
This commit is contained in:
288
skills/api-design-standards/SKILL.md
Normal file
288
skills/api-design-standards/SKILL.md
Normal 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
|
||||
118
skills/api-design-standards/checklists/api-design-checklist.md
Normal file
118
skills/api-design-standards/checklists/api-design-checklist.md
Normal 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
|
||||
84
skills/api-design-standards/checklists/security-review.md
Normal file
84
skills/api-design-standards/checklists/security-review.md
Normal 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
|
||||
17
skills/api-design-standards/examples/INDEX.md
Normal file
17
skills/api-design-standards/examples/INDEX.md
Normal 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
|
||||
332
skills/api-design-standards/examples/fastapi-crud.md
Normal file
332
skills/api-design-standards/examples/fastapi-crud.md
Normal 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
|
||||
38
skills/api-design-standards/examples/pagination.md
Normal file
38
skills/api-design-standards/examples/pagination.md
Normal 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
|
||||
149
skills/api-design-standards/examples/pydantic-schemas.md
Normal file
149
skills/api-design-standards/examples/pydantic-schemas.md
Normal 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
|
||||
71
skills/api-design-standards/examples/tanstack-start.md
Normal file
71
skills/api-design-standards/examples/tanstack-start.md
Normal 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
|
||||
22
skills/api-design-standards/examples/testing.md
Normal file
22
skills/api-design-standards/examples/testing.md
Normal 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`
|
||||
11
skills/api-design-standards/reference/INDEX.md
Normal file
11
skills/api-design-standards/reference/INDEX.md
Normal 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
|
||||
24
skills/api-design-standards/reference/authentication.md
Normal file
24
skills/api-design-standards/reference/authentication.md
Normal 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.
|
||||
26
skills/api-design-standards/reference/cors-rate-limiting.md
Normal file
26
skills/api-design-standards/reference/cors-rate-limiting.md
Normal 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.
|
||||
17
skills/api-design-standards/reference/error-handlers.md
Normal file
17
skills/api-design-standards/reference/error-handlers.md
Normal 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)
|
||||
```
|
||||
34
skills/api-design-standards/reference/fastapi-setup.md
Normal file
34
skills/api-design-standards/reference/fastapi-setup.md
Normal 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`
|
||||
17
skills/api-design-standards/reference/openapi.md
Normal file
17
skills/api-design-standards/reference/openapi.md
Normal 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).
|
||||
115
skills/api-design-standards/templates/error-handler.py
Normal file
115
skills/api-design-standards/templates/error-handler.py
Normal 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)
|
||||
130
skills/api-design-standards/templates/fastapi-crud-endpoint.py
Normal file
130
skills/api-design-standards/templates/fastapi-crud-endpoint.py
Normal 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",
|
||||
)
|
||||
61
skills/api-design-standards/templates/pydantic-schemas.py
Normal file
61
skills/api-design-standards/templates/pydantic-schemas.py
Normal 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
|
||||
82
skills/api-design-standards/templates/rate-limiter.py
Normal file
82
skills/api-design-standards/templates/rate-limiter.py
Normal 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
|
||||
101
skills/api-design-standards/templates/repository-pattern.py
Normal file
101
skills/api-design-standards/templates/repository-pattern.py
Normal 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
|
||||
@@ -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 };
|
||||
});
|
||||
Reference in New Issue
Block a user