Zornux docs
Get started Spec

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:

zornux
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
Cap the limit

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:

zornux
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:

zornux
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
Whitelist sort fields

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:

zornux
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:

zornux
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:

zornux
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:

zornux
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
Same web block, different paths

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:

zornux
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
Development CORS

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:

zornux
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:

zornux
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:

zornux
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:

EndpointPurposeFails when
GET /liveLiveness — is the process alive?Process is unresponsive.
GET /healthHealth — are dependencies up?Database or dependency check fails.
GET /readyReadiness — 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.