Zornux docs
Get started Spec

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

zornux
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

FeatureHow it works
Route groupsEach controller owns a URL prefix — routes are relative, so you never repeat the prefix.
Stateless by designControllers have no mutable state. Business logic lives in service blocks.
Typed responsesgive back ok, created, accepted, no content — intent-clear, status-correct.
Declarative authrequire authentication or require role "admin" on a controller protects every route in the group.
CompositionA web block aggregates controllers and owns the pipeline, health, and lifecycle.

Response helpers

Controllers use named response helpers instead of raw status codes:

SyntaxStatusBody
give back ok data200JSON-encoded data
give back created data201JSON-encoded data
give back accepted data202JSON-encoded data
give back no content204None
zornux
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
Raw status codes still work

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.

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

Reading query, headers, and who is calling

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:

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

zornux
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
GuardRejects with
require authentication401 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:

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

zornux
# 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.

Architecture diagnostics

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:

zornux
publish PublicApi on port 8080
publish AdminApi  on port 9090
In-memory callers still reach everything

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

Still supported

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.

zornux
# legacy — still works
service SmallApi
    on GET "/hello"
        give back "Hello"
    end
end

publish SmallApi on port 5000
Zornux's own web framework

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.