Web & APIs
Building APIs
This page walks you through building a complete REST API in Zornux,
from an empty file to a working application with controllers, services,
validation, error handling, and tests. Controllers define your route
groups, services encapsulate business logic, and the
web block wires them into a running application. If you've
read the Controllers reference, this is where
you see it all come together.
Project setup
Initialize a project and create the entry file:
zornux init task-api
cd task-api
The generated zornux.project needs one addition — a
database provider for persistence:
name = task-api
version = 0.1.0
entry = main.zx
Define a controller
A controller block declares a stateless route group with a
base path. A web block composes controllers into an
application. Start with a simple health check:
controller Health at "/health"
on GET "/"
give back ok { "status": "ok" }
end
end
web TaskApp
use Health
end
publish TaskApp on port 8080
Run it:
zornux serve main.zx
The application is now listening. GET http://localhost:8080/health
returns {"status":"ok"}.
zornux serve watches for file changes and restarts automatically. Keep it running while you develop.
CRUD endpoints
Build a task manager API with create, read, update, and delete. Start with an in-memory list, then graduate to a database:
controller Tasks at "/tasks"
create tasks = []
create next_id = 1
# List all tasks
on GET "/"
give back ok tasks
end
# Get a single task
on GET "/:id"
create task = find_task(id)
if task is nothing
give back status 404 with message "Task not found"
end
give back ok task
end
# Create a task
on POST "/" with body
create task = {
"id": next_id,
"title": body.title,
"description": body.description,
"done": false
}
next_id = next_id + 1
add task to tasks
give back created task
end
# Update a task
on PUT "/:id" with body
create task = find_task(id)
if task is nothing
give back status 404 with message "Task not found"
end
task.title = body.title
task.description = body.description
task.done = body.done
give back ok task
end
# Delete a task
on DELETE "/:id"
create task = find_task(id)
if task is nothing
give back status 404 with message "Task not found"
end
remove task from tasks
give back no content
end
function find_task with target_id
for each task in tasks
if task.id is equal to target_id
give back task
end
end
give back nothing
end
end
web TaskApp
use Tasks
end
publish TaskApp on port 8080
The :id in "/:id" is bound as an UntrustedText value. For simple lookups by equality this is safe — the value never reaches a trusted sink. If you build SQL from it, use a parameterized query (the data layer does this by default).
Request and response
Every route handler has access to the full request context:
| Property | Type | Example |
|---|---|---|
body (via with) | Map/List/Text | Parsed JSON request body. |
request.query["key"] | UntrustedText | Query string parameter. |
request.headers["name"] | UntrustedText | Request header (case-insensitive). |
request.client_ip | UntrustedText | Caller's IP address. |
request.host | UntrustedText | The Host header value. |
request.files["name"] | File | Uploaded file. |
Responses use named helpers for common status codes:
# 200 OK — return data as JSON
give back ok { "users": users }
# 201 Created — return the new resource
give back created task
# 202 Accepted — acknowledge an async operation
give back accepted { "job_id": job_id }
# 204 No Content — success with no body
give back no content
# Explicit status code with a body
give back status 200 with task
# Explicit status code with a message
give back status 404 with message "Not found"
The explicit give back status 201 with task form still works but named helpers like ok, created, and no content are preferred in new code. The compiler accepts both.
Input validation
Validate request data before processing it. Use the application layer record syntax for declarative validation, or validate manually:
controller Tasks at "/tasks"
on POST "/" with body
# manual validation
if body.title is nothing or body.title is ""
give back status 400 with message "Title is required"
end
if length of body.title is greater than 200
give back status 400 with message "Title must be 200 characters or fewer"
end
create task = {
"id": next_id,
"title": sanitize(body.title),
"description": sanitize(body.description ?? ""),
"done": false
}
next_id = next_id + 1
add task to tasks
give back created task
end
end
For richer validation, define a record and bind it
directly in the route signature with with ... body:
record CreateTaskRequest
title is required, minimum length 1, maximum length 200
description is maximum length 2000
end
controller Tasks at "/tasks"
on POST "/" with CreateTaskRequest body
create task = {
"id": next_id,
"title": body.title,
"description": body.description ?? "",
"done": false
}
next_id = next_id + 1
add task to tasks
give back created task
end
end
A validated record produces trusted values — body.title is already safe to use in responses or database queries. If validation fails, the runtime returns a 400 with structured errors before your handler runs.
Error responses
Return consistent error shapes so clients can parse them reliably:
function error_response with code, message
give back status code with {
"error": {
"code": code,
"message": message
}
}
end
controller Tasks at "/tasks"
on GET "/:id"
create task = find_task(id)
if task is nothing
error_response(404, "Task not found")
end
give back ok task
end
on POST "/" with body
if body.title is nothing
error_response(400, "Title is required")
end
# ...
end
end
Adding a database
Replace the in-memory list with a real database. The data layer handles schema, queries, and migrations. This example keeps routes inline in the controller — the next section shows how to extract a proper service layer:
database TaskDb using sqlite "tasks.db"
table tasks
id is number, primary key, auto increment
title is text, not null
description is text
done is truth, default false
created_at is timestamp, default now
end
end
controller Tasks at "/tasks"
on GET "/"
create all_tasks = from TaskDb.tasks select all
give back ok all_tasks
end
on GET "/:id"
create task = from TaskDb.tasks where id is equal to id select one
if task is nothing
give back status 404 with message "Task not found"
end
give back ok task
end
on POST "/" with body
create task = insert into TaskDb.tasks with {
"title": sanitize(body.title),
"description": sanitize(body.description ?? "")
}
give back created task
end
on PUT "/:id" with body
create updated = update TaskDb.tasks
where id is equal to id
set title to sanitize(body.title),
description to sanitize(body.description ?? ""),
done to body.done
if updated is 0
give back status 404 with message "Task not found"
end
create task = from TaskDb.tasks where id is equal to id select one
give back ok task
end
on DELETE "/:id"
create deleted = delete from TaskDb.tasks where id is equal to id
if deleted is 0
give back status 404 with message "Task not found"
end
give back no content
end
end
controller Health at "/health"
on GET "/"
give back ok { "status": "ok" }
end
end
web TaskApp
use pipeline
use Tasks
use Health
end
publish TaskApp on port 8080
Every where clause is parameterized — the user-supplied :id is never interpolated into SQL. This is not a convention; it is enforced by the language.
Layering with services
As an API grows, keeping database queries and business logic inside controllers becomes unwieldy. The idiomatic Zornux approach is a three-layer architecture: repository for data access, service for business rules, and controller for HTTP concerns.
# --- Data access layer ---
repository TaskRepository
function all
give back from TaskDb.tasks select all
end
function find_by_id with id
give back from TaskDb.tasks where id is equal to id select one
end
function save with data
give back insert into TaskDb.tasks with data
end
function remove with id
give back delete from TaskDb.tasks where id is equal to id
end
end
# --- Business logic layer ---
service TaskService
use TaskRepository
function list_tasks
give back TaskRepository.all()
end
function get_task with id
create task = TaskRepository.find_by_id(id)
if task is nothing
give back status 404 with message "Task not found"
end
give back task
end
function create_task with data
give back TaskRepository.save(data)
end
function delete_task with id
create deleted = TaskRepository.remove(id)
if deleted is 0
give back status 404 with message "Task not found"
end
end
end
# --- HTTP layer ---
controller Tasks at "/tasks"
use TaskService
on GET "/"
create tasks = TaskService.list_tasks()
give back ok tasks
end
on GET "/:id"
create task = TaskService.get_task(id)
give back ok task
end
on POST "/" with CreateTaskRequest body
create task = TaskService.create_task({
"title": body.title,
"description": body.description ?? "",
"done": false
})
give back created task
end
on DELETE "/:id"
TaskService.delete_task(id)
give back no content
end
end
controller Health at "/health"
on GET "/"
give back ok { "status": "ok" }
end
end
web TaskApp
use pipeline
use Tasks
use Health
end
publish TaskApp on port 8080
Each layer has a single responsibility:
- Repository — raw data access. No HTTP concepts, no business rules. Easy to swap between SQLite, Postgres, or an in-memory store for tests.
- Service — business logic and orchestration. Uses repositories via
use, owns validation rules, and raises domain errors. - Controller — HTTP routing only. Binds paths, parses requests, calls services, and maps results to response helpers like
okandcreated.
The compiler helps enforce layering. ZX6101 warns when a controller contains direct database queries (move them to a repository). ZX6102 warns when a service references HTTP-specific concepts like status codes or request headers (keep those in the controller).
Testing the API
Write tests that exercise the application in-memory — no server needed:
test "POST /tasks creates a task"
create response = TaskApp.post("/tasks", {
"title": "Buy groceries"
})
expect response.status to be 201
expect response.body.title to be "Buy groceries"
expect response.body.done to be false
end
test "GET /tasks/:id returns the task"
TaskApp.post("/tasks", { "title": "Test task" })
create response = TaskApp.get("/tasks/1")
expect response.status to be 200
expect response.body.title to be "Test task"
end
test "GET /tasks/:id returns 404 for missing task"
create response = TaskApp.get("/tasks/999")
expect response.status to be 404
end
test "DELETE /tasks/:id removes the task"
TaskApp.post("/tasks", { "title": "To delete" })
create response = TaskApp.delete("/tasks/1")
expect response.status to be 204
end
zornux test main.zx
TaskApp.get() and TaskApp.post() call the actual route handlers through the full controller and service stack with the same validation, middleware, and authorization. The only difference is no TCP socket — the transport is in-memory.
Running in production
When the API is ready, zornux serve runs it with the
production profile, and zornux deploy generates the
infrastructure:
# run in production
zornux serve main.zx --profile production
# generate Dockerfile, systemd units, nginx config
zornux deploy all main.zx
See Deployment for the full generation pipeline, and Configuration for production settings.
What's next
- API Patterns — pagination, filtering, file uploads, versioning, and caching.
- API Authentication — JWT, API keys, OAuth, and role-based access control.
- Middleware — CORS, rate limiting, compression, and custom pipeline steps.
- Data Layer — database declarations, queries, relationships, and migrations.