GitHub - Lightbug-HQ/lightbug_api: Author APIs in pure Mojo

Logo

Lightbug API

🐝 FastAPI-style HTTP APIs in Pure Mojo 🔥

Written in Mojo MIT License Contributors Welcome Join our Discord

Overview

Lightbug API is a FastAPI-inspired HTTP framework for Mojo. It uses Mojo's compile-time metaprogramming to give you ergonomic, typed route handlers with zero runtime overhead.

Not production-ready yet. We're tracking Mojo's rapid development — breaking changes may occur.

Features

  • Declarative routingGET, POST, PUT, DELETE, PATCH route builders
  • Typed handlers — return your model directly; the framework auto-serialises it as JSON
  • Resource controllers — one struct registers all five CRUD routes
  • Middleware — request/response pipeline with short-circuit support
  • Path & query parameters — typed extraction with defaults
  • JSON body parsing — deserialise request bodies into typed structs
  • Sub-routers — mount route groups under a shared prefix
  • Lifecycle hooks — run code once before the server starts

Getting Started

Installation

Add the mojo-community channel and lightbug_api to your pixi.toml:

[workspace]
channels = [
  "conda-forge",
  "https://conda.modular.com/max",
  "https://repo.prefix.dev/mojo-community",
]

[dependencies]
lightbug_api = ">=0.1.1"

Then run:

Minimal example

from lightbug_api import App, GET, POST, HandlerResponse
from lightbug_api.context import Context
from lightbug_api.response import Response
from lightbug_http.http.json import JsonSerializable, JsonDeserializable

@fieldwise_init
struct Item(JsonSerializable, Movable, Defaultable):
    var id: Int
    var name: String
    var price: Float64
    fn __init__(out self): self.id = 0; self.name = ""; self.price = 0.0

@fieldwise_init
struct CreateItemRequest(JsonDeserializable, Movable, Defaultable):
    var name: String
    var price: Float64
    fn __init__(out self): self.name = ""; self.price = 0.0

fn list_items(ctx: Context) raises -> Item:
    return Item(1, "Widget", 9.99)

fn create_item(ctx: Context) raises -> HandlerResponse:
    var body = ctx.json[CreateItemRequest]()
    return Response.created(Item(100, body.name, body.price))

fn main() raises:
    var app = App(
        GET[Item, list_items]("/items"),
        POST("/items", create_item),
    )
    app.run()
pixi run mojo main.mojo
curl http://localhost:8080/items
# {"id":1,"name":"Widget","price":9.99}

Routing

Typed handlers

Handlers that return a model type are auto-serialised as JSON 200 OK — no Response.json() call needed. Use Mojo's compile-time parameters to register them:

fn get_item(ctx: Context) raises -> Item:
    var id = ctx.param("id", 0)
    return Item(id, String("Item ", id), 9.99)

GET[Item, get_item]("/items/{id}")

For handlers that need a non-200 status code, return HandlerResponse explicitly:

fn create_item(ctx: Context) raises -> HandlerResponse:
    var body = ctx.json[CreateItemRequest]()
    return Response.created(Item(100, body.name, body.price))   # 201 Created

fn delete_item(ctx: Context) raises -> HandlerResponse:
    return Response.no_content()                                # 204 No Content

Declarative app

var app = App(
    GET[Item, list_items]("/items"),
    GET[Item, get_item]("/items/{id}"),
    POST("/items",        create_item),
    PUT[Item, update_item]("/items/{id}"),
    DELETE("/items/{id}", delete_item),
    mount("v1",
        GET[StatusResponse, health]("status"),
    ),
)
app.run()

Resource controllers

Implement the Resource trait on a struct to group all five CRUD handlers. One call registers all five routes:

