Skip to main content

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

Python Version License

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 @Scoped out 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

zoe_framework-0.2.0.tar.gz (59.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

zoe_framework-0.2.0-py3-none-any.whl (81.4 kB view details)

Uploaded Python 3

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

Hashes for zoe_framework-0.2.0.tar.gz
Algorithm Hash digest
SHA256 ea03ce4c0049812c15821592c74e7c19b03343d05467adaf5bb7284190d8c878
MD5 a7e19cb9972e0b3d9175afd11adead88
BLAKE2b-256 2842049fb3ff1876190623758d85714a697524ad819a28e997e073fe93e061d0

See more details on using hashes here.

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

Hashes for zoe_framework-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 aabd1da550f3db75869dbc8ab93970fcb42a3fdc03b44dd9ae28c2ad863da4d6
MD5 580cc21672570379ebfa802e7de700ac
BLAKE2b-256 f152f7fc73292714d5976f778062dad0900168c8454ef7bd48a35b777409954c

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page