SerpentStack scaffold
Generate boilerplate for new API endpoints and frontend pages following this project's conventions (flush/commit transaction pattern, ownership enforcement, domain returns). Use when: adding a new resource, creating CRUD endpoints, wiring up a new frontend page, or asking 'how do I add a new feature end-to-end.'
git clone https://github.com/Benja-Pauls/SerpentStack
T=$(mktemp -d) && git clone --depth=1 https://github.com/Benja-Pauls/SerpentStack "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.skills/scaffold" ~/.claude/skills/benja-pauls-serpentstack-scaffold && rm -rf "$T"
.skills/scaffold/SKILL.mdScaffold
Generate boilerplate for new API endpoints and frontend pages in SerpentStack.
Adding a New API Endpoint
Given a resource name (e.g.,
projects), create the following files in order.
1. SQLAlchemy Model -- backend/app/models/{name}.py
backend/app/models/{name}.pyInherit from
Base which provides id (UUID), created_at, and updated_at automatically.
import uuid from sqlalchemy import Boolean, ForeignKey, String, Text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base class {Name}(Base): __tablename__ = "{name}s" title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True) # Owner — see Item model for the reference pattern user_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True, )
Register the model by adding to
backend/app/models/__init__.py:
from app.models.{name} import {Name}
2. Pydantic Schemas -- backend/app/schemas/{name}.py
backend/app/schemas/{name}.pyfrom datetime import datetime from uuid import UUID from pydantic import BaseModel class {Name}Base(BaseModel): title: str description: str | None = None class {Name}Create({Name}Base): pass class {Name}Update(BaseModel): title: str | None = None description: str | None = None class {Name}Response({Name}Base): id: UUID is_active: bool user_id: UUID | None created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class {Name}ListResponse(BaseModel): {name}s: list[{Name}Response] total: int skip: int limit: int
3. Service Layer -- backend/app/services/{name}.py
backend/app/services/{name}.pyUses async SQLAlchemy sessions. Services return
None or domain values — they never raise HTTPException. Services flush but do not commit — the route layer owns the transaction boundary. This allows multiple service calls to be composed in a single transaction.
from uuid import UUID from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.models.{name} import {Name} from app.schemas.{name} import {Name}Create, {Name}Update class {Name}Service: def __init__(self, db: AsyncSession) -> None: self.db = db async def create(self, data: {Name}Create, *, user_id: UUID | None = None) -> {Name}: item = {Name}(**data.model_dump(), user_id=user_id) self.db.add(item) await self.db.flush() await self.db.refresh(item) return item async def get(self, {name}_id: UUID) -> {Name} | None: stmt = select({Name}).where({Name}.id == {name}_id) result = await self.db.execute(stmt) return result.scalar_one_or_none() async def list(self, skip: int = 0, limit: int = 100) -> tuple[list[{Name}], int]: total = await self.db.scalar(select(func.count()).select_from({Name})) result = await self.db.execute(select({Name}).offset(skip).limit(limit)) return list(result.scalars().all()), total or 0 async def update(self, {name}_id: UUID, data: {Name}Update, *, user_id: UUID) -> object: """Update with ownership check. Returns: {Name} — updated successfully None — not found False — exists but owned by a different user """ item = await self.get({name}_id) if item is None: return None if item.user_id is not None and item.user_id != user_id: return False for key, value in data.model_dump(exclude_unset=True).items(): setattr(item, key, value) await self.db.flush() await self.db.refresh(item) return item async def delete(self, {name}_id: UUID, *, user_id: UUID) -> bool | None: """Delete with ownership check. Returns: True — deleted successfully None — not found False — exists but owned by a different user """ item = await self.get({name}_id) if item is None: return None if item.user_id is not None and item.user_id != user_id: return False await self.db.delete(item) await self.db.flush() return True
4. Router -- backend/app/routes/{name}.py
backend/app/routes/{name}.pyRoute handlers use
async def and translate service results (None → 404, bool → 204/404) into HTTP responses. Services never raise HTTPException. Routes own the transaction — they call await db.commit() after successful mutations. Depends(get_db) is cached per-request, so the route's db and the service's self.db are the same session.
from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.models.base import get_db from app.routes.auth import UserInfo, get_current_user, get_optional_user from app.schemas.{name} import {Name}Create, {Name}Update, {Name}Response, {Name}ListResponse from app.services.{name} import {Name}Service router = APIRouter(prefix="/{name}s", tags=["{name}s"]) async def get_service(db: AsyncSession = Depends(get_db)) -> {Name}Service: return {Name}Service(db) @router.post("", response_model={Name}Response, status_code=201) async def create( data: {Name}Create, user: UserInfo | None = Depends(get_optional_user), db: AsyncSession = Depends(get_db), service: {Name}Service = Depends(get_service), ): user_id = UUID(user.user_id) if user else None item = await service.create(data, user_id=user_id) await db.commit() return item @router.get("", response_model={Name}ListResponse) async def list_all(skip: int = 0, limit: int = 100, service: {Name}Service = Depends(get_service)): items, total = await service.list(skip, limit) return {Name}ListResponse({name}s=items, total=total, skip=skip, limit=limit) @router.get("/{{{name}_id}}", response_model={Name}Response) async def read({name}_id: UUID, service: {Name}Service = Depends(get_service)): item = await service.get({name}_id) if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="{Name} not found") return item @router.put("/{{{name}_id}}", response_model={Name}Response) async def update( {name}_id: UUID, data: {Name}Update, user: UserInfo = Depends(get_current_user), db: AsyncSession = Depends(get_db), service: {Name}Service = Depends(get_service), ): result = await service.update({name}_id, data, user_id=UUID(user.user_id)) if result is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="{Name} not found") if result is False: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only update your own {name}s") await db.commit() return result @router.delete("/{{{name}_id}}", status_code=204) async def delete( {name}_id: UUID, user: UserInfo = Depends(get_current_user), db: AsyncSession = Depends(get_db), service: {Name}Service = Depends(get_service), ): result = await service.delete({name}_id, user_id=UUID(user.user_id)) if result is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="{Name} not found") if result is False: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only delete your own {name}s") await db.commit()
5. Register the Router
In
backend/app/main.py, inside create_app(), add:
from app.routes.{name} import router as {name}_router app.include_router({name}_router, prefix="/api/v1")
6. Generate Migration
cd backend && uv run alembic revision --autogenerate -m "add {name}s table" cd backend && uv run alembic upgrade head
7. Add Tests -- backend/tests/test_{name}.py
backend/tests/test_{name}.pyTests use a real PostgreSQL container via testcontainers (see
conftest.py) and async httpx AsyncClient. The @pytest.mark.asyncio decorator is not needed — asyncio_mode = "auto" is set in pyproject.toml.
from httpx import AsyncClient async def test_create_{name}(client: AsyncClient): response = await client.post("/api/v1/{name}s", json={{"title": "Test {Name}"}}) assert response.status_code == 201 data = response.json() assert data["title"] == "Test {Name}" assert "id" in data async def test_list_{name}s(client: AsyncClient): response = await client.get("/api/v1/{name}s") assert response.status_code == 200 data = response.json() assert "{name}s" in data assert "total" in data async def test_get_{name}_not_found(client: AsyncClient): response = await client.get("/api/v1/{name}s/00000000-0000-0000-0000-000000000000") assert response.status_code == 404
8. Auto-generate Frontend Types
After adding or changing backend schemas, regenerate the frontend TypeScript types:
make types
This runs
openapi-typescript against the running backend's OpenAPI spec to produce frontend/src/types/api.generated.ts. See Type Generation below.
Adding a New Frontend Page
Given a page name (e.g.,
Projects):
1. TypeScript Types
Types are auto-generated from the backend's OpenAPI spec (see step 8 above). Import them from the generated file:
import type {{ components }} from "../types/api.generated"; type {Name} = components["schemas"]["{Name}Response"]; type {Name}Create = components["schemas"]["{Name}Create"]; type {Name}ListResponse = components["schemas"]["{Name}ListResponse"];
If you need custom types beyond what the API provides, add them to
frontend/src/types/{name}.ts.
2. API Client -- frontend/src/api/{name}.ts
frontend/src/api/{name}.tsimport {{ apiRequest }} from "./client"; import type {{ components }} from "../types/api.generated"; type {Name} = components["schemas"]["{Name}Response"]; type {Name}Create = components["schemas"]["{Name}Create"]; type {Name}ListResponse = components["schemas"]["{Name}ListResponse"]; export const get{Name}s = (): Promise<{Name}ListResponse> => apiRequest("/{name}s"); export const get{Name} = (id: string): Promise<{Name}> => apiRequest(`/{name}s/${{id}}`); export const create{Name} = (data: {Name}Create): Promise<{Name}> => apiRequest("/{name}s", {{ method: "POST", body: JSON.stringify(data) }}); export const update{Name} = (id: string, data: Partial<{Name}>): Promise<{Name}> => apiRequest(`/{name}s/${{id}}`, {{ method: "PUT", body: JSON.stringify(data) }}); export const delete{Name} = (id: string): Promise<void> => apiRequest(`/{name}s/${{id}}`, {{ method: "DELETE" }});
3. React Query Hooks -- frontend/src/hooks/use{Name}s.ts
frontend/src/hooks/use{Name}s.tsimport {{ useQuery, useMutation, useQueryClient }} from "@tanstack/react-query"; import {{ get{Name}s, create{Name}, update{Name}, delete{Name} }} from "../api/{name}"; import type {{ components }} from "../types/api.generated"; type {Name}Create = components["schemas"]["{Name}Create"]; export function use{Name}s() {{ return useQuery({{ queryKey: ["{name}s"], queryFn: get{Name}s }}); }} export function useCreate{Name}() {{ const queryClient = useQueryClient(); return useMutation({{ mutationFn: (data: {Name}Create) => create{Name}(data), onSuccess: () => queryClient.invalidateQueries({{ queryKey: ["{name}s"] }}), }}); }} export function useUpdate{Name}() {{ const queryClient = useQueryClient(); return useMutation({{ mutationFn: ({{ id, data }}: {{ id: string; data: Partial<{Name}> }}) => update{Name}(id, data), onSuccess: () => queryClient.invalidateQueries({{ queryKey: ["{name}s"] }}), }}); }} export function useDelete{Name}() {{ const queryClient = useQueryClient(); return useMutation({{ mutationFn: (id: string) => delete{Name}(id), onSuccess: () => queryClient.invalidateQueries({{ queryKey: ["{name}s"] }}), }}); }}
4. Page Component -- frontend/src/routes/{Name}s.tsx
frontend/src/routes/{Name}s.tsxCreate a React component that uses the hooks above. Include loading and error states. Use
data?.{name}s to unwrap the list response.
5. Route Registration -- frontend/src/App.tsx
frontend/src/App.tsxAdd a
<Route path="/{name}s" element={{<{Name}s />}} /> inside the router configuration.
6. Navigation
Add a
<NavLink to="/{name}s">{Name}s</NavLink> in the sidebar or header component.
Type Generation
Frontend types are auto-generated from the backend's OpenAPI spec:
make types # Exports OpenAPI spec from app, generates TypeScript types
This exports the FastAPI OpenAPI schema without starting a server, then produces
frontend/src/types/api.generated.ts. Run this whenever you add or change backend schemas. The generated file should be committed to the repo.