A lightweight web framework for building APIs in Python
Project description
Crimsy
A lightweight web framework for building APIs in Python, built on top of Starlette with msgspec for fast JSON encoding/decoding.
Features
- 🚀 Fast: Uses
msgspecfor ultra-fast JSON encoding/decoding - 🪶 Lightweight: Minimal dependencies (only
starletteandmsgspec) - 🔒 Fully Typed: Complete type hints for better IDE support
- 📚 Auto Documentation: Automatic OpenAPI schema generation and Swagger UI
- 🎯 Familiar API: Similar interface to FastAPI for easy adoption
- ⚡ All HTTP Methods: Support for GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
- 💉 Dependency Injection: Built-in support for dependency injection
- 🛡️ Exception Handling: Built-in HTTP exception handling
Table of Contents
- Installation
- Quick Start
- Core Concepts
- msgspec Integration
- Dependency Injection
- Exception Handling
- OpenAPI Documentation
- Complete Examples
- Development
Installation
pip install crimsy # (when published)
# or for development:
uv sync
Quick Start
import msgspec
from crimsy import Crimsy, Router
class User(msgspec.Struct):
name: str
age: int = 0
app = Crimsy()
router = Router(prefix="/users")
@router.get("/")
async def list_users() -> list[User]:
return [User(name="Alice", age=30), User(name="Bob", age=25)]
@router.post("/")
async def create_user(user: User) -> User:
# Your code here
return user
app.add_router(router)
Run with:
uvicorn app:app --reload
Visit http://localhost:8000/docs for automatic API documentation.
Core Concepts
Application
The Crimsy class is the main application class, wrapping Starlette:
from crimsy import Crimsy
app = Crimsy(
title="My API", # API title for OpenAPI
version="1.0.0", # API version
openapi_url="/openapi.json", # OpenAPI schema URL (or None to disable)
docs_url="/docs", # Swagger UI URL (or None to disable)
debug=False, # Debug mode
middleware=None, # List of Starlette middleware
)
Routers
Routers group related endpoints under a common prefix:
from crimsy import Router
router = Router(prefix="/api/v1")
@router.get("/items")
async def list_items() -> list[dict]:
return [{"id": 1, "name": "Item 1"}]
@router.post("/items")
async def create_item(item: dict) -> dict:
return item
app.add_router(router)
Supported HTTP Methods
All standard HTTP methods are supported:
@router.get("/resource") # GET
@router.post("/resource") # POST
@router.put("/resource") # PUT
@router.delete("/resource") # DELETE
@router.patch("/resource") # PATCH
@router.head("/resource") # HEAD
@router.options("/resource") # OPTIONS
async def handler() -> dict:
return {}
Request Parameters
Crimsy provides three ways to extract parameters from requests:
1. Query Parameters
Extract parameters from the URL query string:
from crimsy import Query
@router.get("/search")
async def search(
q: str, # Required query parameter
limit: int = 10, # Optional with default
offset: int = Query(default=0) # Explicit Query marker with default
) -> dict:
return {"query": q, "limit": limit, "offset": offset}
# Usage: GET /search?q=python&limit=20&offset=5
2. Path Parameters
Extract parameters from the URL path:
from crimsy import Path
@router.get("/users/{user_id}")
async def get_user(
user_id: int = Path() # Path parameter
) -> dict:
return {"user_id": user_id}
# Usage: GET /users/123
Path parameters can also be declared without the Path() marker - Crimsy automatically detects them:
@router.get("/items/{item_id}")
async def get_item(item_id: int) -> dict:
return {"item_id": item_id}
3. Body Parameters
Extract data from the request body:
from crimsy import Body
import msgspec
class CreateUserRequest(msgspec.Struct):
name: str
email: str
age: int = 0
@router.post("/users")
async def create_user(
user: CreateUserRequest = Body() # Body parameter
) -> CreateUserRequest:
# user is automatically validated and deserialized
return user
# Usage: POST /users with JSON body: {"name": "Alice", "email": "alice@example.com", "age": 30}
For POST/PUT/PATCH requests with msgspec.Struct, the Body() marker is optional:
@router.post("/users")
async def create_user(user: CreateUserRequest) -> CreateUserRequest:
# Automatically treated as body parameter
return user
Mixing Parameter Types
You can mix different parameter types in the same handler:
@router.put("/users/{user_id}")
async def update_user(
user_id: int = Path(), # From URL path
version: str = Query(default="v1"), # From query string
user: CreateUserRequest = Body() # From request body
) -> dict:
return {"user_id": user_id, "version": version, "user": user}
# Usage: PUT /users/123?version=v2 with JSON body
Response Handling
Crimsy automatically encodes responses using msgspec:
@router.get("/user")
async def get_user() -> User:
return User(name="Alice", age=30)
# Returns: {"name": "Alice", "age": 30}
@router.get("/users")
async def get_users() -> list[User]:
return [User(name="Alice", age=30), User(name="Bob", age=25)]
# Returns: [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]
@router.get("/data")
async def get_data() -> dict:
return {"key": "value"}
# Returns: {"key": "value"}
@router.delete("/users/{user_id}")
async def delete_user(user_id: int) -> None:
# None returns 204 No Content
pass
You can also return Starlette Response objects directly:
from starlette.responses import Response
@router.get("/custom")
async def custom_response() -> Response:
return Response(content="Custom response", media_type="text/plain")
msgspec Integration
Crimsy is tightly integrated with msgspec for fast JSON encoding/decoding.
Defining Data Models
Use msgspec.Struct to define your data models:
import msgspec
class User(msgspec.Struct):
name: str
email: str
age: int = 0 # Optional field with default
is_active: bool = True
class Post(msgspec.Struct):
title: str
content: str
author: User # Nested structures
tags: list[str] = [] # Lists with defaults
Automatic Validation
msgspec automatically validates incoming data:
@router.post("/users")
async def create_user(user: User) -> User:
# If request body doesn't match User structure,
# automatic 400 Bad Request response is returned
return user
# Valid: {"name": "Alice", "email": "alice@example.com"}
# Invalid: {"name": "Alice"} -> 400: missing required field 'email'
# Invalid: {"name": "Alice", "email": "alice@example.com", "age": "thirty"} -> 400: invalid type
msgspec.Struct in GET Requests
For GET requests, msgspec.Struct parameters are treated as JSON-encoded query parameters:
@router.get("/greet")
async def greet(user: User, greeting: str = "Hello") -> dict:
return {"message": f"{greeting}, {user.name}!"}
# Usage: GET /greet?user={"name":"Alice","email":"alice@example.com","age":30}&greeting=Hi
# URL-encoded: GET /greet?user=%7B%22name%22%3A%22Alice%22%2C%22email%22%3A%22alice%40example.com%22%2C%22age%22%3A30%7D&greeting=Hi
Note: This is a non-standard but intentional feature allowing complex types in GET requests. For production APIs with complex data structures, consider using POST requests instead, which are more conventional and avoid issues with URL length limits and caching.
Dependency Injection
Crimsy includes a built-in dependency injection system similar to FastAPI's.
Basic Dependencies
Use Depends() to inject dependencies:
from crimsy import Depends
async def get_database() -> Database:
"""Dependency that returns a database connection."""
return Database()
@router.get("/users")
async def list_users(db: Database = Depends(get_database)) -> list[User]:
# db is automatically injected
return db.get_all_users()
Dependencies with Parameters
Dependencies can have their own parameters:
async def get_current_user(token: str = Query()) -> User:
"""Dependency that extracts current user from token."""
if not token:
raise HTTPException(status_code=401, message="Missing token")
# Validate token and return user
return validate_token(token)
@router.get("/me")
async def get_me(current_user: User = Depends(get_current_user)) -> User:
return current_user
# Usage: GET /me?token=abc123
Nested Dependencies
Dependencies can depend on other dependencies:
async def get_db() -> Database:
return Database()
async def get_user_repository(db: Database = Depends(get_db)) -> UserRepository:
return UserRepository(db)
@router.get("/users/{user_id}")
async def get_user(
user_id: int,
repo: UserRepository = Depends(get_user_repository)
) -> User:
return repo.get_by_id(user_id)
Dependency Caching
By default, dependencies are cached within a single request:
async def get_db() -> Database:
print("Creating database connection")
return Database()
@router.get("/data")
async def get_data(
db1: Database = Depends(get_db),
db2: Database = Depends(get_db)
) -> dict:
# "Creating database connection" is printed only once
# db1 and db2 are the same instance
return {"same": db1 is db2} # Returns: {"same": true}
To disable caching:
@router.get("/data")
async def get_data(
db: Database = Depends(get_db, use_cache=False)
) -> dict:
# Fresh instance each time
return {}
Exception Handling
HTTPException
Use HTTPException to return HTTP error responses:
from crimsy import HTTPException
@router.get("/users/{user_id}")
async def get_user(user_id: int) -> User:
user = database.get(user_id)
if user is None:
raise HTTPException(status_code=404, message="User not found")
return user
# Returns: 404 with body: {"error": "User not found"}
HTTPException can be raised anywhere - in endpoints, dependencies, or nested function calls.
Exception in Dependencies
async def verify_admin(token: str = Query()) -> User:
user = verify_token(token)
if not user.is_admin:
raise HTTPException(status_code=403, message="Admin access required")
return user
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
admin: User = Depends(verify_admin)
) -> None:
database.delete(user_id)
Custom Exception Handlers
Register custom exception handlers:
class DatabaseException(Exception):
pass
app = Crimsy()
@app.exception_handler(DatabaseException)
async def database_exception_handler(request, exc):
raise HTTPException(status_code=503, message="Database unavailable")
@router.get("/data")
async def get_data() -> dict:
raise DatabaseException() # Returns 503
Automatic Error Responses
Crimsy automatically handles common errors:
- 400 Bad Request: Missing required parameters, invalid types, or validation errors
- 500 Internal Server Error: Unhandled exceptions
@router.get("/search")
async def search(q: str) -> dict: # q is required
return {"query": q}
# GET /search -> 400: {"error": "Missing required query parameter: q"}
# GET /search?q=test -> 200: {"query": "test"}
OpenAPI Documentation
Crimsy automatically generates OpenAPI 3.0 documentation for your API.
Accessing Documentation
- OpenAPI JSON:
http://localhost:8000/openapi.json - Swagger UI:
http://localhost:8000/docs
Customizing Documentation
app = Crimsy(
title="My Awesome API",
version="2.1.0",
openapi_url="/api/schema.json", # Custom OpenAPI URL
docs_url="/api/docs", # Custom Swagger UI URL
)
# Or disable documentation:
app = Crimsy(
title="My API",
openapi_url=None, # Disables OpenAPI schema endpoint
docs_url=None, # Disables Swagger UI
)
Type Annotations in Documentation
Crimsy uses Python type annotations to generate accurate API documentation:
@router.get("/users/{user_id}")
async def get_user(
user_id: int = Path(),
include_posts: bool = Query(default=False)
) -> User:
"""Get a user by ID.
Optional: include their posts in the response.
"""
return user
# OpenAPI schema will include:
# - Path parameter: user_id (integer, required)
# - Query parameter: include_posts (boolean, optional, default: false)
# - Response schema: User object structure
# - Endpoint description from docstring
Complete Examples
Example 1: Simple CRUD API
import msgspec
from crimsy import Crimsy, Router, HTTPException, Path, Query, Body
class User(msgspec.Struct):
id: int
name: str
email: str
age: int = 0
# In-memory database
users_db: dict[int, User] = {
1: User(id=1, name="Alice", email="alice@example.com", age=30),
2: User(id=2, name="Bob", email="bob@example.com", age=25),
}
next_id = 3
app = Crimsy(title="User Management API", version="1.0.0")
router = Router(prefix="/users")
@router.get("/")
async def list_users(limit: int = Query(default=10)) -> list[User]:
"""List all users."""
return list(users_db.values())[:limit]
@router.get("/{user_id}")
async def get_user(user_id: int = Path()) -> User:
"""Get a specific user by ID."""
if user_id not in users_db:
raise HTTPException(status_code=404, message="User not found")
return users_db[user_id]
@router.post("/")
async def create_user(user: User) -> User:
"""Create a new user."""
global next_id
user_with_id = User(id=next_id, name=user.name, email=user.email, age=user.age)
users_db[next_id] = user_with_id
next_id += 1
return user_with_id
@router.put("/{user_id}")
async def update_user(user_id: int = Path(), user: User = Body()) -> User:
"""Update an existing user."""
if user_id not in users_db:
raise HTTPException(status_code=404, message="User not found")
users_db[user_id] = user
return user
@router.delete("/{user_id}")
async def delete_user(user_id: int = Path()) -> None:
"""Delete a user."""
if user_id not in users_db:
raise HTTPException(status_code=404, message="User not found")
del users_db[user_id]
app.add_router(router)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
Example 2: API with Dependency Injection
import msgspec
from crimsy import Crimsy, Router, Depends, HTTPException, Query
class Database:
"""Mock database."""
def __init__(self):
self.users = {"alice": "Alice Smith", "bob": "Bob Jones"}
def get_user(self, username: str) -> str | None:
return self.users.get(username)
class User(msgspec.Struct):
username: str
full_name: str
# Dependencies
async def get_db() -> Database:
"""Provide database connection."""
return Database()
async def get_current_user(
token: str = Query(),
db: Database = Depends(get_db)
) -> User:
"""Extract current user from token."""
if token not in db.users:
raise HTTPException(status_code=401, message="Invalid token")
return User(username=token, full_name=db.users[token])
# Application
app = Crimsy(title="Auth API", version="1.0.0")
router = Router(prefix="/api")
@router.get("/me")
async def get_me(current_user: User = Depends(get_current_user)) -> User:
"""Get current authenticated user."""
return current_user
@router.get("/protected")
async def protected_resource(
current_user: User = Depends(get_current_user),
db: Database = Depends(get_db)
) -> dict:
"""Access a protected resource."""
return {
"message": f"Hello, {current_user.full_name}!",
"data": "Secret information"
}
app.add_router(router)
# Usage:
# GET /api/me?token=alice -> {"username": "alice", "full_name": "Alice Smith"}
# GET /api/me?token=invalid -> 401: {"error": "Invalid token"}
Example 3: Complex Nested Structures
import msgspec
from crimsy import Crimsy, Router
class Address(msgspec.Struct):
street: str
city: str
country: str
postal_code: str = ""
class Company(msgspec.Struct):
name: str
address: Address
class Employee(msgspec.Struct):
id: int
name: str
email: str
company: Company
skills: list[str] = []
app = Crimsy(title="Employee API", version="1.0.0")
router = Router(prefix="/employees")
@router.post("/")
async def create_employee(employee: Employee) -> Employee:
"""Create a new employee with nested company and address."""
# employee is fully validated including nested structures
return employee
@router.get("/{employee_id}")
async def get_employee(employee_id: int) -> Employee:
"""Get employee with all nested data."""
return Employee(
id=employee_id,
name="Alice Smith",
email="alice@example.com",
company=Company(
name="Tech Corp",
address=Address(
street="123 Main St",
city="San Francisco",
country="USA",
postal_code="94105"
)
),
skills=["Python", "JavaScript", "SQL"]
)
app.add_router(router)
# POST /employees/ with:
# {
# "id": 1,
# "name": "Alice Smith",
# "email": "alice@example.com",
# "company": {
# "name": "Tech Corp",
# "address": {
# "street": "123 Main St",
# "city": "San Francisco",
# "country": "USA",
# "postal_code": "94105"
# }
# },
# "skills": ["Python", "JavaScript", "SQL"]
# }
Development
Setting Up Development Environment
# Clone the repository
git clone https://github.com/xelandernt/crimsy.git
cd crimsy
# Install dependencies with uv
uv sync
# Or install with pip
pip install -e ".[dev]"
Running Tests
# Run all tests
just test
# Run with coverage
uv run pytest --cov=src/crimsy --cov-report=term-missing
# Run specific test file
uv run pytest tests/unit/test_router.py
Linting and Type Checking
# Run all linters
just lint
# Or run individually
uv run ruff format # Format code
uv run ruff check --fix # Check and fix issues
uv run mypy . # Type checking
Project Structure
crimsy/
├── src/crimsy/
│ ├── __init__.py # Public API exports
│ ├── app.py # Main Crimsy application class
│ ├── router.py # Router for grouping endpoints
│ ├── params.py # Parameter extraction (Query, Body, Path)
│ ├── dependencies.py # Dependency injection system
│ ├── exceptions.py # HTTPException and error handling
│ └── openapi.py # OpenAPI schema generation
├── tests/ # Test suite
├── examples/ # Example applications
└── README.md # This file
License
See LICENSE file.
Additional Resources
For more examples, see README_EXAMPLES.md.
Contributing
Contributions are welcome! Please ensure:
- All tests pass:
just test - Code is properly formatted:
just lint - Type hints are correct:
mypy .
Why Crimsy?
- Performance:
msgspecis one of the fastest JSON libraries for Python - Simplicity: Minimal API surface, easy to learn
- Type Safety: Full type hints help catch errors before runtime
- Familiarity: Similar to FastAPI but lighter weight
- Modern: Built on modern Python features (3.11+)
Crimsy - Fast, lightweight, and fully typed web framework for building APIs in Python.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file crimsy-0.1.13.tar.gz.
File metadata
- Download URL: crimsy-0.1.13.tar.gz
- Upload date:
- Size: 50.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.2 {"installer":{"name":"uv","version":"0.10.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
57c94814671f034fa8e6216f244b32fd8d0d7095ae4d4d1520b467a047570832
|
|
| MD5 |
c81fd18f2c06aadaa7c6f02d20e1a023
|
|
| BLAKE2b-256 |
d105d4491238a13cf6741d963191bb2603c2c1e0c621ff83fea41e87bb57ea0b
|
File details
Details for the file crimsy-0.1.13-py3-none-any.whl.
File metadata
- Download URL: crimsy-0.1.13-py3-none-any.whl
- Upload date:
- Size: 23.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.2 {"installer":{"name":"uv","version":"0.10.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
14dcffd0eea8ab0fee650891d6966bd0ce35faa238066bd9729ad76540f7b226
|
|
| MD5 |
e9b3d114c06aa15f84e1951a7d6c9ba0
|
|
| BLAKE2b-256 |
15728be8248c55702aedba44cf6a8002e46832bea4334d8fbfbfea0e73da8858
|