A lightweight Python web framework for building APIs
Project description
🐾 Zoe
A lightweight Python web framework. Zero dependencies, type-aware injection, aggregated validation.
pip install zoe-framework
Full documentation at zoe-framework.dev
Why Zoe?
- Zero dependencies — pure Python standard library
- Type-aware injection — body, services, and request resolved automatically via type hints
- Aggregated validation — all field errors returned at once, never just the first
- Three DI lifecycles —
@Singleton,@Transient, and@Scopedout of the box - Multipart support — file uploads with typed field access, no setup required
- Rich middleware stack — Logger, CORS, Limiter, Guard, Helmet, StaticFiles, BodyLimiter
Quick Start
from zoe import App, Server, Router, Request, Response, HttpCode
router = Router(prefix="/")
@router.get("/hello")
def hello(req: Request) -> Response:
return Response.text(HttpCode.OK, text="Hello, world!")
if __name__ == "__main__":
app = App()
app.use(router)
Server(application=app).run()
Routing
Routes can be registered using decorators or the explicit router.add / Route API.
Decorator style (function-based)
from zoe import Router, Request, Response, HttpCode
router = Router(prefix="/users")
@router.get("/")
def list_users(req: Request) -> Response:
return Response.json(HttpCode.OK, body=[])
@router.post("/")
def create_user(req: Request) -> Response: ...
@router.get("/{user_id}")
def get_user(req: Request) -> Response:
user_id = req.path_params.get("user_id")
return Response.json(HttpCode.OK, body={"id": user_id})
@router.put("/{user_id}")
def update_user(req: Request) -> Response: ...
@router.patch("/{user_id}")
def patch_user(req: Request) -> Response: ...
@router.delete("/{user_id}")
def delete_user(req: Request) -> Response: ...
Decorator style (class-based)
from zoe import Handler, Request, Response, HttpCode
router = Router(prefix="/users")
@router.get("/{user_id}")
class GetUserHandler(Handler):
def handle(self, request: Request) -> Response:
user_id = request.path_params.get("user_id")
return Response.json(HttpCode.OK, body={"id": user_id})
Explicit registration
from zoe import Router, Route
router = Router(prefix="/users")
router.add(Route.get(endpoint="/{user_id}", handler=GetUserHandler()))
router.add(Route.post(endpoint="/", handler=CreateUserHandler()))
router.add(Route.delete(endpoint="/{user_id}", handler=DeleteUserHandler()))
Handlers
Every handler receives a Request and must return a Response.
Function-based
@router.get("/{user_id}")
def get_user(req: Request) -> Response:
user_id = req.path_params.get("user_id")
return Response.json(HttpCode.OK, body={"id": user_id})
The Request parameter can be named anything as long as it is typed as Request.
Class-based
class GetUserHandler(Handler):
def handle(self, request: Request) -> Response:
user_id = request.path_params.get("user_id")
return Response.json(HttpCode.OK, body={"id": user_id})
The Request parameter in class-based handlers must be named request — Zoe uses this name to distinguish it from injected services.
Request
@router.get("/example/{id}")
def example(req: Request) -> Response:
# Path params
user_id = req.path_params.get("id")
# Query params — with optional type coercion and default
page = req.query_params.get("page", type_=int, default=1)
limit = req.query_params.get("limit", type_=int, default=10)
# Headers
content_type = req.headers.get("Content-Type")
# Auth
token = req.auth.bearer_token
credentials = req.auth.basic_credentials # (username, password)
api_key = req.auth.api_key
return Response.json(HttpCode.OK, body={})
Response
# JSON — accepts dicts, lists, Model instances
Response.json(HttpCode.OK, body={"key": "value"})
# Plain text
Response.text(HttpCode.OK, text="Hello")
# HTML
Response.html(HttpCode.OK, html_content="<h1>Hello</h1>")
# Redirect
Response.redirect(HttpCode.FOUND, redirect_to="/new-path")
# File — inline or forced download
Response.file(HttpCode.OK, filename="report.pdf", directory="./files")
Response.file(HttpCode.OK, filename="data.csv", force_download=True)
Models and Validation
Extend Model and annotate fields with Field and validators. Zoe validates the request body automatically and returns all errors at once.
Basic usage
from zoe import Model, Field, NotNull, Email, Min, Max, Password, Pattern, OneOf, Assert
class CreateUserDto(Model):
name: str = Field(NotNull())
email: str = Field(NotNull(), Email())
age: int = Field(NotNull(), Min(18), Max(120))
password: str = Field(NotNull(), Password())
role: str = Field(NotNull(), OneOf("admin", "user", "guest"))
@router.post("/users")
def create_user(req: Request, body: CreateUserDto) -> Response:
return Response.json(HttpCode.CREATED, body=body.to_dict())
Optional fields and defaults
class UpdateUserDto(Model):
name: str | None = Field() # optional, no default
email: str | None = Field() # optional, no default
role: str = Field(default="user") # required type, default value
Required validator
Required() differs from NotNull() in intent: it asserts that the field must be explicitly present in the body, regardless of type.
class CreatePostDto(Model):
title: str = Field(Required())
content: str = Field(Required())
Nested models
class AddressDto(Model):
street: str = Field(NotNull())
city: str = Field(NotNull())
zip: str = Field(NotNull(), Pattern(r"^\d{5}$"))
class CreateUserDto(Model):
name: str = Field(NotNull())
email: str = Field(NotNull(), Email())
address: AddressDto = Field(NotNull())
Nested models are validated recursively. Errors from nested fields are included in the same aggregated response.
Strict mode
class StrictDto(Model):
__strict__ = True
name: str = Field(NotNull())
# extra fields in the body will return a 400 error
Validation error response
{
"error": {
"type": "SCHEMA_VALIDATION_ERROR",
"model": "CreateUserDto",
"count": 2,
"errors": [
{ "field": "email", "code": "INVALID_FORMAT", "message": "..." },
{ "field": "password", "code": "WEAK_PASSWORD", "message": "..." }
]
}
}
Available validators
| Validator | Description |
|---|---|
NotNull() |
Value must not be null |
Required() |
Field must be present in the body |
Email() |
Must be a valid email address |
Password() |
Must meet password strength requirements |
Min(n) |
Minimum numeric value or string/list length |
Max(n) |
Maximum numeric value or string/list length |
Range(min, max) |
Numeric range |
Pattern(regex) |
Must match a regex pattern |
OneOf(*values) |
Must be one of the given values |
Assert(fn, msg) |
Custom assertion function |
Dependency Injection
Register services with @Singleton, @Transient, or @Scoped. Zoe resolves them by type in handler parameters.
from zoe import Singleton, Transient, Scoped
@Singleton()
class Database:
def __init__(self):
self.conn = connect(...)
@Transient()
class EmailService:
def send(self, to: str, subject: str): ...
@router.post("/users")
def create_user(req: Request, body: CreateUserDto, db: Database, email: EmailService) -> Response:
user = db.create(body.name, body.email)
email.send(user.email, "Welcome")
return Response.json(HttpCode.CREATED, body={"id": user.id})
You can pass constructor arguments directly in the decorator:
@Singleton(host="localhost", port=5432)
class Database:
def __init__(self, host: str, port: int):
self.conn = connect(host, port)
Lifecycle comparison
| Decorator | Behavior |
|---|---|
@Singleton() |
One instance shared across the entire application |
@Transient() |
New instance created on every injection |
@Scoped() |
One instance per request, shared within that request |
File Uploads
@router.post("/upload")
def upload(req: Request) -> Response:
photo = req.multipart.file("photo") # UploadFile | None
attachments = req.multipart.files("attachments") # list[UploadFile] | None
title = req.multipart.field("title") # str | None
count = req.multipart.field("count", type_=int)
saved = photo.save(path="./uploads", create_dirs=True)
return Response.json(HttpCode.OK, body={"saved": str(saved)})
UploadFile properties:
| Property | Description |
|---|---|
filename |
Original filename from the form |
file_type |
MIME type (e.g. image/jpeg) |
size |
File size in bytes |
data_bytes |
Raw bytes |
text |
Decoded content as UTF-8 (text files only) |
Middlewares
from zoe import Logger, Limiter, CORS, Helmet, BodyLimiter, Guard, BearerStrategy, StaticFiles
app = App()
app.use(CORS(allowed_origins=["https://mysite.com"]))
app.use(Logger())
app.use(Limiter(max_requests=100, window_seconds=60))
app.use(Helmet())
app.use(BodyLimiter(max_size_mb=5))
app.use(StaticFiles(directory="./public", prefix="/static"))
app.use(Guard(strategy=BearerStrategy(secret="my-secret")))
app.use(router)
Built-in middlewares
| Middleware | Description |
|---|---|
Logger() |
Color-coded request logs with timing |
Limiter(max_requests, window_seconds) |
IP-based rate limiting |
CORS(allowed_origins, ...) |
CORS headers with preflight support |
Helmet() |
Security headers (XSS, HSTS, etc.) |
BodyLimiter(max_size_mb) |
Reject oversized request bodies |
Guard(strategy) |
Auth enforcement — Bearer, Basic, ApiKey |
StaticFiles(directory, prefix) |
Serve static files |
Guard strategies
from zoe import Guard, BearerStrategy, BasicStrategy, ApiKeyStrategy, AnyStrategy, AllStrategy
Guard(BearerStrategy(secret="token"))
Guard(ApiKeyStrategy(key="key123"))
Guard(AnyStrategy([BearerStrategy(secret="token"), ApiKeyStrategy(key="key123")]))
Guard(AllStrategy([BearerStrategy(secret="token"), ApiKeyStrategy(key="key123")]))
Attach Guard to a specific Router to protect only those routes:
admin_router = Router(prefix="/admin")
admin_router.use(Guard(BearerStrategy(secret="secret")))
Custom middleware
from zoe import Middleware, Request, Response
class TimingMiddleware(Middleware):
def process(self, request: Request, next) -> Response:
start = time.time()
response = next(request)
response.add_header("X-Response-Time", f"{time.time() - start:.3f}s")
return response
Lifecycle Hooks
app = App()
@app.on_startup()
def on_start():
print("Connecting to database...")
@app.on_shutdown()
def on_stop():
print("Closing connections...")
Server(application=app).run()
Environment
from zoe import Env
db_url = Env.get("DATABASE_URL")
port = Env.get("PORT", default="8080")
debug = Env.get_bool("DEBUG", default=False)
Exceptions
from zoe import ZoeHttpException, NotFoundException, HttpCode
raise ZoeHttpException(message="Forbidden", status_code=HttpCode.FORBIDDEN)
raise NotFoundException(resource="User", identifier=user_id)
Full Example
from zoe import App, Server, Router, Request, Response, HttpCode
from zoe import Model, Field, NotNull, Email, Min, Max
from zoe import Singleton, Logger, CORS, Limiter
@Singleton()
class UserRepository:
def __init__(self):
self._users: dict = {}
def create(self, name: str, email: str) -> dict:
uid = str(len(self._users) + 1)
self._users[uid] = {"id": uid, "name": name, "email": email}
return self._users[uid]
def find_all(self) -> list:
return list(self._users.values())
def find(self, uid: str) -> dict | None:
return self._users.get(uid)
class CreateUserDto(Model):
name: str = Field(NotNull())
email: str = Field(NotNull(), Email())
age: int = Field(NotNull(), Min(18), Max(120))
router = Router(prefix="/users")
@router.post("/")
def create_user(req: Request, body: CreateUserDto, repo: UserRepository) -> Response:
user = repo.create(body.name, body.email)
return Response.json(HttpCode.CREATED, body=user)
@router.get("/")
def list_users(req: Request, repo: UserRepository) -> Response:
return Response.json(HttpCode.OK, body=repo.find_all())
@router.get("/{user_id}")
def get_user(req: Request, repo: UserRepository) -> Response:
user = repo.find(req.path_params.get("user_id"))
if user is None:
return Response.json(HttpCode.NOT_FOUND, body={"error": "User not found"})
return Response.json(HttpCode.OK, body=user)
if __name__ == "__main__":
app = App()
app.use(CORS()).use(Logger()).use(Limiter(max_requests=100, window_seconds=60)).use(router)
@app.on_startup()
def on_start():
print("Server ready.")
Server(application=app).run()
Project Structure
your-project/
├── main.py
├── routers/
│ ├── user_router.py
│ └── post_router.py
├── dtos/
│ ├── user_dto.py
│ └── post_dto.py
├── services/
│ ├── user_repository.py
│ └── email_service.py
└── database/
└── connection.py
Roadmap
| Version | Focus | Status |
|---|---|---|
v0.1.0-alpha |
Core routing, validation, basic DI, middlewares | released |
v0.2.0 |
3 DI lifecycles, multipart, all response types, Guard, Helmet, StaticFiles, nested models, Env | current |
v0.3.0 |
Global exception handler, Container.reset(), test client | planned |
v0.4.0 |
OpenAPI / Swagger generation | planned |
v0.5.0 |
Async/await support | planned |
v1.0.0 |
Stable API, full documentation, production-ready | planned |
License
MIT — Lucas Silva Brites
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 zoe_framework-0.2.0.tar.gz.
File metadata
- Download URL: zoe_framework-0.2.0.tar.gz
- Upload date:
- Size: 59.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ea03ce4c0049812c15821592c74e7c19b03343d05467adaf5bb7284190d8c878
|
|
| MD5 |
a7e19cb9972e0b3d9175afd11adead88
|
|
| BLAKE2b-256 |
2842049fb3ff1876190623758d85714a697524ad819a28e997e073fe93e061d0
|
File details
Details for the file zoe_framework-0.2.0-py3-none-any.whl.
File metadata
- Download URL: zoe_framework-0.2.0-py3-none-any.whl
- Upload date:
- Size: 81.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aabd1da550f3db75869dbc8ab93970fcb42a3fdc03b44dd9ae28c2ad863da4d6
|
|
| MD5 |
580cc21672570379ebfa802e7de700ac
|
|
| BLAKE2b-256 |
f152f7fc73292714d5976f778062dad0900168c8454ef7bd48a35b777409954c
|