refactor: renamte to get/set/as/delete pattern

This commit is contained in:
Nurul Huda (Apon) 2026-03-20 11:53:17 +06:00
parent 06c8984a10
commit 4ecd96d4d8
No known key found for this signature in database
GPG Key ID: 5D3F1DE2855A2F79
17 changed files with 139 additions and 80 deletions

View File

@ -44,13 +44,13 @@ fn handleAction(ctx: zx.PageCtx(void, AuthState)) void {
if (std.mem.eql(u8, action, "Login")) {
const username = fd.get("username") orelse return;
if (username.len > 0) {
ctx.response.setCookie("session", username, .{
ctx.response.cookies.set("session", username, .{
.path = "/",
.max_age = 3600, // 1 hour
});
}
} else if (std.mem.eql(u8, action, "Logout")) {
ctx.response.deleteCookie("session", .{ .path = "/" });
ctx.response.cookies.delete("session", .{ .path = "/" });
}
// Redirect back to GET to avoid form resubmission

View File

@ -1,5 +1,5 @@
pub fn UserProfile(ctx: zx.PageContext) zx.Component {
const user_id = ctx.request.getParam("id") orelse "unknown";
const user_id = ctx.request.params.get("id") orelse "unknown";
return (
<main @allocator={ctx.arena}>

View File

@ -223,7 +223,7 @@ pub fn DynamicAttr(
// --- Dynamic Path ---
pub fn Page(ctx: zx.PageContext) zx.Component {
const id = ctx.request.getParam("id");
const id = ctx.request.params.get("id");
return (
<section @allocator={ctx.arena}>
<h1>User {id}</h1>

View File

@ -3,16 +3,16 @@ pub fn PUT(ctx: zx.RouteContext) !void {
}
pub fn DELETE(ctx: zx.RouteContext) !void {
ctx.response.deleteCookie("body", .{});
ctx.response.deleteCookie("body-1", .{});
ctx.response.cookies.delete("body", .{});
ctx.response.cookies.delete("body-1", .{});
try ctx.response.json(.{ .message = "Deleted" }, .{});
}
// Too much column width of codes
pub fn POST(ctx: zx.RouteContext) !void {
const body = ctx.request.text() orelse "No body";
ctx.response.setCookie("body", body, .{});
ctx.response.setCookie("body-1", body, .{});
ctx.response.cookies.set("body", body, .{});
ctx.response.cookies.set("body-1", body, .{});
try ctx.response.json(.{
.message = "PST",
.body = body,
@ -22,8 +22,8 @@ pub fn POST(ctx: zx.RouteContext) !void {
// Support multiple methods signature
// pub fn POST(req: zx.Request, res: zx.Response) !void {
// const body = req.text() orelse "No body";
// res.setCookie("body", body, .{});
// res.setCookie("body-1", body, .{});
// res.cookies.set("body", body, .{});
// res.cookies.set("body-1", body, .{});
// try res.json(.{
// .message = "PST",
// .body = body,

View File

@ -1,15 +1,15 @@
var count: i32 = 0;
pub fn Page(ctx: zx.PageContext) zx.Component {
count += 1;
ctx.response.setCookie("count", std.fmt.allocPrint(ctx.arena, "{d}", .{count}) catch unreachable, .{});
ctx.response.cookies.set("count", std.fmt.allocPrint(ctx.arena, "{d}", .{count}) catch unreachable, .{});
if (count == 4) {
ctx.response.deleteCookie("count", .{});
ctx.response.cookies.delete("count", .{});
count = 0;
}
const count_cookie = ctx.request.cookies.get("count");
if (count_cookie) |cookie| {
ctx.response.setCookie("count-old", cookie, .{});
ctx.response.cookies.set("count-old", cookie, .{});
}
const allocator = ctx.arena;

View File

@ -3,12 +3,12 @@ pub fn Page(ctx: zx.PageContext) zx.Component {
const set_username = ctx.request.searchParams.get("username") orelse "";
var leave_username = ctx.request.searchParams.get("leave") orelse "";
if (set_username.len > 0) {
ctx.response.setCookie("username", set_username, .{});
ctx.response.cookies.set("username", set_username, .{});
chat_username = set_username;
ctx.response.redirect("/examples/realtime", null);
}
if (leave_username.len > 0) {
ctx.response.deleteCookie("username", .{});
ctx.response.cookies.delete("username", .{});
leave_username = leave_username;
chat_username = "";
}

View File

@ -33,7 +33,7 @@ fn start(ctx: zx.RouteContext) void {
}
fn end(ctx: zx.RouteContext) void {
ctx.response.setCookie("progress", ctx.fmt("{d}", .{progress}) catch "0", .{});
ctx.response.cookies.set("progress", ctx.fmt("{d}", .{progress}) catch "0", .{});
}
const std = @import("std");

View File

@ -1,6 +1,6 @@
// site/pages/user/[id]/page.zx
pub fn UserProfile(ctx: zx.PageContext) zx.Component {
const user_id = ctx.request.getParam("id") orelse "unknown";
const user_id = ctx.request.params.get("id") orelse "unknown";
return (
<main @allocator={ctx.arena}>

View File

@ -590,7 +590,7 @@ fn PagesAndLayouts(allocator: zx.Allocator, props: PagesAndLayoutsProps) zx.Comp
<li><code>app/pages/user/[id]/page.zx</code> <code>/user/:id</code></li>
<li><code>app/pages/blog/[slug]/page.zx</code> <code>/blog/:slug</code></li>
</ul>
<p>Access the parameter value using <code>ctx.request.getParam("name")</code>:</p>
<p>Access the parameter value using <code>ctx.request.params.get("name")</code>:</p>
<ExampleBlock id="dynamic-route" zx_code={props.zx_dynamic} zig_code={props.zig_dynamic} html_code={props.html_dynamic} filename="user/[id]/user_profile.zx" />
<h3>Creating layouts</h3>

View File

@ -1,6 +1,6 @@
pub fn Page(ctx: zx.PageContext) zx.Component {
const allocator = ctx.arena;
const slug = ctx.request.getParam("slug") orelse "unknown";
const slug = ctx.request.params.get("slug") orelse "unknown";
return (
<div @allocator={allocator}>

View File

@ -486,7 +486,7 @@ pub fn Page(ctx: zx.PageContext) !zx.Component {
<li><code>site/pages/[category]/[item]/page.zx</code> <code>/:category/:item</code></li>
</ul>
<h3>Accessing Route and Query Parameters</h3>
<p>Use <code>ctx.request.getParam("name")</code> to access dynamic route parameters and <code>ctx.request.query("name")</code> for query strings:</p>
<p>Use <code>ctx.request.params.get("name")</code> to access dynamic route parameters and <code>ctx.request.query("name")</code> for query strings:</p>
<ExampleBlock id="dynamic-route" zx_code={zx_example_dynamic} zig_code={zig_example_dynamic} html_code={html_example_dynamic} filename="user_profile.zx" />
<p>Both methods return <code>?[]const u8</code>, so use <code>orelse</code> to provide a default value.</p>
</section>

View File

@ -79,7 +79,7 @@ fn frameworkBySlug(slug: []const u8) ?Framework {
pub fn Page(ctx: zx.PageContext) zx.Component {
const allocator = ctx.arena;
const slug = ctx.request.getParam("framework") orelse "";
const slug = ctx.request.params.get("framework") orelse "";
const maybe_framework = frameworkBySlug(slug);
if (maybe_framework == null) {

View File

@ -87,6 +87,12 @@ cookies: Cookies = .{ .header_value = "" },
///
/// https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
searchParams: URLSearchParams = .{},
/// URL parameters accessor (from route matching).
///
/// **Zig Note:** This is an extension field not present in the web standard Request.
/// Used for accessing dynamic route parameters (e.g., /users/:id -> params.get("id")).
params: Params = .{},
formdata_backend_ctx: ?*anyopaque = null,
formdata_vtable: ?*const FormDataVTable = null,
multiformdata_backend_ctx: ?*anyopaque = null,
@ -125,15 +131,10 @@ vtable: ?*const VTable = null,
pub const VTable = struct {
/// Returns the request body as text.
text: *const fn (ctx: *anyopaque) ?[]const u8 = &defaultText,
/// Returns a URL parameter by name (from route matching).
getParam: *const fn (ctx: *anyopaque, name: []const u8) ?[]const u8 = &defaultGetParam,
fn defaultText(_: *anyopaque) ?[]const u8 {
return null;
}
fn defaultGetParam(_: *anyopaque, _: []const u8) ?[]const u8 {
return null;
}
};
// --- Instance Methods --- //
@ -163,37 +164,10 @@ pub fn json(self: *const Request, comptime T: type, opts: std.json.ParseOptions)
/// Returns a URL parameter by name (from route matching).
///
/// **Zig Note:** This is an extension method not present in the web standard Request.
/// Used for accessing dynamic route parameters (e.g., /users/:id -> getParam("id")).
/// Used for accessing dynamic route parameters (e.g., /users/:id -> params.get("id")).
/// @deprecated Use `params.get(name)` instead.
pub fn getParam(self: *const Request, name: []const u8) ?[]const u8 {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
return vt.getParam(ctx, name);
}
}
return null;
}
/// Returns a typed URL parameter by name (from route matching).
///
/// **Zig Note:** This is an extension method not present in the web standard Request.
/// Used for accessing dynamic route parameters (e.g., /users/:id -> getParam("id")).
pub fn getParamAs(self: *const Request, name: []const u8, comptime T: type) ?T {
const str = self.getParam(name) orelse return null;
return switch (@typeInfo(T)) {
.pointer => |p| switch (p.size) {
.Slice => if (p.child == u8 and p.is_const) str
else @compileError("getParamAs: only []const u8 is supported, got " ++ @typeName(T)),
else => @compileError("getParamAs: only []const u8 is supported, got " ++ @typeName(T)),
},
.int => std.fmt.parseInt(T, str, 10) catch return null,
.float => std.fmt.parseFloat(T, str) catch return null,
.bool => if (std.mem.eql(u8, str, "true") or std.mem.eql(u8, str, "1")) true
else if (std.mem.eql(u8, str, "false") or std.mem.eql(u8, str, "0")) false
else return null,
.@"enum" => std.meta.stringToEnum(T, str) orelse return null,
else => @compileError("getParamAs: unsupported type " ++ @typeName(T)),
};
return self.params.get(name);
}
/// Returns a FormData object representing the URL-encoded form data of the request body.
@ -271,6 +245,50 @@ pub const URLSearchParams = struct {
}
};
// --- Params --- //
/// The Params interface provides utility methods to work with URL parameters.
/// extracted from dynamic routes.
pub const Params = struct {
backend_ctx: ?*anyopaque = null,
vtable: ?*const ParamsVTable = null,
pub const ParamsVTable = struct {
getParam: *const fn (ctx: *anyopaque, name: []const u8) ?[]const u8 = &defaultGetParam,
fn defaultGetParam(_: *anyopaque, _: []const u8) ?[]const u8 {
return null;
}
};
/// Returns the value of a URL parameter by name.
pub fn get(self: *const Params, name: []const u8) ?[]const u8 {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
return vt.getParam(ctx, name);
}
}
return null;
}
/// Returns a typed value of a URL parameter by name.
pub fn as(self: *const Params, name: []const u8, comptime T: type) ?T {
const str = self.get(name) orelse return null;
return switch (@typeInfo(T)) {
.pointer => |p| switch (p.size) {
.Slice => if (p.child == u8 and p.is_const) str else @compileError("Params.as: only []const u8 is supported, got " ++ @typeName(T)),
else => @compileError("Params.as: only []const u8 is supported, got " ++ @typeName(T)),
},
.int => std.fmt.parseInt(T, str, 10) catch return null,
.float => std.fmt.parseFloat(T, str) catch return null,
.bool => if (std.mem.eql(u8, str, "true") or std.mem.eql(u8, str, "1")) true else if (std.mem.eql(u8, str, "false") or std.mem.eql(u8, str, "0")) false else return null,
.@"enum" => std.meta.stringToEnum(T, str) orelse return null,
else => @compileError("Params.as: unsupported type " ++ @typeName(T)),
};
}
};
// --- Headers --- //
// https://developer.mozilla.org/en-US/docs/Web/API/Headers
@ -343,6 +361,8 @@ pub const Builder = struct {
cookie_header: []const u8 = "",
search_params_ctx: ?*anyopaque = null,
search_params_vtable: ?*const URLSearchParams.URLSearchParamsVTable = null,
params_ctx: ?*anyopaque = null,
params_vtable: ?*const Params.ParamsVTable = null,
formdata_ctx: ?*anyopaque = null,
formdata_vtable: ?*const FormDataVTable = null,
multiformdata_ctx: ?*anyopaque = null,
@ -370,6 +390,10 @@ pub const Builder = struct {
.backend_ctx = self.search_params_ctx,
.vtable = self.search_params_vtable,
},
.params = .{
.backend_ctx = self.params_ctx orelse self.backend_ctx,
.vtable = self.params_vtable,
},
.formdata_backend_ctx = self.formdata_ctx,
.formdata_vtable = self.formdata_vtable,
.multiformdata_backend_ctx = self.multiformdata_ctx,

View File

@ -60,6 +60,12 @@ bodyUsed: bool = false,
/// https://developer.mozilla.org/en-US/docs/Web/API/Response/headers
headers: Headers = .{},
/// Cookie accessor for setting/deleting cookies.
///
/// **Zig Note:** This is an extension field not present in the web standard Response.
/// Used for MDN-compliant cookie management (response.cookies.set/delete).
cookies: ResponseCookies = .{},
/// A boolean indicating whether the response was successful (status in the range 200299) or not.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Response/ok
@ -236,14 +242,10 @@ pub fn redirect(self: *const Response, location: []const u8, redirect_status: ?u
/// **Parameters:**
/// - `name`: The cookie name.
/// - `value`: The cookie value.
/// - `options`: Optional cookie options (path, domain, max_age, secure, etc.).
/// - `options`: Optional cookie options (path, domain, max_age, secure, http_only, etc.).
/// @deprecated Use `cookies.set(name, value, options)` instead.
pub fn setCookie(self: *const Response, name: []const u8, value: []const u8, options: ?CookieOptions) void {
const opts = options orelse CookieOptions{};
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
vt.setCookie(ctx, name, value, opts) catch {};
}
}
self.cookies.set(name, value, options);
}
/// **Extension to Web Standard:**
@ -253,10 +255,9 @@ pub fn setCookie(self: *const Response, name: []const u8, value: []const u8, opt
/// **Parameters:**
/// - `name`: The cookie name to delete.
/// - `options`: Optional cookie options (path and domain should match the original cookie).
/// @deprecated Use `cookies.delete(name, options)` instead.
pub fn deleteCookie(self: *const Response, name: []const u8, options: ?CookieOptions) void {
var opts = options orelse CookieOptions{};
opts.max_age = 0; // Setting max-age to 0 deletes the cookie
self.setCookie(name, "", opts);
self.cookies.delete(name, options);
}
/// Gets the response writer for streaming content.
@ -359,6 +360,33 @@ pub const Headers = struct {
}
};
// --- Cookies --- //
/// The ResponseCookies interface provides utility methods to work with cookies on the response.
pub const ResponseCookies = struct {
backend_ctx: ?*anyopaque = null,
vtable: ?*const VTable = null,
/// Sets a cookie on the response.
///
/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies
pub fn set(self: *const ResponseCookies, name: []const u8, value: []const u8, options: ?CookieOptions) void {
const opts = options orelse CookieOptions{};
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
vt.setCookie(ctx, name, value, opts) catch {};
}
}
}
/// Deletes a cookie by setting it with an expired max-age.
pub fn delete(self: *const ResponseCookies, name: []const u8, options: ?CookieOptions) void {
var opts = options orelse CookieOptions{};
opts.max_age = 0;
self.set(name, "", opts);
}
};
// --- Builder (not part of web standard) --- //
/// Builder for creating Response objects.
@ -396,6 +424,10 @@ pub const Builder = struct {
.backend_ctx = self.headers_ctx,
.vtable = self.headers_vtable,
},
.cookies = .{
.backend_ctx = self.backend_ctx,
.vtable = self.vtable,
},
};
}
};

View File

@ -87,7 +87,7 @@ pub const Cookies = struct {
return null;
}
pub fn getAs(self: Cookies, name: []const u8, comptime T: type) ?T {
pub fn as(self: Cookies, name: []const u8, comptime T: type) ?T {
var it = std.mem.splitScalar(u8, self.header_value, ';');
while (it.next()) |kv| {
const trimmed = std.mem.trimLeft(u8, kv, " ");
@ -98,16 +98,13 @@ pub const Cookies = struct {
return switch (@typeInfo(T)) {
.pointer => |p| switch (p.size) {
.Slice => if (p.child == u8 and p.is_const) str
else @compileError("Cookies.getAs: only []const u8 is supported, got " ++ @typeName(T)),
.Slice => if (p.child == u8 and p.is_const) str else @compileError("Cookies.getAs: only []const u8 is supported, got " ++ @typeName(T)),
else => @compileError("Cookies.getAs: only []const u8 is supported, got " ++ @typeName(T)),
},
.int => std.fmt.parseInt(T, str, 10) catch return null,
.int => std.fmt.parseInt(T, str, 10) catch return null,
.float => std.fmt.parseFloat(T, str) catch return null,
.bool => if (std.mem.eql(u8, str, "true") or std.mem.eql(u8, str, "1")) true
else if (std.mem.eql(u8, str, "false") or std.mem.eql(u8, str, "0")) false
else return null,
.@"enum" => std.meta.stringToEnum(T, str) orelse return null,
.bool => if (std.mem.eql(u8, str, "true") or std.mem.eql(u8, str, "1")) true else if (std.mem.eql(u8, str, "false") or std.mem.eql(u8, str, "0")) false else return null,
.@"enum" => std.meta.stringToEnum(T, str) orelse return null,
.@"struct", .@"union" => @compileError("Cookies.getAs: use getJson for struct/union types"),
else => @compileError("Cookies.getAs: unsupported type " ++ @typeName(T)),
};

View File

@ -61,6 +61,8 @@ pub fn createRequest(inner: *httpz.Request) Request {
.cookie_header = inner.headers.get("cookie") orelse "",
.search_params_ctx = @ptrCast(inner),
.search_params_vtable = &search_params_vtable,
.params_ctx = @ptrCast(inner),
.params_vtable = &params_vtable,
.formdata_ctx = @ptrCast(inner),
.formdata_vtable = &formdata_vtable,
.multiformdata_ctx = @ptrCast(inner),
@ -70,6 +72,9 @@ pub fn createRequest(inner: *httpz.Request) Request {
const request_vtable = Request.VTable{
.text = &requestText,
};
const params_vtable = Request.Params.ParamsVTable{
.getParam = &requestGetParam,
};

View File

@ -38,24 +38,25 @@ test "Request: text returns null without backend" {
try std.testing.expect(req.text() == null);
}
test "Request: getParam returns null without backend" {
test "Request: params.get returns null without backend" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const req = (Request.Builder{ .arena = fba.allocator() }).build();
try std.testing.expect(req.getParam("id") == null);
try std.testing.expect(req.params.get("id") == null);
}
test "Request: cookies field returns Cookies accessor" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const req = (Request.Builder{
.arena = fba.allocator(),
.cookie_header = "session=abc123",
.cookie_header = "session=abc123; count=42",
}).build();
try std.testing.expectEqualStrings("abc123", req.cookies.get("session").?);
try std.testing.expectEqual(@as(i32, 42), req.cookies.@"as"("count", i32).?);
}
// --- Headers --- //