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,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 };
});