Web & APIs
API Patterns
A working CRUD API is just the start. This page covers the patterns that make an API production-grade: pagination for large data sets, filtering and sorting, file handling, versioning, caching, and CORS.
Pagination
Return large collections in pages. Read page and
limit from the query string and return pagination metadata
alongside the data:
controller Products at "/products"
on GET "/"
create page = to_number(request.query["page"] ?? "1")
create limit = to_number(request.query["limit"] ?? "20")
if limit is greater than 100
limit = 100
end
create offset = (page - 1) * limit
create items = from ProductDb.products
select all
order by created_at descending
skip offset
take limit
create total = from ProductDb.products count all
give back ok {
"items": items,
"page": page,
"limit": limit,
"total": total,
"pages": ceiling(total / limit)
}
end
end
Always enforce a maximum page size. Without it, a caller can request ?limit=1000000 and exhaust memory. A cap of 100 is a reasonable default.
Filtering
Let clients narrow results with query parameters:
controller Products at "/products"
on GET "/"
create page = to_number(request.query["page"] ?? "1")
create limit = to_number(request.query["limit"] ?? "20")
create category = request.query["category"]
create min_price = request.query["min_price"]
create max_price = request.query["max_price"]
create search = request.query["q"]
create query = from ProductDb.products
if category is not nothing
query = query where category is equal to sanitize(category)
end
if min_price is not nothing
query = query where price is greater than or equal to to_number(min_price)
end
if max_price is not nothing
query = query where price is less than or equal to to_number(max_price)
end
if search is not nothing
query = query where name contains sanitize(search)
end
create items = query
order by name ascending
skip (page - 1) * limit
take limit
give back ok { "items": items, "page": page, "limit": limit }
end
end
Sorting
Accept a sort parameter with a field name and direction:
controller Products at "/products"
on GET "/"
create sort_field = request.query["sort"] ?? "created_at"
create sort_dir = request.query["order"] ?? "desc"
# whitelist allowed sort fields
create allowed_fields = ["name", "price", "created_at", "rating"]
if sort_field is not in allowed_fields
give back status 400 with message "Invalid sort field"
end
create items = from ProductDb.products
select all
order by sort_field direction sort_dir
take 20
give back ok items
end
end
Never pass a user-supplied sort field directly to a query without checking it against an allow-list. While Zornux parameterizes values, column names are structural — an arbitrary name is a schema-leak vector.
Search
Full-text search with ranking and highlighting:
controller Search at "/search"
on GET "/"
create q = request.query["q"]
if q is nothing or q is ""
give back status 400 with message "Search query is required"
end
create results = from ProductDb.products
where name contains sanitize(q)
or description contains sanitize(q)
order by name ascending
take 50
give back ok {
"query": sanitize(q),
"count": length of results,
"results": results
}
end
end
File uploads
Handle file uploads with request.files. The file is
available as a temporary object with a name, size, and content:
controller Avatars at "/avatars"
require authentication
on POST "/" with body
create file = request.files["avatar"]
if file is nothing
give back status 400 with message "No file uploaded"
end
# validate the file
if file.size is greater than 5000000
give back status 400 with message "File must be under 5 MB"
end
create allowed_types = ["image/jpeg", "image/png", "image/webp"]
if file.content_type is not in allowed_types
give back status 400 with message "Only JPEG, PNG, and WebP images are allowed"
end
# save and return the URL
create path = save_file(file, "avatars/" + request.user.id)
give back ok { "url": "/files/" + path }
end
end
Streaming large responses
For CSV exports or large data dumps, use a stream
response to send data incrementally without buffering it all in memory:
controller OrderExport at "/export"
require role "admin"
on GET "/orders"
give back stream "text/csv"
send "id,customer,total,date"
create orders = from OrderDb.orders select all order by id ascending
for each order in orders
send order.id + "," + order.customer + "," + order.total + "," + order.date
end
end
end
end
API versioning
Version your API with URL prefixes. Each version is a separate controller:
controller ProductsV1 at "/v1/products"
on GET "/"
create products = from ProductDb.products select all
give back ok products
end
end
controller ProductsV2 at "/v2/products"
on GET "/"
create products = from ProductDb.products select all
# v2 returns a different shape
create result = []
for each product in products
add {
"id": product.id,
"name": product.name,
"price": { "amount": product.price, "currency": "USD" },
"metadata": { "created": product.created_at }
} to result
end
give back ok { "data": result, "version": "2.0" }
end
end
web ProductApi
use ProductsV1
use ProductsV2
end
publish ProductApi on port 8080
Two controllers in the same web block are fine as long as their paths do not overlap. The /v1/ and /v2/ prefixes guarantee that.
CORS
Configure Cross-Origin Resource Sharing in the middleware pipeline:
controller Products at "/products"
on GET "/"
give back ok products
end
end
web PublicApi
pipeline
cors allow origins "https://app.example.com", "https://admin.example.com"
cors allow methods "GET", "POST", "PUT", "DELETE"
cors allow headers "Authorization", "Content-Type"
cors max age 3600
end
use Products
end
During development, use cors allow origins "*" to allow all origins. Replace it with explicit origins before shipping.
Rate limiting
Protect endpoints from abuse with the built-in rate limiter:
controller Search at "/search"
on POST "/" with body
# expensive operation — rate-limited to 100 req/min per IP
create results = search_products(body.query)
give back ok results
end
end
web PublicApi
pipeline
rate limit 100 per minute by client_ip
end
use Search
end
When a client exceeds the limit, the runtime returns
429 Too Many Requests with a Retry-After
header automatically.
Caching headers
Set cache control headers on responses that are safe to cache:
controller Products at "/products"
on GET "/:id"
create product = from ProductDb.products where id is equal to id select one
if product is nothing
give back status 404 with message "Product not found"
end
give back ok product
header "Cache-Control" "public, max-age=300"
header "ETag" product.version
end
end
Health and readiness
Production APIs need health check endpoints for load balancers and orchestrators. The hardening module provides these declaratively:
controller Tasks at "/tasks"
on GET "/"
give back ok tasks
end
end
web TaskApp
health
check database TaskDb
check dependency "payment-service" url "https://payments.internal/health"
end
use Tasks
end
This generates three endpoints automatically:
| Endpoint | Purpose | Fails when |
|---|---|---|
GET /live | Liveness — is the process alive? | Process is unresponsive. |
GET /health | Health — are dependencies up? | Database or dependency check fails. |
GET /ready | Readiness — can it serve traffic? | Still initializing or draining. |
What's next
- API Authentication — JWT, API keys, OAuth, and role-based access control.
- Building APIs — the complete walkthrough from an empty file to a working CRUD service.
- Middleware — the pipeline system for request processing.
- Hardening — resilience, graceful shutdown, and production readiness.