struct Items(Resource):
    @staticmethod
    fn index(ctx: Context) raises -> HandlerResponse:    # GET /items
        return Response.json(Item(1, "Widget", 9.99))

    @staticmethod
    fn show(ctx: Context) raises -> HandlerResponse:     # GET /items/{id}
        return Response.json(Item(ctx.param("id", 0), "Widget", 9.99))

    @staticmethod
    fn create(ctx: Context) raises -> HandlerResponse:   # POST /items
        return Response.created(Item(1, "new", 0.0))

    @staticmethod
    fn update(ctx: Context) raises -> HandlerResponse:   # PUT /items/{id}
        return Response.json(Item(ctx.param("id", 0), "updated", 0.0))

    @staticmethod
    fn destroy(ctx: Context) raises -> HandlerResponse:  # DELETE /items/{id}
        return Response.no_content()

var app = App(resource[Items]("items"))
app.run()
# → GET /items  GET /items/{id}  POST /items  PUT /items/{id}  DELETE /items/{id}

Path & query parameters

fn get_item(ctx: Context) raises -> Item:
    var id      = ctx.param("id", 0)          # Int  (inferred from default)
    var verbose = ctx.query("verbose", False)  # Bool (inferred from default)
    var search  = ctx.query("q", "")          # String
    ...

ctx.param reads path params ({id} in the route pattern); ctx.query reads query string params. The type is inferred from the default value — no explicit casting needed.

JSON body parsing

@fieldwise_init
struct CreateItemRequest(JsonDeserializable, Movable, Defaultable):
    var name: String
    var price: Float64
    fn __init__(out self): self.name = ""; self.price = 0.0

fn create_item(ctx: Context) raises -> HandlerResponse:
    var body = ctx.json[CreateItemRequest]()
    # body.name, body.price are ready to use
    return Response.created(Item(1, body.name, body.price))

Middleware

Middleware runs before every handler in registration order. Return next() to continue or abort(response) to short-circuit.

from lightbug_api import MiddlewareResult, next, abort

fn require_auth(ctx: Context) raises -> MiddlewareResult:
    if not ctx.header("Authorization"):
        return abort(Response.unauthorized("missing Authorization header"))
    return next()

fn log_requests(ctx: Context) raises -> MiddlewareResult:
    print(ctx.method(), ctx.path())
    return next()

var app = App(...)
app.use(log_requests)
app.use(require_auth)
app.run()

Response helpers

Response.json(value)           # 200 OK — application/json
Response.text("hello")         # 200 OK — text/plain
Response.html("<h1>hi</h1>")   # 200 OK — text/html
Response.created(value)        # 201 Created — application/json
Response.no_content()          # 204 No Content
Response.redirect("/new/path") # 302 Found
Response.bad_request("msg")    # 400
Response.unauthorized("msg")   # 401
Response.forbidden("msg")      # 403
Response.not_found("msg")      # 404
Response.unprocessable("msg")  # 422
Response.internal_error("msg") # 500

Sub-routers

Group routes under a shared URL prefix with mount:

var app = App(
    mount("v1",
        GET[StatusResponse, health]("status"),
        GET[Version, version]("version"),
    ),
    mount("v2",
        GET[StatusResponse, health_v2]("status"),
    ),
)
# → GET /v1/status  GET /v1/version  GET /v2/status

For dynamic registration, use the builder API:

var v1 = Router("v1")
v1.get("status", health)
app.add_router(v1^)

Lifecycle hooks & error handling

fn connect_db() raises:
    print("connecting to database...")

fn my_error_handler(ctx: Context, e: Error) raises -> HTTPResponse:
    print("unhandled error:", String(e))
    return Response.internal_error(String(e))

var app = App(...)
app.on_startup(connect_db)
app.on_error(my_error_handler)
app.run()

Running

# development
pixi run mojo main.mojo

# compiled binary
pixi run mojo build main.mojo -o server
./server

# custom host / port
app.run(host="127.0.0.1", port=9090)

Contributors

Want your name to show up here? See CONTRIBUTING.md!

Made with contrib.rocks.