Web & APIs
Controllers
A controller is a stateless route group that handles HTTP
requests at a URL prefix. A web block composes controllers
into a running application with a shared pipeline, health checks, and
lifecycle hooks. Together they are the primary way to build web APIs in
Zornux.
A complete API
controller Products at "/products"
on GET "/"
give back ok { "items": [] }
end
on POST "/" with body
give back created body
end
on DELETE "/:id"
give back no content
end
end
web App
use Products
end
publish App on port 5000
A controller declares routes relative to its prefix.
GET "/" on a controller at "/products" handles
GET /products. A web block composes one or more
controllers, and publish starts the application.
What you get for free
| Feature | How it works |
|---|---|
| Route groups | Each controller owns a URL prefix — routes are relative, so you never repeat the prefix. |
| Stateless by design | Controllers have no mutable state. Business logic lives in service blocks. |
| Typed responses | give back ok, created, accepted, no content — intent-clear, status-correct. |
| Declarative auth | require authentication or require role "admin" on a controller protects every route in the group. |
| Composition | A web block aggregates controllers and owns the pipeline, health, and lifecycle. |
Response helpers
Controllers use named response helpers instead of raw status codes:
| Syntax | Status | Body |
|---|---|---|
give back ok data | 200 | JSON-encoded data |
give back created data | 201 | JSON-encoded data |
give back accepted data | 202 | JSON-encoded data |
give back no content | 204 | None |
controller Tasks at "/tasks"
on GET "/"
give back ok { "tasks": [] }
end
on POST "/" with body
give back created { "id": 1, "title": body.title }
end
on DELETE "/:id"
give back no content
end
end
The older give back status 201 with body syntax is still valid. The named helpers are clearer and recommended for new code.
Methods and path parameters
Routes support GET, POST, PUT,
PATCH, and DELETE. A path may carry
path parameters (:name); the
with keyword binds the incoming request body.
controller Products at "/products"
on GET "/:id"
create clean = sanitize(id)
give back ok { "id": clean }
end
on PUT "/:id" with body
give back ok body
end
on PATCH "/:id" with body
give back ok body
end
end
Path parameters arrive untrusted, so sanitize (or
validate/trust) them before a trusted sink. A
literal route always beats a parameterized one.
Beyond path parameters, a handler reads request.query["q"] (percent-decoded, case-sensitive), request.headers["…"] (case-insensitive), uploaded request.files["avatar"], and who is asking — request.client_ip and request.host. All of it is untrusted until laundered. Behind a trusted proxy, the last two are the caller's real address and hostname rather than the proxy's; without one, forwarding headers are ignored entirely.
Typed body validation
Use a record type in the with clause to validate
the request body automatically. Invalid input returns 400
before the route body runs:
record CreateItem
has name
required
minimum length 2
end
controller Items at "/items"
on POST "/" with CreateItem body
give back created { "name": body.name }
end
end
Authentication and authorization
Declare security requirements on a controller. They apply to every route in the group — the runtime enforces them before any route body executes:
controller Admin at "/admin"
require authentication
on GET "/dashboard"
give back ok { "access": "granted" }
end
end
controller Users at "/admin/users"
require role "admin"
on GET "/"
give back ok { "users": [] }
end
end
controller Settings at "/settings"
require permission "manage_settings"
on GET "/"
give back ok { "settings": [] }
end
end
| Guard | Rejects with |
|---|---|
require authentication | 401 when no identity is present |
require role "name" | 403 when the principal lacks the role |
require permission "name" | 403 when the principal lacks the permission |
Composing with web
A web block aggregates controllers, owns the middleware
pipeline, and provides lifecycle hooks. It is the unit you
publish:
controller Products at "/products"
on GET "/"
give back ok { "type": "products" }
end
end
controller Orders at "/orders"
on GET "/"
give back ok { "type": "orders" }
end
end
web App
use Products
use Orders
on GET "/"
give back ok { "service": "Shop API" }
end
pipeline
catch errors
end
health
give back ok
end
on startup
show "App started"
end
end
publish App on port 5000
The web block can also declare its own routes alongside
composed controllers — useful for a root endpoint or version info.
pipeline, health, and lifecycle hooks
(on startup, on shutdown) belong to the web
block, not individual controllers.
Business logic in services
Controllers handle HTTP concerns — routing, request parsing, and
responses. Business logic lives in service blocks, injected
into controllers via use. Data access lives in
repository blocks. This layered separation is enforced by
the compiler:
# Repository: data access only
repository ProductRepository
async function all
give back find all from ProductDb.Products
end
async function save with product
save product into ProductDb.Products
give back product
end
end
# Service: business rules, depends on repository
service ProductService
use ProductRepository
async function list_products
give back wait for ProductRepository.all()
end
async function create_product with request
create product from Product
product.name = request.name
product.price = request.price
give back wait for ProductRepository.save(product)
end
end
# Controller: HTTP routing, delegates to service
controller Products at "/products"
use ProductService
on GET "/"
create items = wait for ProductService.list_products()
give back ok items
end
on POST "/" with CreateProductRequest body
create product = wait for ProductService.create_product(body)
give back created product
end
end
# Web: composition + pipeline
web App
use Products
pipeline
catch errors
end
end
publish App on port 5000
The use keyword injects dependencies. The service depends on
the repository; the controller depends on the service. The controller
never touches the database directly — that is the repository's job.
The compiler warns if a controller depends directly on a repository (ZX6101) or a database (ZX6102) — route data access through a service layer instead. A repository depending on a controller is an error (ZX6103). Duplicate routes within a controller are caught at compile time (ZX6104).
A published port is a routing boundary
A request is routed among the controllers published on the port it arrived on. Two web blocks can serve the same path on different ports, each with its own pipeline and CORS policy:
publish PublicApi on port 8080
publish AdminApi on port 9090
A caller with no port — zornux test, or a unit test invoking a controller directly — reaches every route, exactly as before. The boundary is a property of the transport, not of the controller.
Legacy: service as route container
The older pattern of declaring routes directly inside a service block still compiles and runs. For new code, use controller + web — it separates routing concerns from business logic, enables controller-level auth guards, and produces better architecture diagnostics.
# legacy — still works
service SmallApi
on GET "/hello"
give back "Hello"
end
end
publish SmallApi on port 5000
The web framework behind controllers is native to Zornux — not a wrapper over a third-party server. It runs in-memory for tests and over a built-in HTTP transport when published.
Closely related: how Zornux keeps these endpoints safe by default — Security.