Imported from anaswaleedtahir/bookapp (
AGENTS.md). Install upstream withnpx skills add anaswaleedtahir/bookapp. Copyright stays with the author.
AGENTS.md — Bookapp
Guidance for AI coding agents (Cline, Windsurf, etc.) working in this codebase. Read this before making any changes.
Project Overview
A learning-focused Litestar + Piccolo ORM application implementing a books & authors management system with JWT authentication and role + ownership-based authorization.
Primary goal: Code quality and conceptual correctness over speed. Explain tradeoffs when making non-obvious decisions.
Stack
| Component | Library | Notes |
|---|---|---|
| Framework | Litestar 2.19+ | Use built-in guards, not custom middleware for auth |
| ORM | Piccolo ORM 1.30+ | Explicit relationship loading only — no lazy loading |
| Serialization | msgspec | Not Pydantic — use msgspec.Struct for schemas |
| Password hashing | argon2-cffi | Never bcrypt or plain hashlib |
| JWT | PyJWT | HS256 for this project |
| Logging | structlog | JSON output, always include request_id |
| Package manager | uv | Never pip directly |
| Linter/Formatter | ruff | Rules: E, F, I, S, UP |
| Test runner | pytest + anyio | Async tests only via anyio mode |
| Application server | granian | Development and production ASGI server |
| Response serialization | msgspec | Use msgspec_json_response_handler for automatic Struct handling |
Project Structure
bookapp/
├── main.py # Litestar app factory (no models, no handlers)
├── pyproject.toml
├── piccolo_conf.py # Piccolo engine configuration (project root)
├── justfile
├── .env.dev
├── .env.test
│
├── core/
│ ├── __init__.py
│ ├── settings.py # All config via environs
│ ├── exceptions.py # Custom exception classes + handlers
│ └── plugins.py # Plugin registration (piccolo, structlog, otel)
│
├── api/
│ ├── __init__.py
│ └── v1/
│ ├── __init__.py
│ ├── router.py # Combines all v1 controllers
│ ├── auth.py # Auth endpoints (register, login, refresh)
│ ├── users.py # UserController
│ ├── books.py # BookController
│ └── authors.py # AuthorController
│
├── apps/
│ ├── users/
│ │ ├── model.py # User piccolo Table
│ │ ├── service.py # UserService
│ │ └── schemas.py # msgspec.Struct for request/response
│ ├── books/
│ │ ├── model.py # Book piccolo Table
│ │ ├── service.py # BookService
│ │ └── schemas.py
│ ├── authors/
│ │ ├── model.py # Author piccolo Table
│ │ ├── service.py # AuthorService
│ │ └── schemas.py
│ └── auth/
│ ├── model.py # Role, Permission, UserRole
│ ├── service.py # AuthService (login, register, tokens)
│ ├── guards.py # Guard callables for ACL
│ └── schemas.py
│
├── piccolo_migrations/ # Generated by piccolo
│ └── ...
│
└── tests/
├── conftest.py # App factory, DB setup, polyfactory fixtures
├── test_auth.py
├── test_books.py
└── test_authors.py
Architecture Rules
Controllers
- Controllers are thin: validate input, call service, return response
- No business logic in controllers
- No direct DB queries in controllers
- No authorization logic in controllers (use guards instead)
Services
- All business logic lives in
service.pyper domain - Services receive typed inputs (msgspec Structs or primitives)
- Services raise domain exceptions — never HTTP exceptions
- Services should not handle authorization (use guards instead)
Guards
- Authorization via Litestar
Guard— applied at controller class level - Two guard factories live in
auth/guards.py:require_role(*roles)— role-based checkis_owner_or_role(resource_fn, *bypass_roles)— ownership check
- Guards must return
Noneto allow or raiseNotAuthorizedException
Exceptions
- Define custom exception classes in
app/exceptions.py - Register Litestar exception handlers in
app/exceptions.py - Never raise
HTTPExceptionfrom a service — only from handlers - Services should raise domain exceptions — handlers convert to HTTP exceptions
Schemas
- One
schemas.pyper domain — no separatedtos/directory - Use
msgspec.Structfor all request/response schemas - Never expose password fields in any response schema
- Name consistently:
BookCreate,BookUpdate,BookResponse
Database
- Piccolo relationship loading is explicit — always use
.output()orprefetchdeliberately. Never assume related objects are loaded. - Soft delete pattern: set
deleted_at = datetime.now(UTC)— neverDELETErows from books or authors tables - All queries must filter
deleted_at IS NULLunless explicitly fetching deleted records - Migrations via Piccolo CLI only — never
create_db_tables()at startup
Authorization Model
Role | Own Books/Authors | All Books/Authors | Admin Actions
---------|-------------------|-------------------|---------------
admin | CRUD | CRUD | Yes
editor | CRUD | Read | No
viewer | Read | Read | No
Ownership rule: A resource is "owned" by the user whose created_by
FK matches the current user's ID.
Implementation: Guards live in auth/guards.py, applied at controller
level. The guard reads request.user (set by Litestar's JWT middleware)
and the resource from DB if needed.
What Agents Must NOT Do
- Do not use
create_db_tables()at app startup - Do not use lazy loading — all Piccolo relationships must be explicitly loaded
- Do not import Pydantic — this project uses msgspec
- Do not use
print()— usestructlog.get_logger() - Do not hardcode secrets or connection strings
- Do not raise
HTTPExceptionfrom service layer - Do not write synchronous DB calls — all Piccolo queries must be awaited
- Do not skip the
deleted_at IS NULLfilter on user-facing queries - Do not store plaintext passwords or use
hashlibdirectly for passwords - Do not write tests without assertions
- Do not add new dependencies without checking
pyproject.tomlfirst
What Agents Should Always Do
- Run
ruff check . --fixafter any code change - Add or update tests when modifying service logic
- Include
request_idin all log entries - Use timezone-aware datetimes:
datetime.now(UTC)notdatetime.utcnow() - Validate environment variables at startup — fail loudly if missing
- Return structured error bodies, not just HTTP status codes
- Keep controllers thin — push logic down to services
- Use guards for authorization, not in services
- Use msgspec for serialization (request and response), not Pydantic
- Use granian as the ASGI server
Testing Requirements
- All tests are async — use
@pytest.mark.anyio - Test DB is separate from dev DB — configured via
TEST_DATABASE_URLenv var - Use
polyfactoryfactories for test data — no hardcoded fixtures - Each role boundary must have at least one dedicated test
- Auth tests must cover: happy path, invalid credentials, expired token, revoked token
- Coverage target: 80%+ on
services/,guards/,routers/
Common Patterns
Guard factory (reference, do not modify)
# auth/guards.py
from litestar.connection import ASGIConnection
from litestar.handlers import BaseRouteHandler
from litestar.exceptions import NotAuthorizedException
def require_role(*roles: str):
async def guard(connection: ASGIConnection, _: BaseRouteHandler) -> None:
user = connection.user
if not user or user.role not in roles:
raise NotAuthorizedException("Insufficient role")
return guard
Soft delete query pattern
# Always filter deleted records in user-facing queries
await Book.select().where(Book.deleted_at.is_null()).output(load_json=True)
Structured logging pattern
import structlog
logger = structlog.get_logger()
# In service methods:
logger.info("book.created", book_id=str(book.id), user_id=str(user.id))
Commit Style
feat(books): add soft delete to BookController
fix(auth): correct refresh token expiry check
test(auth): add revoked token rejection test
refactor(authors): move ownership check to service layer
Format: type(domain): description
Types: feat, fix, test, refactor, chore, docs
Key Learning Questions
When an agent is about to make a significant architectural decision, surface the tradeoff explicitly in a comment or in the response:
- Why this approach over the alternative?
- What breaks if the input is malformed or the DB is unavailable?
- What's the performance implication at 10x current load?
This project is a learning vehicle. Correct and explainable beats clever and opaque.