Guides menu
Guide

Build a REST API

A step-by-step tutorial: build a small JSON REST API with routes, middleware, and security using serez-http, then ship it as a Docker image with serez-apipack.

What you'll build: a tasks API with GET/POST routes, route parameters, logging and auth middleware, rate limiting, then a deployable Docker container.

Step 1 — Set up

mkdir tasks-api
cd tasks-api
sz init --y
sz install serez-http

sz init --y creates a serez.json with a dev script (sz index.sz), so we'll start the server with sz run dev. Create index.sz and create the app:

import "serez-http"

const app = new App()

Step 2 — Your first route

A route is an HTTP verb, a path, and a handler that receives req and res. Send JSON with res.json():

app.GET("/", fn(req, res) {
    res.json({"message": "tasks API", "version": "1.0"})
})

app.listen(3000, fn() {
    out "listening on http://127.0.0.1:3000"
})

Run it with the dev script and hit it from another terminal:

sz run dev
# elsewhere:
curl http://127.0.0.1:3000/
Note: get and use are reserved words in Serez Code, so the methods are GET/POST/PUT/delete and middleware is registered with addMw.

Step 3 — Route parameters and a data store

Use :param segments and read them from req["params"]. We'll keep tasks in a simple in-memory array:

let tasks = ["buy milk", "write docs", "ship release"]

// List all tasks
app.GET("/tasks", fn(req, res) {
    res.json({"tasks": tasks, "count": tasks.length()})
})

// Get one task by index: /tasks/0
app.GET("/tasks/:id", fn(req, res) {
    let id = parseInt(req["params"]["id"])
    if (id < 0 || id >= tasks.length()) {
        res.status(404).json({"error": "no such task"})
    } else {
        res.json({"id": id, "task": tasks[id]})
    }
})

Step 4 — Handle POST with a JSON body

The raw body is in req["body"]. Parse it with JSON.parse:

app.POST("/tasks", fn(req, res) {
    let body = JSON.parse(req["body"])
    let title = body["title"]
    if (title == null) {
        res.status(400).json({"error": "title is required"})
    } else {
        tasks.push(title)
        res.status(201).json({"added": title, "count": tasks.length()})
    }
})
curl -X POST http://127.0.0.1:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "review PR"}'

Step 5 — Middleware: logging and auth

Middleware runs before handlers. Register it with addMw; call next() to continue, or send a response to stop the chain. Order matters — register middleware before listen:

// Log every request
app.addMw(fn(req, res, next) {
    out req["method"] + " " + req["path"]
    next()
})

// Require a token on write routes
app.addMw(fn(req, res, next) {
    if (req["method"] == "POST" && req["authorization"] == "") {
        res.status(401).json({"error": "unauthorized"})
    } else {
        next()
    }
})

Step 6 — Security: rate limiting and CORS

Throttle abusive clients with the built-in per-IP rate limiter, and control which origins may call your API with corsMiddleware:

// Max 100 requests per minute per IP
app.addMw(fn(req, res, next) {
    if (app.rateLimit(req["ip"], 100, 60)) {
        next()
    } else {
        res.status(429).json({"error": "too many requests"})
    }
})

// Allow a specific origin
app.addMw(corsMiddleware(
    "https://my-frontend.com",
    "GET, POST, PUT, DELETE",
    "Content-Type, Authorization"
))

Step 7 — Handle errors

Register a global error handler so a thrown exception in a handler returns a clean 500 instead of crashing the server:

app.error(fn(err, req, res) {
    out "error: " + err
    res.status(500).json({"error": "internal server error"})
})

Step 8 — Deploy with serez-apipack

serez-apipack packages the project into a self-contained Docker image — the host only needs Docker, not Serez Code. Your serez.json already has a dev script from sz init; declare the dependency and the full dev/build script pair:

{
  "name": "tasks-api",
  "version": "1.0.0",
  "main": "index.sz",
  "scripts": {
    "dev": "sz index.sz",
    "build": "sz pack.sz"
  },
  "dependencies": {
    "serez-http": "1.0.0",
    "serez-apipack": "1.0.0"
  }
}

Now the workflow is two commands: sz run dev to develop locally, and sz run build to produce the Docker image. Install the packer and build:

sz install                         # installs serez-http + serez-apipack from serez.json

sz run dev                         # local development server
sz run build                       # → runs sz pack.sz, builds tasks-api:1.0.0

# pack.sz also takes options directly:
sz pack.sz port=8080 tag=tasks-api:latest

Run the container — it's reachable from outside the host:

docker run -p 3000:3000 tasks-api:1.0.0
How it works: Serez Code binds to 127.0.0.1, so apipack adds a socat proxy inside the image that forwards 0.0.0.0:PORT → the app. You write a local server; apipack makes it reachable.

Next steps