Add a database-backed resource#
This example creates articles with a UUID ID and a title, then exposes POST /articles and GET /articles/:id. Start in an initialized application. Authentication and resource ownership are application policy; these example routes are public. Add guards and owner-scoped queries before using them for private records.
1. Create the table#
zyn g table articles
Add title to the generated migration's CREATE TABLE declaration, retaining its UUID primary key, timestamps, and update trigger. The resulting table declaration is:
CREATE TABLE public."articles" (
id uuid NOT NULL DEFAULT uuidv7() PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
title text NOT NULL CHECK (length(btrim(title)) > 0)
);
Run zyn db migrate. Commit the migration and the refreshed db/schema.sql, db/zyn_schema.sql, and db/types.zig. The generated row type is db.Article.
2. Define the public representation#
Create src/resources/Article.zig. This is an application-owned representation, not the generated database row:
// src/resources/Article.zig
const std = @import("std");
id: []const u8,
title: []const u8,
pub fn validate_title(title: []const u8) !void {
if (std.mem.trim(u8, title, " \t\r\n").len == 0) return error.InvalidTitle;
}
pub fn valid_id(id: []const u8) bool {
if (id.len != 36) return false;
for (id, 0..) |char, i| {
if (i == 8 or i == 13 or i == 18 or i == 23) {
if (char != '-') return false;
} else if (!std.ascii.isHex(char)) return false;
}
return true;
}
3. Add the actions#
// src/actions/articles.zig
const zyn = @import("zyn");
const Article = @import("../resources/Article.zig");
const Payload = struct { title: []const u8 };
pub fn create(action: *zyn.Action) !zyn.Response {
const payload = try action.expect_payload(Payload);
Article.validate_title(payload.title) catch {
return .empty(.{ .status = .unprocessable_entity });
};
const article = (try action.db.one(
Article,
"INSERT INTO articles (title) VALUES ($1) RETURNING id::text AS id, title",
.{payload.title},
)) orelse return error.ArticleNotCreated;
return action.json(article, .{ .status = .created });
}
pub fn show(action: *zyn.Action) !zyn.Response {
const id = action.param("id");
if (!Article.valid_id(id)) {
return .empty(.{ .status = .bad_request });
}
const row = (try action.db.one(
Article,
"SELECT id::text AS id, title FROM articles WHERE id = $1::uuid",
.{id},
)) orelse {
return .empty(.{ .status = .not_found });
};
return action.json(row, .{});
}
The query returns only public fields; generated row types must not be serialized directly. PostgreSQL UUID columns map to binary bytes in generated types, so the queries cast the ID to text for JSON and cast the validated route parameter back to UUID for lookup.
4. Register the routes#
Add this import to src/routes.zig:
// src/routes.zig: imports
const articles = @import("actions/articles.zig");
Add these entries to the existing table, retaining its other entries and the module's defaults, including .csrf(.protected):
// src/routes.zig: table entries
.post("/articles", articles.create, &.{}),
.get("/articles/:id", articles.show, &.{}),
The generated src/app.zig already exports pub const routes = @import("routes.zig");. Keep that export.
5. Verify#
zyn routes
zyn check
zyn dev --http
In another terminal, create an article:
curl -i http://localhost:7532/articles \
-H 'Content-Type: application/json' \
-H 'Sec-Fetch-Site: same-origin' \
--data '{"title":"Example article"}'
Expect 201 Created and an object containing the allocated id and title. Use the ID returned by the create request in a read request:
curl -i http://localhost:7532/articles/<returned-id>
The read returns 200 with the same representation; an absent ID returns 404, a malformed UUID returns 400, and a blank title returns 422. Use the reported server port when PORT overrides the default. recipes/add-application-test adds executable tests for these actions.
For collections, use bounded queries with application-defined ordering and pagination. Use SQL parameters for request values. When an application write and a queued job or notification must commit together, use a transaction. Blob publication itself does not share that transaction.