Zornux docs
Get started Spec

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:

bash
zornux init task-api
cd task-api

The generated zornux.project needs one addition — a database provider for persistence:

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

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

bash
zornux serve main.zx

The application is now listening. GET http://localhost:8080/health returns {"status":"ok"}.

Hot reload

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:

zornux
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
Path parameters are untrusted

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:

PropertyTypeExample
body (via with)Map/List/TextParsed JSON request body.
request.query["key"]UntrustedTextQuery string parameter.
request.headers["name"]UntrustedTextRequest header (case-insensitive).
request.client_ipUntrustedTextCaller's IP address.
request.hostUntrustedTextThe Host header value.
request.files["name"]FileUploaded file.

Responses use named helpers for common status codes:

zornux
# 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"
Legacy status syntax

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:

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

zornux
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
Records validate and sanitize

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:

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

zornux
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
Injection-safe by default

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.

zornux
# --- 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 ok and created.
Compiler diagnostics

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:

zornux
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
bash
zornux test main.zx
Tests hit the real code path

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:

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