feat: web standard Request/Response

This commit is contained in:
Nurul Huda (Apon) 2026-01-04 16:42:16 +06:00
parent d300fb9b7f
commit 4f44aad385
No known key found for this signature in database
GPG Key ID: 5D3F1DE2855A2F79
30 changed files with 2131 additions and 60 deletions

View File

@ -63,11 +63,11 @@ pub const routes = [_]zx.App.Meta.Route{
.page = wrapPage(@import(".zx/pages/examples/streaming/page.zig").Page),
.page_opts = getOptions(@import(".zx/pages/examples/streaming/page.zig"), zx.PageOptions),
},
.{
.path = "/examples/wasm/simple",
.page = wrapPage(@import(".zx/pages/examples/wasm/simple/page.zig").Page),
.page_opts = getOptions(@import(".zx/pages/examples/wasm/simple/page.zig"), zx.PageOptions),
},
// .{
// .path = "/examples/wasm/simple",
// .page = wrapPage(@import(".zx/pages/examples/wasm/simple/page.zig").Page),
// .page_opts = getOptions(@import(".zx/pages/examples/wasm/simple/page.zig"), zx.PageOptions),
// },
.{
.path = "/examples/overview",
.page = wrapPage(@import(".zx/pages/examples/overview/page.zig").Page),

View File

@ -482,7 +482,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.param("name")</code> to access dynamic route parameters and <code>ctx.request.query("name")</code> for query strings:</p>
<p>Use <code>ctx.request.getParam("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

@ -1,5 +1,5 @@
var count = zx.Signal(i32).init(0);
var doubled = zx.Computed(i32, i32).init(&count, doubleValue);
var count: zx.Signal(i32) = .init(0);
var doubled: zx.Computed(i32, i32) = .init(&count, doubleValue);
pub fn CounterComponent(allocator: zx.Allocator) zx.Component {
return (

View File

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

View File

@ -16,7 +16,7 @@ const RequestInfo = struct {
pub fn handleRequest(ctx: zx.PageContext) RequestInfo {
// const fd = ctx.request.formData() catch @panic("OOM");
const qs = ctx.request.query() catch @panic("OOM");
const qs = ctx.request.searchParams;
const is_reset = qs.get("reset") != null;
const is_delete = qs.get("delete") != null;
@ -42,7 +42,7 @@ pub fn handleRequest(ctx: zx.PageContext) RequestInfo {
const filtered_users = filterUsers(ctx.arena, search_opt);
if (is_delete or is_add or is_reset) {
ctx.response.header("Location", "/examples/form");
ctx.response.setHeader("Location", "/examples/form");
ctx.response.setStatus(.found);
}

View File

@ -1,5 +1,6 @@
pub fn Page(ctx: zx.PageContext) zx.Component {
const allocator = ctx.arena;
return (
<div @allocator={allocator} class="todo-container">
<TodoApp @rendering={.client} />
@ -12,6 +13,29 @@ pub fn Page(ctx: zx.PageContext) zx.Component {
);
}
pub const CounterPage = @import("./simple/page.zx").Page;
fn Counter(allocator: zx.Allocator) zx.Component {
const ctx = zx.PageContext{
.request = .{
.url = "",
.method = .GET,
.pathname = "",
.headers = .{},
.arena = allocator,
},
.response = .{ .arena = allocator },
.allocator = allocator,
.arena = allocator,
};
const counter_page = CounterPage(ctx);
return (
<div @allocator={allocator}>
{counter_page}
</div>
);
}
const Todo = struct { id: u32, text: []const u8, completed: bool };
var todos = std.ArrayList(Todo).empty;
@ -22,6 +46,7 @@ pub fn TodoApp(allocator: zx.Allocator) zx.Component {
<div @allocator={allocator} class="todo-app">
<div class="todo-header">
<h1 class="todo-title">ZX + WASM</h1>
<Counter />
<p class="todo-subtitle">A demo todo app built with ZX Client Side Rendering</p>
</div>

View File

@ -1,7 +1,7 @@
pub fn Page(ctx: zx.PageContext) zx.Component {
return (
<div @allocator={ctx.arena}>
<CounterComponent @rendering={.client} />
<CounterComponent />
<script type="module" src="/assets/main.js" />
</div>

View File

@ -1,10 +1,24 @@
pub fn Layout(ctx: zx.LayoutContext, children: zx.Component) zx.Component {
const path = ctx.request.pathname;
const description = "A full-stack web framework for Zig. Write declarative UI components using familiar JSX patterns, transpiled to efficient Zig code.";
const title = "ZX - Full-Stack Web Framework for Zig";
const site_url = "https://ziex.dev";
const keywords = "zig, zx, web framework, jsx, full-stack, ssr, ssg, wasm, components, server-side rendering, static site generation";
const js_href = std.fmt.allocPrint(ctx.arena, "/assets/docs.js?v={s}", .{zx.info.version_string}) catch unreachable;
const needs_js = !std.mem.eql(u8, ctx.request.url.path, "/");
const needs_js = !std.mem.eql(u8, path, "/");
if (std.mem.eql(u8, path, "/overview") or std.mem.eql(u8, path, "/time")) {
return (
<html lang="en" @allocator={ctx.arena}>
<head>
<title>{title}</title>
</head>
<body>
{children}
</body>
</html>
);
}
return (
<html lang="en" @allocator={ctx.arena}>
@ -13,7 +27,7 @@ pub fn Layout(ctx: zx.LayoutContext, children: zx.Component) zx.Component {
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="description" content={description} />
<meta name="keywords" content={keywords} />
<meta name="author" content="Nurul Huda" />
<meta name="robots" content="index, follow" />
@ -48,9 +62,13 @@ pub fn Layout(ctx: zx.LayoutContext, children: zx.Component) zx.Component {
}
fn getCss(ctx: zx.LayoutContext) []const u8 {
const path = ctx.request.url.path;
return if (std.mem.eql(u8, path, "/"))
const req_path = ctx.request.pathname;
return if (std.mem.eql(u8, req_path, "/"))
@embedFile("../assets/home.css")
else if (std.mem.eql(u8, req_path, "/overview"))
""
else if (std.mem.eql(u8, req_path, "/time"))
""
else
@embedFile("../assets/docs.css");
}

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.param("id") orelse "unknown";
const user_id = ctx.request.getParam("id") orelse "unknown";
var _zx = zx.allocInit(ctx.arena);
return _zx.ele(

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.param("id") orelse "unknown";
const user_id = ctx.request.getParam("id") orelse "unknown";
return (
<main @allocator={ctx.arena}>

View File

@ -3,7 +3,7 @@ pub fn Page(ctx: zx.PageContext) zx.Component {
<main @allocator={ctx.arena}>
<h1>About Us</h1>
<p>Welcome to our website!</p>
<p>Path: {ctx.request.url.path}</p>
<p>Path: {ctx.request.pathname}</p>
</main>
);
}

View File

@ -567,7 +567,7 @@ fn PagesAndLayouts(allocator: zx.Allocator, props: PagesAndLayoutsProps) zx.Comp
<li><code>site/pages/user/[id]/page.zx</code> <code>/user/:id</code></li>
<li><code>site/pages/blog/[slug]/page.zx</code> <code>/blog/:slug</code></li>
</ul>
<p>Access the parameter value using <code>ctx.request.param("name")</code>:</p>
<p>Access the parameter value using <code>ctx.request.getParam("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>

162
src/app/Headers.zig Normal file
View File

@ -0,0 +1,162 @@
//! MDN Web API compliant Headers abstraction.
//! This module is backend-agnostic. The actual implementation is provided via vtable.
//! https://developer.mozilla.org/en-US/docs/Web/API/Headers
const std = @import("std");
const common = @import("common.zig");
pub const Headers = @This();
// Re-export types from std.http
pub const Header = common.Header;
/// @deprecated Use `Header` instead.
pub const Entry = Header;
/// HTTP header iterator for parsing raw header bytes.
/// Re-exported from std.http.HeaderIterator for convenience.
///
/// This is useful when you have raw HTTP protocol bytes and need to parse headers.
/// For iterating over headers from a backend, use the `entries()` method which
/// returns the vtable-based `Iterator`.
///
/// [Zig std.http Reference](https://ziglang.org/documentation/master/std/#std.http.HeaderIterator)
pub const HeaderIterator = std.http.HeaderIterator;
// --- Headers Data --- //
/// Backend-specific context pointer (null for WASM/client-side)
backend_ctx: ?*anyopaque = null,
/// VTable for backend-specific operations
vtable: ?*const VTable = null,
/// Whether this Headers instance is read-only (from request)
read_only: bool = true,
pub const VTable = struct {
get: *const fn (ctx: *anyopaque, name: []const u8) ?[]const u8,
has: *const fn (ctx: *anyopaque, name: []const u8) bool,
set: *const fn (ctx: *anyopaque, name: []const u8, value: []const u8) void,
append: *const fn (ctx: *anyopaque, name: []const u8, value: []const u8) void,
iterate: *const fn (ctx: *anyopaque) ?Iterator,
};
// --- Headers Methods --- //
/// Returns true if this Headers instance is read-only (from request).
pub fn isReadOnly(self: *const Headers) bool {
return self.read_only;
}
/// Returns the value of the header with the specified name.
pub fn get(self: *const Headers, name: []const u8) ?[]const u8 {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
return vt.get(ctx, name);
}
}
return null;
}
/// Returns whether a header with the specified name exists.
pub fn has(self: *const Headers, name: []const u8) bool {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
return vt.has(ctx, name);
}
}
return false;
}
/// Appends a new value onto an existing header (response only, no-op for request).
/// https://developer.mozilla.org/en-US/docs/Web/API/Headers/append
pub fn append(self: *Headers, name: []const u8, value: []const u8) void {
if (self.read_only) return;
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
vt.append(ctx, name, value);
}
}
}
/// Sets a header value (response only, no-op for request).
/// https://developer.mozilla.org/en-US/docs/Web/API/Headers/set
pub fn set(self: *Headers, name: []const u8, value: []const u8) void {
if (self.read_only) return;
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
vt.set(ctx, name, value);
}
}
}
/// Deletes a header. Note: Most backends don't support header deletion.
/// https://developer.mozilla.org/en-US/docs/Web/API/Headers/delete
pub fn delete(self: *Headers, name: []const u8) void {
_ = self;
_ = name;
// Most backends don't support deletion - no-op
}
/// Returns an iterator over all key/value pairs.
pub fn entries(self: *const Headers) ?Iterator {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
return vt.iterate(ctx);
}
}
return null;
}
/// Executes a provided function once for each header.
/// https://developer.mozilla.org/en-US/docs/Web/API/Headers/forEach
pub fn forEach(self: *const Headers, callback: *const fn (value: []const u8, key: []const u8) void) void {
if (self.entries()) |*iter| {
var it = iter.*;
while (it.next()) |entry| {
callback(entry.value, entry.name);
}
}
}
// --- Iterator (vtable-based, for backend abstraction) --- //
/// Backend-agnostic iterator for iterating over headers.
///
/// Example:
/// ```zig
/// if (headers.entries()) |*iter| {
/// while (iter.next()) |header| {
/// std.debug.print("{s}: {s}\n", .{ header.name, header.value });
/// }
/// }
/// ```
pub const Iterator = struct {
backend_ctx: ?*anyopaque = null,
nextFn: ?*const fn (ctx: *anyopaque) ?Header = null,
/// Returns the next header, or null if iteration is complete.
pub fn next(self: *Iterator) ?Header {
if (self.nextFn) |nextFunc| {
if (self.backend_ctx) |ctx| {
return nextFunc(ctx);
}
}
return null;
}
};
// --- Builder --- //
pub const Builder = struct {
backend_ctx: ?*anyopaque = null,
vtable: ?*const VTable = null,
read_only: bool = true,
pub fn build(self: Builder) Headers {
return .{
.backend_ctx = self.backend_ctx,
.vtable = self.vtable,
.read_only = self.read_only,
};
}
};

309
src/app/Request.zig Normal file
View File

@ -0,0 +1,309 @@
//! The Request interface of the Fetch API represents a resource request.
//!
//! You can create a new Request object using the Request.Builder, but you are more likely
//! to encounter a Request object being returned as part of another API operation, such as
//! a page handler context receiving an incoming HTTP request.
//!
//! This module is backend-agnostic. The actual implementation is provided via vtable.
//!
//! https://developer.mozilla.org/en-US/docs/Web/API/Request
const std = @import("std");
const common = @import("common.zig");
pub const Request = @This();
// Re-export common types for convenience
pub const Method = common.Method;
pub const Version = common.Version;
pub const Protocol = common.Protocol; // Deprecated alias for Version
pub const Cookies = common.Cookies;
pub const Header = common.Header;
/// @deprecated Use `Header` instead.
pub const Entry = Header;
pub const MultiFormEntry = common.MultiFormEntry;
// --- Instance Properties --- //
// https://developer.mozilla.org/en-US/docs/Web/API/Request#instance_properties
/// Contains the URL of the request.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Request/url
url: []const u8,
/// Contains the request's method (GET, POST, etc.).
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Request/method
///
/// **Zig Note:** In the web standard, this is a string. In this implementation,
/// it is represented as a `Method` enum for type safety. The original string
/// is available via `method_str`.
method: Method,
/// Contains the request's method as a string.
///
/// **Zig Note:** This is an extension field. In the web standard, `method` is
/// already a string. This field preserves the original string representation.
method_str: []const u8 = "",
/// Contains the pathname portion of the URL.
///
/// **Zig Note:** This is an extension field not present in the web standard Request.
/// In the web standard, you would parse the URL to get the pathname.
/// Example: https://foo.com/bar/baz -> /bar/baz
pathname: []const u8,
/// Contains the referrer of the request (e.g., client, no-referrer, or a URL).
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Request/referrer
///
/// **Zig Note:** In the web standard, this defaults to "about:client". In this
/// implementation, it defaults to an empty string.
referrer: []const u8 = "",
/// Contains the search/query string portion of the URL.
///
/// **Zig Note:** This is an extension field not present in the web standard Request.
/// In the web standard, you would parse the URL to get the search string.
/// Example: https://foo.com/bar?q=qux -> q=qux
search: []const u8 = "",
/// Contains the associated Headers object of the request.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Request/headers
headers: Headers,
/// Cookie accessor for parsing cookies from the Cookie header.
///
/// **Zig Note:** This is an extension field not present in the web standard Request.
/// In the web standard, you would access cookies via `document.cookie` or parse
/// the Cookie header manually.
cookies: Cookies = .{ .header_value = "" },
/// URL search parameters accessor.
///
/// **Zig Note:** This is an extension field. In the web standard, you would use
/// `new URL(request.url).searchParams` to access search parameters.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
searchParams: URLSearchParams = .{},
/// HTTP protocol version (HTTP/1.0 or HTTP/1.1).
///
/// **Zig Note:** This is an extension field not present in the web standard Request.
/// The web standard Request doesn't expose the HTTP protocol version.
/// Uses `std.http.Version` from the standard library.
protocol: Version = .@"HTTP/1.1",
// --- Internal Fields (not part of web standard) --- //
/// Arena allocator for request-scoped allocations.
///
/// **Zig Note:** This is an internal field not present in the web standard.
/// Used for memory management in the Zig implementation.
arena: std.mem.Allocator,
/// Backend-specific context pointer (null for WASM/client-side).
///
/// **Zig Note:** This is an internal field not present in the web standard.
/// Provides the vtable pattern for backend abstraction.
backend_ctx: ?*anyopaque = null,
/// VTable for backend-specific operations.
///
/// **Zig Note:** This is an internal field not present in the web standard.
/// Allows different HTTP server backends to implement request operations.
vtable: ?*const VTable = null,
/// VTable interface for backend-specific request operations.
///
/// **Zig Note:** This is an internal type not present in the web standard.
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 --- //
// https://developer.mozilla.org/en-US/docs/Web/API/Request#instance_methods
/// Returns a promise that resolves with a text representation of the request body.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Request/text
///
/// **Zig Note:** In the web standard, this returns a Promise<string>. In this
/// implementation, it returns `?[]const u8` synchronously (null if no body).
pub fn text(self: *const Request) ?[]const u8 {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
return vt.text(ctx);
}
}
return null;
}
/// 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")).
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;
}
// --- URLSearchParams --- //
// https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
/// The URLSearchParams interface defines utility methods to work with the
/// query string of a URL.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
///
/// **Zig Note:** This is a simplified implementation that delegates to the backend.
/// Not all methods from the web standard URLSearchParams interface are implemented.
pub const URLSearchParams = struct {
backend_ctx: ?*anyopaque = null,
vtable: ?*const URLSearchParamsVTable = null,
pub const URLSearchParamsVTable = struct {
get: *const fn (ctx: *anyopaque, name: []const u8) ?[]const u8,
has: *const fn (ctx: *anyopaque, name: []const u8) bool,
};
/// Returns the first value associated with the given search parameter.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/get
///
/// **Zig Note:** Returns `?[]const u8` instead of `string | null`.
pub fn get(self: *const URLSearchParams, name: []const u8) ?[]const u8 {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
return vt.get(ctx, name);
}
}
return null;
}
/// Returns a boolean indicating if such a given parameter exists.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/has
pub fn has(self: *const URLSearchParams, name: []const u8) bool {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
return vt.has(ctx, name);
}
}
return false;
}
};
// --- Headers --- //
// https://developer.mozilla.org/en-US/docs/Web/API/Headers
/// The Headers interface of the Fetch API allows you to perform various actions
/// on HTTP request and response headers.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Headers
///
/// **Zig Note:** This is a simplified read-only implementation for request headers.
/// Not all methods from the web standard Headers interface are implemented.
pub const Headers = struct {
backend_ctx: ?*anyopaque = null,
vtable: ?*const HeadersVTable = null,
pub const HeadersVTable = struct {
get: *const fn (ctx: *anyopaque, name: []const u8) ?[]const u8,
has: *const fn (ctx: *anyopaque, name: []const u8) bool,
};
/// Returns a String sequence of all the values of a header within a Headers
/// object with a given name.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Headers/get
///
/// **Zig Note:** Returns `?[]const u8` instead of a string, returning `null`
/// if the header is not found.
pub fn get(self: *const Headers, name: []const u8) ?[]const u8 {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
return vt.get(ctx, name);
}
}
return null;
}
/// Returns a boolean stating whether a Headers object contains a certain header.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Headers/has
pub fn has(self: *const Headers, name: []const u8) bool {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
return vt.has(ctx, name);
}
}
return false;
}
};
// --- Builder (not part of web standard) --- //
/// Builder for creating Request objects.
///
/// **Zig Note:** This is an internal type not present in the web standard.
/// In the web standard, you would use the `new Request(input, init)` constructor.
/// This builder pattern is used for backend implementations to construct
/// Request objects with the appropriate vtable and context.
pub const Builder = struct {
url: []const u8 = "",
method: Method = .GET,
method_str: []const u8 = "GET",
pathname: []const u8 = "/",
referrer: []const u8 = "",
search: []const u8 = "",
protocol: Version = .@"HTTP/1.1",
arena: std.mem.Allocator,
backend_ctx: ?*anyopaque = null,
vtable: ?*const VTable = null,
headers_ctx: ?*anyopaque = null,
headers_vtable: ?*const Headers.HeadersVTable = null,
cookie_header: []const u8 = "",
search_params_ctx: ?*anyopaque = null,
search_params_vtable: ?*const URLSearchParams.URLSearchParamsVTable = null,
/// Builds the Request object with all configured values.
pub fn build(self: Builder) Request {
return .{
.url = self.url,
.method = self.method,
.method_str = self.method_str,
.pathname = self.pathname,
.referrer = self.referrer,
.search = self.search,
.protocol = self.protocol,
.arena = self.arena,
.backend_ctx = self.backend_ctx,
.vtable = self.vtable,
.headers = .{
.backend_ctx = self.headers_ctx,
.vtable = self.headers_vtable,
},
.cookies = .{ .header_value = self.cookie_header },
.searchParams = .{
.backend_ctx = self.search_params_ctx,
.vtable = self.search_params_vtable,
},
};
}
};

350
src/app/Response.zig Normal file
View File

@ -0,0 +1,350 @@
//! The Response interface of the Fetch API represents the response to a request.
//!
//! You can create a new Response object using the Response.Builder, but you are more likely
//! to encounter a Response object being returned as the result of another API operationfor
//! example, a page handler context or a fetch() call.
//!
//! This module is backend-agnostic. The actual implementation is provided via vtable.
//!
//! https://developer.mozilla.org/en-US/docs/Web/API/Response
const std = @import("std");
const common = @import("common.zig");
pub const Response = @This();
// Re-export common types for convenience
pub const CookieOptions = common.CookieOptions;
pub const ContentType = common.ContentType;
pub const HttpStatus = common.HttpStatus;
/// The type of the response.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Response/type
///
/// **Values:**
/// - `basic`: Normal, same origin response, with all headers exposed except "Set-Cookie".
/// - `cors`: Response was received from a valid cross-origin request.
/// - `default`: Default response type (used when response type is not explicitly set).
/// - `error`: Network error. No useful information describing the error is available.
/// - `opaque`: Response for "no-cors" request to cross-origin resource.
/// - `opaqueredirect`: The fetch request was made with redirect: "manual".
pub const ResponseType = enum {
basic,
cors,
default,
@"error",
@"opaque",
opaqueredirect,
};
// --- Instance Properties --- //
// https://developer.mozilla.org/en-US/docs/Web/API/Response#instance_properties
/// A ReadableStream of the body contents.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Response/body
///
/// **Zig Note:** In the web standard, this is a ReadableStream. In this implementation,
/// it is represented as `[]const u8` (a byte slice) for simplicity.
body: []const u8 = "",
/// Stores a boolean value that declares whether the body has been used in a response yet.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Response/bodyUsed
bodyUsed: bool = false,
/// The Headers object associated with the response.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Response/headers
headers: Headers = .{},
/// 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
ok: bool = true,
/// Indicates whether or not the response is the result of a redirect
/// (that is, its URL list has more than one entry).
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Response/redirected
redirected: bool = false,
/// The status code of the response (e.g., 200 for a success).
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Response/status
status: u16 = 200,
/// The status message corresponding to the status code (e.g., "OK" for 200).
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Response/statusText
statusText: []const u8 = "OK",
/// The type of the response (e.g., basic, cors).
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Response/type
///
/// **Zig Note:** In the web standard, this is a string. In this implementation,
/// it is represented as a `ResponseType` enum for type safety.
type: ResponseType = .default,
/// The URL of the response.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Response/url
url: []const u8 = "",
// --- Internal Fields (not part of web standard) --- //
/// Arena allocator for response-scoped allocations.
///
/// **Zig Note:** This is an internal field not present in the web standard.
/// Used for memory management in the Zig implementation.
arena: std.mem.Allocator,
/// Backend-specific context pointer (null for WASM/client-side).
///
/// **Zig Note:** This is an internal field not present in the web standard.
/// Provides the vtable pattern for backend abstraction.
backend_ctx: ?*anyopaque = null,
/// VTable for backend-specific operations.
///
/// **Zig Note:** This is an internal field not present in the web standard.
/// Allows different HTTP server backends to implement response operations.
vtable: ?*const VTable = null,
/// VTable interface for backend-specific response operations.
///
/// **Zig Note:** This is an internal type not present in the web standard.
pub const VTable = struct {
/// Sets the response status code.
setStatus: *const fn (ctx: *anyopaque, code: u16) void,
/// Sets the response body.
setBody: *const fn (ctx: *anyopaque, content: []const u8) void,
/// Sets a header.
setHeader: *const fn (ctx: *anyopaque, name: []const u8, value: []const u8) void,
/// Gets a writer for streaming.
getWriter: *const fn (ctx: *anyopaque) ?std.io.AnyWriter,
/// Writes a chunk for chunked transfer.
writeChunk: *const fn (ctx: *anyopaque, data: []const u8) anyerror!void,
/// Clears the response writer/buffer.
clearWriter: *const fn (ctx: *anyopaque) void,
};
// --- Methods --- //
/// Sets the HTTP status code using an HttpStatus enum.
///
/// **Zig Note:** This is an extension method not present in the web standard Response
/// interface (which has read-only status). This method updates the backend response.
/// The local `status`, `statusText`, and `ok` fields reflect the initial state;
/// use the backend for the source of truth after mutation.
pub fn setStatus(self: *const Response, stat: HttpStatus) void {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
vt.setStatus(ctx, @intFromEnum(stat));
}
}
}
/// Sets the HTTP status code using a raw u16 value.
///
/// **Zig Note:** This is an extension method not present in the web standard Response
/// interface (which has read-only status). This method updates the backend response.
pub fn setStatusCode(self: *const Response, code: u16) void {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
vt.setStatus(ctx, code);
}
}
}
/// Sets the response body directly.
///
/// **Zig Note:** This is an extension method not present in the web standard Response
/// interface (which has read-only body). This method updates the backend response.
pub fn setBody(self: *const Response, content: []const u8) void {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
vt.setBody(ctx, content);
}
}
}
/// Sets a header on the response.
///
/// **Zig Note:** This is an extension method. In the web standard, you would use
/// `response.headers.set(name, value)` on the Headers object instead.
pub fn setHeader(self: *const Response, name: []const u8, value: []const u8) void {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
vt.setHeader(ctx, name, value);
}
}
}
/// Sets the Content-Type header.
///
/// **Zig Note:** This is a convenience method not present in the web standard.
/// Equivalent to `setHeader("Content-Type", content_type.toString())`.
pub fn setContentType(self: *const Response, content_type: ContentType) void {
self.setHeader("Content-Type", content_type.toString());
}
/// Creates a redirect response by setting the Location header and status code.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static
///
/// **Zig Note:** In the web standard, `Response.redirect()` is a static method that
/// creates a new Response object. In this implementation, it modifies the current
/// response by setting the Location header and status code on the backend.
///
/// **Parameters:**
/// - `location`: The URL to redirect to.
/// - `redirect_status`: Optional status code (default: 302 Found).
pub fn redirect(self: *const Response, location: []const u8, redirect_status: ?u16) void {
const code = redirect_status orelse 302;
self.setStatusCode(code);
self.setHeader("Location", location);
}
/// Gets the response writer for streaming content.
///
/// **Zig Note:** This is an extension method not present in the web standard.
/// Used for server-side streaming responses.
pub fn writer(self: *const Response) ?std.io.AnyWriter {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
return vt.getWriter(ctx);
}
}
return null;
}
/// Writes a chunk for chunked transfer encoding.
///
/// **Zig Note:** This is an extension method not present in the web standard.
/// Used for server-side streaming/chunked responses.
pub fn chunk(self: *const Response, data: []const u8) !void {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
try vt.writeChunk(ctx, data);
}
}
}
/// Clears the response writer/buffer.
///
/// **Zig Note:** This is an extension method not present in the web standard.
/// Used for server-side response management.
pub fn clearWriter(self: *const Response) void {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
vt.clearWriter(ctx);
}
}
}
// --- Headers --- //
// https://developer.mozilla.org/en-US/API/Headers
/// The Headers interface of the Fetch API allows you to perform various actions
/// on HTTP request and response headers.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Headers
///
/// **Zig Note:** This is a simplified implementation that delegates to the backend.
/// Not all methods from the web standard Headers interface are implemented.
pub const Headers = struct {
backend_ctx: ?*anyopaque = null,
vtable: ?*const HeadersVTable = null,
pub const HeadersVTable = struct {
get: *const fn (ctx: *anyopaque, name: []const u8) ?[]const u8,
set: *const fn (ctx: *anyopaque, name: []const u8, value: []const u8) void,
add: *const fn (ctx: *anyopaque, name: []const u8, value: []const u8) void,
};
/// Returns a String sequence of all the values of a header within a Headers
/// object with a given name.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Headers/get
///
/// **Zig Note:** Returns `?[]const u8` instead of a string, returning `null`
/// if the header is not found.
pub fn get(self: *const Headers, name: []const u8) ?[]const u8 {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
return vt.get(ctx, name);
}
}
return null;
}
/// Sets a new value for an existing header inside a Headers object,
/// or adds the header if it does not already exist.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Headers/set
pub fn set(self: *const Headers, name: []const u8, value: []const u8) void {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
vt.set(ctx, name, value);
}
}
}
/// Appends a new value onto an existing header inside a Headers object,
/// or adds the header if it does not already exist.
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Headers/append
///
/// **Zig Note:** Named `add` instead of `append` in this implementation.
pub fn add(self: *const Headers, name: []const u8, value: []const u8) void {
if (self.vtable) |vt| {
if (self.backend_ctx) |ctx| {
vt.add(ctx, name, value);
}
}
}
};
// --- Builder (not part of web standard) --- //
/// Builder for creating Response objects.
///
/// **Zig Note:** This is an internal type not present in the web standard.
/// In the web standard, you would use the `new Response(body, init)` constructor.
/// This builder pattern is used for backend implementations to construct
/// Response objects with the appropriate vtable and context.
pub const Builder = struct {
status: u16 = 200,
redirected: bool = false,
url: []const u8 = "",
response_type: ResponseType = .default,
arena: std.mem.Allocator,
backend_ctx: ?*anyopaque = null,
vtable: ?*const VTable = null,
headers_ctx: ?*anyopaque = null,
headers_vtable: ?*const Headers.HeadersVTable = null,
/// Builds the Response object with all configured values.
pub fn build(self: Builder) Response {
return .{
.body = "",
.bodyUsed = false,
.ok = self.status >= 200 and self.status <= 299,
.redirected = self.redirected,
.status = self.status,
.statusText = common.statusCodeToText(self.status),
.type = self.response_type,
.url = self.url,
.arena = self.arena,
.backend_ctx = self.backend_ctx,
.vtable = self.vtable,
.headers = .{
.backend_ctx = self.headers_ctx,
.vtable = self.headers_vtable,
},
};
}
};

291
src/app/adapter.zig Normal file
View File

@ -0,0 +1,291 @@
//! httpz backend adapter.
//! Creates abstract Request/Response from httpz types.
//!
//! This adapter converts between httpz-specific types and std.http types
//! used in the abstract Request/Response layer.
const std = @import("std");
const httpz = @import("httpz");
const Request = @import("Request.zig");
const Response = @import("Response.zig");
const Headers = @import("Headers.zig");
const common = @import("common.zig");
// --- Type Conversion Helpers --- //
/// Converts httpz.Method to std.http.Method.
/// Note: httpz has OTHER for unknown methods, std.http.Method doesn't.
/// OTHER is mapped to GET as a fallback (the original method string is preserved in method_str).
fn convertMethod(method: httpz.Method) std.http.Method {
return switch (method) {
.GET => .GET,
.HEAD => .HEAD,
.POST => .POST,
.PUT => .PUT,
.DELETE => .DELETE,
.CONNECT => .CONNECT,
.OPTIONS => .OPTIONS,
.PATCH => .PATCH,
// Default to CONNECT but the original method string is preserved in method_str
.OTHER => .CONNECT, //TODO: figure out better way to handle this
};
}
/// Converts httpz.Protocol to std.http.Version.
fn convertProtocol(protocol: httpz.Protocol) std.http.Version {
return switch (protocol) {
.HTTP10 => .@"HTTP/1.0",
.HTTP11 => .@"HTTP/1.1",
};
}
// --- Request Adapter --- //
/// Creates an abstract Request from an httpz.Request
pub fn createRequest(inner: *httpz.Request) Request {
return (Request.Builder{
.url = inner.url.raw,
.method = convertMethod(inner.method),
.method_str = inner.method_string,
.pathname = inner.url.path,
.referrer = inner.headers.get("referer") orelse "",
.search = inner.url.query,
.protocol = convertProtocol(inner.protocol),
.arena = inner.arena,
.backend_ctx = @ptrCast(inner),
.vtable = &request_vtable,
.headers_ctx = @ptrCast(inner),
.headers_vtable = &request_headers_vtable,
.cookie_header = inner.headers.get("cookie") orelse "",
.search_params_ctx = @ptrCast(inner),
.search_params_vtable = &search_params_vtable,
}).build();
}
const request_vtable = Request.VTable{
.text = &requestText,
.getParam = &requestGetParam,
};
fn requestText(ctx: *anyopaque) ?[]const u8 {
const req: *httpz.Request = @ptrCast(@alignCast(ctx));
return req.body();
}
fn requestGetParam(ctx: *anyopaque, name: []const u8) ?[]const u8 {
const req: *httpz.Request = @ptrCast(@alignCast(ctx));
return req.param(name);
}
const request_headers_vtable = Request.Headers.HeadersVTable{
.get = &requestHeadersGet,
.has = &requestHeadersHas,
};
fn requestHeadersGet(ctx: *anyopaque, name: []const u8) ?[]const u8 {
const req: *httpz.Request = @ptrCast(@alignCast(ctx));
return req.headers.get(name);
}
fn requestHeadersHas(ctx: *anyopaque, name: []const u8) bool {
const req: *httpz.Request = @ptrCast(@alignCast(ctx));
return req.headers.has(name);
}
const search_params_vtable = Request.URLSearchParams.URLSearchParamsVTable{
.get = &searchParamsGet,
.has = &searchParamsHas,
};
fn searchParamsGet(ctx: *anyopaque, name: []const u8) ?[]const u8 {
const req: *httpz.Request = @ptrCast(@alignCast(ctx));
const query = req.query() catch return null;
return query.get(name);
}
fn searchParamsHas(ctx: *anyopaque, name: []const u8) bool {
const req: *httpz.Request = @ptrCast(@alignCast(ctx));
const query = req.query() catch return false;
return query.has(name);
}
// --- Response Adapter --- //
/// Creates an abstract Response from an httpz.Response
pub fn createResponse(inner: *httpz.Response, arena: std.mem.Allocator) Response {
return (Response.Builder{
.status = inner.status,
.arena = arena,
.backend_ctx = @ptrCast(inner),
.vtable = &response_vtable,
.headers_ctx = @ptrCast(inner),
.headers_vtable = &response_headers_vtable,
}).build();
}
const response_vtable = Response.VTable{
.setStatus = &responseSetStatus,
.setBody = &responseSetBody,
.setHeader = &responseSetHeader,
.getWriter = &responseGetWriter,
.writeChunk = &responseWriteChunk,
.clearWriter = &responseClearWriter,
};
fn responseSetStatus(ctx: *anyopaque, status: u16) void {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
res.status = status;
}
fn responseSetBody(ctx: *anyopaque, content: []const u8) void {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
res.body = content;
}
fn responseSetHeader(ctx: *anyopaque, name: []const u8, value: []const u8) void {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
res.header(name, value);
}
fn responseGetWriter(ctx: *anyopaque) ?std.io.AnyWriter {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
return .{ .context = @ptrCast(res), .writeFn = httpzWriteFn };
}
fn httpzWriteFn(context: *const anyopaque, buffer: []const u8) anyerror!usize {
const res: *httpz.Response = @ptrCast(@alignCast(@constCast(context)));
res.writer().writeAll(buffer) catch |err| return err;
return buffer.len;
}
fn responseWriteChunk(ctx: *anyopaque, data: []const u8) anyerror!void {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
try res.chunk(data);
}
fn responseClearWriter(ctx: *anyopaque) void {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
res.clearWriter();
}
const response_headers_vtable = Response.Headers.HeadersVTable{
.get = &responseHeadersGet,
.set = &responseHeadersSet,
.add = &responseHeadersAdd,
};
fn responseHeadersGet(ctx: *anyopaque, name: []const u8) ?[]const u8 {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
return res.headers.get(name);
}
fn responseHeadersSet(ctx: *anyopaque, name: []const u8, value: []const u8) void {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
res.header(name, value);
}
fn responseHeadersAdd(ctx: *anyopaque, name: []const u8, value: []const u8) void {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
res.headers.add(name, value);
}
// --- Headers Adapter (standalone, for use with both request/response) --- //
pub fn createRequestHeaders(inner: *httpz.Request) Headers {
return (Headers.Builder{
.backend_ctx = @ptrCast(inner),
.vtable = &headers_request_vtable,
.read_only = true,
}).build();
}
pub fn createResponseHeaders(inner: *httpz.Response) Headers {
return (Headers.Builder{
.backend_ctx = @ptrCast(inner),
.vtable = &headers_response_vtable,
.read_only = false,
}).build();
}
const headers_request_vtable = Headers.VTable{
.get = &headersRequestGet,
.has = &headersRequestHas,
.set = &headersNoOpSet,
.append = &headersNoOpAppend,
.iterate = &headersRequestIterate,
};
const headers_response_vtable = Headers.VTable{
.get = &headersResponseGet,
.has = &headersResponseHas,
.set = &headersResponseSet,
.append = &headersResponseAppend,
.iterate = &headersResponseIterate,
};
fn headersRequestGet(ctx: *anyopaque, name: []const u8) ?[]const u8 {
const req: *httpz.Request = @ptrCast(@alignCast(ctx));
return req.headers.get(name);
}
fn headersRequestHas(ctx: *anyopaque, name: []const u8) bool {
const req: *httpz.Request = @ptrCast(@alignCast(ctx));
return req.headers.has(name);
}
fn headersNoOpSet(_: *anyopaque, _: []const u8, _: []const u8) void {}
fn headersNoOpAppend(_: *anyopaque, _: []const u8, _: []const u8) void {}
fn headersRequestIterate(ctx: *anyopaque) ?Headers.Iterator {
const req: *httpz.Request = @ptrCast(@alignCast(ctx));
_ = req;
// Note: Would need to store iterator state somewhere accessible
// For now, return null - iteration not supported via vtable
return null;
}
fn headersResponseGet(ctx: *anyopaque, name: []const u8) ?[]const u8 {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
return res.headers.get(name);
}
fn headersResponseHas(ctx: *anyopaque, name: []const u8) bool {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
return res.headers.has(name);
}
fn headersResponseSet(ctx: *anyopaque, name: []const u8, value: []const u8) void {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
res.header(name, value);
}
fn headersResponseAppend(ctx: *anyopaque, name: []const u8, value: []const u8) void {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
res.headers.add(name, value);
}
fn headersResponseIterate(ctx: *anyopaque) ?Headers.Iterator {
const res: *httpz.Response = @ptrCast(@alignCast(ctx));
_ = res;
// Note: Would need to store iterator state somewhere accessible
// For now, return null - iteration not supported via vtable
return null;
}
// --- Utility: Get underlying httpz types (for code that still needs them) --- //
/// Get the underlying httpz.Request from an abstract Request
pub fn getHttpzRequest(request: *const Request) ?*httpz.Request {
if (request.backend_ctx) |ctx| {
return @ptrCast(@alignCast(ctx));
}
return null;
}
/// Get the underlying httpz.Response from an abstract Response
pub fn getHttpzResponse(response: *const Response) ?*httpz.Response {
if (response.backend_ctx) |ctx| {
return @ptrCast(@alignCast(ctx));
}
return null;
}

220
src/app/common.zig Normal file
View File

@ -0,0 +1,220 @@
//! Common types for the app module.
//! These types are backend-independent and can be used with any HTTP server.
//!
//! This module re-exports types from `std.http` where available for consistency
//! with Zig's standard library.
const std = @import("std");
// --- HTTP Header (from std.http) --- //
/// HTTP header name/value pair - re-exported from std.http.Header for consistency.
///
/// Fields:
/// - `name`: The header name (e.g., "Content-Type", "Authorization")
/// - `value`: The header value
///
/// **Zig Note:** This uses `name` (as per RFC 7230) rather than `key`.
pub const Header = std.http.Header;
/// Alias for backward compatibility.
/// @deprecated Use `Header` instead.
pub const Entry = Header;
/// HTTP header iterator for parsing raw header bytes.
/// Re-exported from std.http.HeaderIterator for convenience.
///
/// Useful for parsing HTTP headers from raw bytes. Initializes with `init(bytes)`
/// and iterates via `next()` returning `?Header`.
pub const HeaderIterator = std.http.HeaderIterator;
/// Entry type for multipart form data (includes optional filename).
pub const MultiFormEntry = struct {
key: []const u8,
value: []const u8,
filename: ?[]const u8,
};
// --- HTTP Method (from std.http) --- //
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
/// HTTP request methods - re-exported from std.http.Method for convenience.
///
/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
///
/// Includes useful methods:
/// - `requestHasBody()`: Returns true if request of this method can have a body
/// - `responseHasBody()`: Returns true if response to this method can have a body
/// - `safe()`: Returns true if this method doesn't alter server state
/// - `idempotent()`: Returns true if identical requests have the same effect
/// - `cacheable()`: Returns true if response can be cached
///
/// **Note:** Unlike some HTTP libraries, std.http.Method does not have an "OTHER"
/// variant for unknown methods. All standard HTTP methods are supported.
pub const Method = std.http.Method;
// --- HTTP Version (from std.http) --- //
/// HTTP protocol versions - re-exported from std.http.Version for convenience.
///
/// Values:
/// - `@"HTTP/1.0"`: HTTP/1.0 protocol
/// - `@"HTTP/1.1"`: HTTP/1.1 protocol
pub const Version = std.http.Version;
/// Alias for backward compatibility.
/// @deprecated Use `Version` instead.
pub const Protocol = Version;
// --- Cookie Types --- //
/// Cookie accessor - parses cookies from the Cookie header.
///
/// **Zig Note:** This is an extension type not present in the web standard.
/// In browsers, cookies are accessed via `document.cookie`.
pub const Cookies = struct {
header_value: []const u8,
/// Get a cookie value by name.
pub fn get(self: Cookies, name: []const u8) ?[]const u8 {
var it = std.mem.splitScalar(u8, self.header_value, ';');
while (it.next()) |kv| {
const trimmed = std.mem.trimLeft(u8, kv, " ");
if (name.len >= trimmed.len) continue;
if (!std.mem.startsWith(u8, trimmed, name)) continue;
if (trimmed[name.len] != '=') continue;
return trimmed[name.len + 1 ..];
}
return null;
}
};
/// Options for setting cookies.
///
/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies
pub const CookieOptions = struct {
/// Specifies the URL path that must exist in the requested URL.
path: []const u8 = "",
/// Specifies allowed hosts to receive the cookie.
domain: []const u8 = "",
/// Indicates the maximum lifetime of the cookie in seconds.
max_age: ?i32 = null,
/// Indicates that the cookie is sent only over HTTPS.
secure: bool = false,
/// Forbids JavaScript from accessing the cookie.
http_only: bool = false,
/// Indicates the cookie should be stored using partitioned storage.
partitioned: bool = false,
/// Controls whether the cookie is sent with cross-site requests.
same_site: ?SameSite = null,
pub const SameSite = enum {
lax,
strict,
none,
};
};
// --- HTTP Status (from std.http) --- //
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
/// HTTP status codes - re-exported from std.http.Status for convenience.
///
/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
///
/// Includes useful methods:
/// - `phrase()`: Returns the status message (e.g., "OK", "Not Found")
/// - `class()`: Returns the status class (.informational, .success, .redirect, .client_error, .server_error)
pub const HttpStatus = std.http.Status;
/// Returns the status message (phrase) for an HTTP status code.
/// Uses the standard library's Status.phrase() method.
pub fn statusCodeToText(code: u16) []const u8 {
const status: HttpStatus = @enumFromInt(code);
return status.phrase() orelse "Unknown";
}
// --- Content Types (MIME types) --- //
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
/// Common MIME content types.
///
/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
///
/// **Zig Note:** The standard library does not provide a ContentType enum.
/// This enum uses the actual MIME type string as the tag name for convenience.
pub const ContentType = enum {
// Application types
@"application/gzip",
@"application/javascript",
@"application/json",
@"application/octet-stream",
@"application/pdf",
@"application/wasm",
@"application/xhtml+xml",
@"application/xml",
@"application/x-www-form-urlencoded",
// Audio types
@"audio/aac",
@"audio/mpeg",
@"audio/ogg",
@"audio/wav",
@"audio/webm",
// Font types
@"font/otf",
@"font/ttf",
@"font/woff",
@"font/woff2",
// Image types
@"image/avif",
@"image/bmp",
@"image/gif",
@"image/jpeg",
@"image/png",
@"image/svg+xml",
@"image/tiff",
@"image/webp",
// Multipart types
@"multipart/form-data",
// Text types
@"text/css",
@"text/csv",
@"text/html",
@"text/javascript",
@"text/plain",
@"text/xml",
// Video types
@"video/mp4",
@"video/mpeg",
@"video/ogg",
@"video/webm",
@"video/x-msvideo",
/// Returns the MIME type string.
pub fn toString(self: ContentType) []const u8 {
return @tagName(self);
}
};
// --- Re-exports from std.http for convenience --- //
/// HTTP content encoding - re-exported from std.http.ContentEncoding.
///
/// Values: zstd, gzip, deflate, compress, identity
pub const ContentEncoding = std.http.ContentEncoding;
/// HTTP transfer encoding - re-exported from std.http.TransferEncoding.
///
/// Values: chunked, none
pub const TransferEncoding = std.http.TransferEncoding;
/// HTTP connection type - re-exported from std.http.Connection.
///
/// Values: keep_alive, close
pub const Connection = std.http.Connection;

View File

@ -281,7 +281,9 @@ pub const Handler = struct {
res.status = 404;
res.content_type = .HTML;
const notfoundctx = zx.NotFoundContext.init(req, res, self.allocator);
const abstract_req = httpz_adapter.createRequest(req);
const abstract_res = httpz_adapter.createResponse(res, req.arena);
const notfoundctx = zx.NotFoundContext.init(abstract_req, abstract_res, self.allocator);
// First try to get notfound from route_data if available
var notfound_fn: ?*const fn (zx.NotFoundContext) Component = null;
@ -317,7 +319,9 @@ pub const Handler = struct {
res.status = 500;
res.content_type = .HTML;
const errorctx = zx.ErrorContext.init(req, res, self.allocator, err);
const abstract_req = httpz_adapter.createRequest(req);
const abstract_res = httpz_adapter.createResponse(res, req.arena);
const errorctx = zx.ErrorContext.init(abstract_req, abstract_res, self.allocator, err);
// Find the closest route with error handler
var error_fn: ?*const fn (zx.ErrorContext) Component = null;
@ -352,7 +356,9 @@ pub const Handler = struct {
opts: RenderErrorPageOptions,
) void {
const path = req.url.path;
const layoutctx = zx.LayoutContext.init(req, res, self.allocator);
const abstract_req = httpz_adapter.createRequest(req);
const abstract_res = httpz_adapter.createResponse(res, req.arena);
const layoutctx = zx.LayoutContext.init(abstract_req, abstract_res, self.allocator);
const is_dev_mode = self.meta.cli_command == .dev;
var component = page_component;
@ -564,8 +570,10 @@ pub const Handler = struct {
}
}
const pagectx = zx.PageContext.init(req, res, allocator);
const layoutctx = zx.LayoutContext.init(req, res, allocator);
const abstract_req = httpz_adapter.createRequest(req);
const abstract_res = httpz_adapter.createResponse(res, req.arena);
const pagectx = zx.PageContext.init(abstract_req, abstract_res, allocator);
const layoutctx = zx.LayoutContext.init(abstract_req, abstract_res, allocator);
// log.debug("cli command: {s}", .{@tagName(meta.cli_command orelse .serve)});
@ -690,7 +698,7 @@ pub const Handler = struct {
try self.renderStreaming(res, &page_component, pagectx.arena);
} else {
// Normal mode: render everything at once
const writer = &layoutctx.response.buffer.writer;
const writer = &res.buffer.writer;
_ = writer.write("<!DOCTYPE html>\n") catch |err| {
std.debug.print("Error writing HTML: {}\n", .{err});
break :blk;
@ -908,8 +916,11 @@ const std = @import("std");
const builtin = @import("builtin");
const cachez = @import("cachez");
const zx = @import("../root.zig");
const httpz_adapter = @import("adapter.zig");
const Allocator = std.mem.Allocator;
const Component = zx.Component;
const Printer = zx.Printer;
const App = zx.App;
const Request = @import("Request.zig");
const Response = @import("Response.zig");

View File

@ -1788,3 +1788,6 @@ const ClientComponentOptions = struct {
path: []const u8,
id: []const u8,
};
pub const Headers = @import("app/Headers.zig");
pub const Request = @import("app/Request.zig");
pub const Response = @import("app/Response.zig");

View File

@ -1,33 +1,42 @@
const httpz = @import("httpz");
//! Routing contexts for page, layout, error, and not-found handlers.
//! This module is backend-agnostic - no httpz dependency.
const std = @import("std");
const Request = @import("app/Request.zig");
const Response = @import("app/Response.zig");
/// Base context structure that provides access to request/response objects and allocators.
/// This is the foundation for both PageContext and LayoutContext, providing common functionality
/// for handling HTTP requests and managing memory allocation.
const BaseContext = struct {
/// The HTTP request object containing all request data (headers, body, query params, form data, etc.)
request: *httpz.Request,
/// The HTTP response object used to write the response body and set headers
response: *httpz.Response,
pub const BaseContext = struct {
/// The HTTP request object (backend-agnostic)
/// Provides access to headers, body, query params, form data, cookies, etc.
request: Request,
/// The HTTP response object (backend-agnostic)
/// Used to set status, headers, body, and cookies.
response: Response,
/// Global allocator passed from the app, only cleared when the app is deinitialized.
/// Should be used for allocating memory that needs to persist across requests.
/// Make sure to free the memory on your own that is allocated with this allocator.
allocator: std.mem.Allocator,
/// Allocator for allocating memory that needs to be freed after the request is processed.
/// This allocator is cleared automatically when the request is processed, so you don't need
/// to manually free memory allocated with this allocator. Use this for temporary allocations
/// that are only needed during request processing.
arena: std.mem.Allocator,
/// Optional parent context, used for nested layouts or hierarchical context passing
parent_ctx: ?*BaseContext = null,
/// Initialize a new BaseContext with the given request, response, and allocator.
/// The arena allocator is automatically set from the request's arena.
pub fn init(request: *httpz.Request, response: *httpz.Response, allocator: std.mem.Allocator) BaseContext {
pub fn init(request: Request, response: Response, alloc: std.mem.Allocator) BaseContext {
return .{
.request = request,
.response = response,
.allocator = allocator,
.allocator = alloc,
.arena = request.arena,
};
}
@ -46,8 +55,9 @@ const BaseContext = struct {
/// ```zig
/// pub fn Page(ctx: zx.PageContext) zx.Component {
/// const allocator = ctx.arena; // Use arena for temporary allocations
/// // Access request data
/// const query = ctx.request.query() catch unreachable;
/// // Access request data via MDN-compliant API
/// const method = ctx.request.method;
/// const url = ctx.request.url;
/// // Render component
/// return <div>Hello</div>;
/// }
@ -70,18 +80,24 @@ pub const PageContext = BaseContext;
/// ```
pub const LayoutContext = BaseContext;
pub const NotFoundContext = BaseContext;
pub const ErrorContext = struct {
request: *httpz.Request,
response: *httpz.Response,
/// The HTTP request object (backend-agnostic)
request: Request,
/// The HTTP response object (backend-agnostic)
response: Response,
/// Global allocator
allocator: std.mem.Allocator,
/// Arena allocator for request-scoped allocations
arena: std.mem.Allocator,
/// The error that occurred
err: anyerror,
pub fn init(request: *httpz.Request, response: *httpz.Response, allocator: std.mem.Allocator, err: anyerror) ErrorContext {
pub fn init(request: Request, response: Response, alloc: std.mem.Allocator, err: anyerror) ErrorContext {
return .{
.request = request,
.response = response,
.allocator = allocator,
.allocator = alloc,
.arena = request.arena,
.err = err,
};

125
test/app/common.zig Normal file
View File

@ -0,0 +1,125 @@
const std = @import("std");
// Access common types through Request/Response which re-export them
const Request = @import("zx").Request;
const Response = @import("zx").Response;
// --- Cookies (accessed via Request) --- //
test "Cookies: get returns value" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const req = (Request.Builder{
.arena = fba.allocator(),
.cookie_header = "session=abc123; user=john",
}).build();
try std.testing.expectEqualStrings("abc123", req.cookies.get("session").?);
try std.testing.expectEqualStrings("john", req.cookies.get("user").?);
}
test "Cookies: get returns null for missing cookie" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const req = (Request.Builder{
.arena = fba.allocator(),
.cookie_header = "session=abc123",
}).build();
try std.testing.expectEqual(null, req.cookies.get("missing"));
}
test "Cookies: handles empty header" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const req = (Request.Builder{
.arena = fba.allocator(),
.cookie_header = "",
}).build();
try std.testing.expectEqual(null, req.cookies.get("session"));
}
test "Cookies: handles spaces after semicolon" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const req = (Request.Builder{
.arena = fba.allocator(),
.cookie_header = "a=1; b=2; c=3",
}).build();
try std.testing.expectEqualStrings("1", req.cookies.get("a").?);
try std.testing.expectEqualStrings("2", req.cookies.get("b").?);
try std.testing.expectEqualStrings("3", req.cookies.get("c").?);
}
// --- CookieOptions (accessed via Response) --- //
test "CookieOptions: defaults" {
const opts = Response.CookieOptions{};
try std.testing.expectEqualStrings("", opts.path);
try std.testing.expectEqualStrings("", opts.domain);
try std.testing.expectEqual(null, opts.max_age);
try std.testing.expect(!opts.secure);
try std.testing.expect(!opts.http_only);
try std.testing.expect(!opts.partitioned);
try std.testing.expectEqual(null, opts.same_site);
}
test "CookieOptions: with all values" {
const opts = Response.CookieOptions{
.path = "/api",
.domain = "example.com",
.max_age = 3600,
.secure = true,
.http_only = true,
.partitioned = true,
.same_site = .strict,
};
try std.testing.expectEqualStrings("/api", opts.path);
try std.testing.expectEqualStrings("example.com", opts.domain);
try std.testing.expectEqual(@as(?i32, 3600), opts.max_age);
try std.testing.expect(opts.secure);
try std.testing.expect(opts.http_only);
try std.testing.expect(opts.partitioned);
try std.testing.expectEqual(Response.CookieOptions.SameSite.strict, opts.same_site.?);
}
test "CookieOptions.SameSite: all values" {
const values = [_]Response.CookieOptions.SameSite{ .lax, .strict, .none };
try std.testing.expectEqual(3, values.len);
}
// --- ContentType (accessed via Response) --- //
test "ContentType: toString returns MIME type" {
try std.testing.expectEqualStrings("text/html", Response.ContentType.@"text/html".toString());
try std.testing.expectEqualStrings("application/json", Response.ContentType.@"application/json".toString());
try std.testing.expectEqualStrings("application/wasm", Response.ContentType.@"application/wasm".toString());
try std.testing.expectEqualStrings("image/png", Response.ContentType.@"image/png".toString());
}
// --- MultiFormEntry (accessed via Request) --- //
test "MultiFormEntry: fields" {
const entry = Request.MultiFormEntry{
.key = "file",
.value = "binary data",
.filename = "photo.jpg",
};
try std.testing.expectEqualStrings("file", entry.key);
try std.testing.expectEqualStrings("binary data", entry.value);
try std.testing.expectEqualStrings("photo.jpg", entry.filename.?);
}
test "MultiFormEntry: filename can be null" {
const entry = Request.MultiFormEntry{
.key = "name",
.value = "John",
.filename = null,
};
try std.testing.expectEqual(null, entry.filename);
}

87
test/app/headers.zig Normal file
View File

@ -0,0 +1,87 @@
const std = @import("std");
const Headers = @import("zx").Headers;
// --- Type Re-exports --- //
test "Headers.Header: is std.http.Header" {
try std.testing.expect(Headers.Header == std.http.Header);
}
test "Headers.HeaderIterator: is std.http.HeaderIterator" {
try std.testing.expect(Headers.HeaderIterator == std.http.HeaderIterator);
}
// --- HeaderIterator Usage --- //
test "Headers.HeaderIterator: parses raw HTTP headers" {
const raw = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nX-Custom: value\r\n\r\n";
var iter = Headers.HeaderIterator.init(raw);
const h1 = iter.next().?;
try std.testing.expectEqualStrings("Content-Type", h1.name);
try std.testing.expectEqualStrings("text/html", h1.value);
const h2 = iter.next().?;
try std.testing.expectEqualStrings("X-Custom", h2.name);
try std.testing.expectEqualStrings("value", h2.value);
try std.testing.expect(iter.next() == null);
}
// --- Headers Instance (without backend) --- //
test "Headers: default is read-only" {
const headers = Headers{};
try std.testing.expect(headers.isReadOnly());
}
test "Headers: can be set to writable" {
const headers = Headers{ .read_only = false };
try std.testing.expect(!headers.isReadOnly());
}
test "Headers: get returns null without backend" {
const headers = Headers{};
try std.testing.expect(headers.get("Content-Type") == null);
}
test "Headers: has returns false without backend" {
const headers = Headers{};
try std.testing.expect(!headers.has("Content-Type"));
}
test "Headers: entries returns null without backend" {
const headers = Headers{};
try std.testing.expect(headers.entries() == null);
}
test "Headers: write methods are no-op without backend" {
var headers = Headers{ .read_only = false };
// These should not crash
headers.append("X-Test", "value");
headers.set("X-Test", "value");
headers.delete("X-Test");
}
test "Headers: write methods are no-op when read-only" {
var headers = Headers{}; // read_only = true by default
// These should not crash and should be no-ops
headers.append("X-Test", "value");
headers.set("X-Test", "value");
}
// --- Builder --- //
test "Headers.Builder: builds with defaults" {
const headers = (Headers.Builder{}).build();
try std.testing.expect(headers.read_only);
try std.testing.expect(headers.backend_ctx == null);
try std.testing.expect(headers.vtable == null);
}
test "Headers.Builder: builds with custom values" {
const headers = (Headers.Builder{
.read_only = false,
}).build();
try std.testing.expect(!headers.read_only);
}

126
test/app/request.zig Normal file
View File

@ -0,0 +1,126 @@
const std = @import("std");
const Request = @import("zx").Request;
// --- Type Re-exports --- //
test "Request.Method: is std.http.Method" {
try std.testing.expect(Request.Method == std.http.Method);
}
test "Request.Version: is std.http.Version" {
try std.testing.expect(Request.Version == std.http.Version);
}
test "Request.Protocol: is deprecated alias for Version" {
try std.testing.expect(Request.Protocol == Request.Version);
}
test "Request.Header: is std.http.Header" {
try std.testing.expect(Request.Header == std.http.Header);
}
// --- Request Instance (without backend) --- //
test "Request: Builder default values" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const req = (Request.Builder{ .arena = fba.allocator() }).build();
try std.testing.expectEqualStrings("", req.url);
try std.testing.expectEqualStrings("/", req.pathname);
try std.testing.expectEqualStrings("", req.search);
try std.testing.expectEqualStrings("", req.referrer);
try std.testing.expectEqual(Request.Method.GET, req.method);
try std.testing.expectEqual(Request.Version.@"HTTP/1.1", req.protocol);
}
test "Request: text 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.text() == null);
}
test "Request: getParam 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);
}
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",
}).build();
try std.testing.expectEqualStrings("abc123", req.cookies.get("session").?);
}
// --- Headers --- //
test "Request.headers: 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.headers.get("Content-Type") == null);
}
test "Request.headers: has returns false 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.headers.has("Content-Type"));
}
// --- URLSearchParams --- //
test "Request.searchParams: 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.searchParams.get("q") == null);
}
test "Request.searchParams: has returns false 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.searchParams.has("q"));
}
// --- Builder --- //
test "Request.Builder: builds with custom values" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const req = (Request.Builder{
.url = "/api/users",
.method = .POST,
.pathname = "/api/users",
.search = "?page=1",
.referrer = "https://example.com",
.protocol = .@"HTTP/1.0",
.cookie_header = "session=xyz",
.arena = fba.allocator(),
}).build();
try std.testing.expectEqualStrings("/api/users", req.url);
try std.testing.expectEqual(Request.Method.POST, req.method);
try std.testing.expectEqualStrings("/api/users", req.pathname);
try std.testing.expectEqualStrings("?page=1", req.search);
try std.testing.expectEqualStrings("https://example.com", req.referrer);
try std.testing.expectEqual(Request.Version.@"HTTP/1.0", req.protocol);
try std.testing.expectEqualStrings("xyz", req.cookies.get("session").?);
}

177
test/app/response.zig Normal file
View File

@ -0,0 +1,177 @@
const std = @import("std");
const Response = @import("zx").Response;
// --- Type Re-exports --- //
test "Response.HttpStatus: is std.http.Status" {
try std.testing.expect(Response.HttpStatus == std.http.Status);
}
test "Response.ContentType: is common.ContentType" {
// Verify it exists and has expected variants
try std.testing.expectEqualStrings("text/html", Response.ContentType.@"text/html".toString());
}
test "Response.CookieOptions: is common.CookieOptions" {
const opts = Response.CookieOptions{};
try std.testing.expectEqualStrings("", opts.path);
}
test "Response.Headers: exists and has expected methods" {
const headers = Response.Headers{};
// Response.Headers has get, set, add methods
try std.testing.expect(headers.get("X-Test") == null);
}
// --- Response Instance (Web Standard Properties) --- //
test "Response: default field values" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const res = Response{ .arena = fba.allocator() };
try std.testing.expectEqualStrings("", res.body);
try std.testing.expect(!res.bodyUsed);
try std.testing.expect(res.ok);
try std.testing.expect(!res.redirected);
try std.testing.expectEqual(@as(u16, 200), res.status);
try std.testing.expectEqualStrings("OK", res.statusText);
try std.testing.expectEqual(Response.ResponseType.default, res.type);
try std.testing.expectEqualStrings("", res.url);
}
test "Response: ok is true for 2xx status" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const res200 = (Response.Builder{ .status = 200, .arena = fba.allocator() }).build();
const res201 = (Response.Builder{ .status = 201, .arena = fba.allocator() }).build();
const res299 = (Response.Builder{ .status = 299, .arena = fba.allocator() }).build();
try std.testing.expect(res200.ok);
try std.testing.expect(res201.ok);
try std.testing.expect(res299.ok);
}
test "Response: ok is false for non-2xx status" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const res199 = (Response.Builder{ .status = 199, .arena = fba.allocator() }).build();
const res300 = (Response.Builder{ .status = 300, .arena = fba.allocator() }).build();
const res404 = (Response.Builder{ .status = 404, .arena = fba.allocator() }).build();
const res500 = (Response.Builder{ .status = 500, .arena = fba.allocator() }).build();
try std.testing.expect(!res199.ok);
try std.testing.expect(!res300.ok);
try std.testing.expect(!res404.ok);
try std.testing.expect(!res500.ok);
}
test "Response: statusText is set from status code" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const res200 = (Response.Builder{ .status = 200, .arena = fba.allocator() }).build();
const res404 = (Response.Builder{ .status = 404, .arena = fba.allocator() }).build();
const res500 = (Response.Builder{ .status = 500, .arena = fba.allocator() }).build();
try std.testing.expectEqualStrings("OK", res200.statusText);
try std.testing.expectEqualStrings("Not Found", res404.statusText);
try std.testing.expectEqualStrings("Internal Server Error", res500.statusText);
}
// --- ResponseType --- //
test "Response.ResponseType: all values" {
const types = [_]Response.ResponseType{
.basic, .cors, .default, .@"error", .@"opaque", .opaqueredirect,
};
try std.testing.expectEqual(6, types.len);
}
// --- Builder --- //
test "Response.Builder: builds with custom values" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const res = (Response.Builder{
.status = 201,
.url = "https://example.com/api",
.response_type = .cors,
.arena = fba.allocator(),
}).build();
try std.testing.expectEqual(@as(u16, 201), res.status);
try std.testing.expect(res.ok);
try std.testing.expectEqualStrings("Created", res.statusText);
try std.testing.expectEqualStrings("https://example.com/api", res.url);
try std.testing.expectEqual(Response.ResponseType.cors, res.type);
}
// --- Methods (without backend) --- //
test "Response.setStatus: no-op without backend" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const res = (Response.Builder{ .arena = fba.allocator() }).build();
// Should not crash, but local fields remain unchanged
res.setStatus(.not_found);
try std.testing.expectEqual(@as(u16, 200), res.status);
}
test "Response.setStatusCode: no-op without backend" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const res = (Response.Builder{ .arena = fba.allocator() }).build();
res.setStatusCode(503);
try std.testing.expectEqual(@as(u16, 200), res.status);
}
test "Response.setBody: no-op without backend" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const res = (Response.Builder{ .arena = fba.allocator() }).build();
res.setBody("Hello");
try std.testing.expectEqualStrings("", res.body);
}
test "Response.redirect: no-op without backend" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const res = (Response.Builder{ .arena = fba.allocator() }).build();
res.redirect("/new", null);
res.redirect("/permanent", 301);
try std.testing.expectEqual(@as(u16, 200), res.status);
try std.testing.expect(!res.redirected);
}
// --- Headers --- //
test "Response.headers: get returns null without backend" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const res = Response{ .arena = fba.allocator() };
try std.testing.expect(res.headers.get("Content-Type") == null);
}
test "Response.headers: set and add are no-op without backend" {
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const res = Response{ .arena = fba.allocator() };
// These should not crash (no-op without backend)
res.headers.set("X-Test", "value");
res.headers.add("X-Test", "value2");
}

136
test/app/routing.zig Normal file
View File

@ -0,0 +1,136 @@
const std = @import("std");
const zx = @import("zx");
const Request = zx.Request;
const Response = zx.Response;
const PageContext = zx.PageContext;
const LayoutContext = zx.LayoutContext;
const NotFoundContext = zx.NotFoundContext;
const ErrorContext = zx.ErrorContext;
// --- Context Type Aliases --- //
test "PageContext: is same as LayoutContext" {
try std.testing.expect(PageContext == LayoutContext);
}
test "PageContext: is same as NotFoundContext" {
try std.testing.expect(PageContext == NotFoundContext);
}
// --- PageContext --- //
test "PageContext: has request field" {
var buffer: [4096]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const alloc = fba.allocator();
const req = (Request.Builder{
.url = "/test",
.method = .POST,
.arena = alloc,
}).build();
const res = (Response.Builder{
.status = 200,
.arena = alloc,
}).build();
const ctx = PageContext.init(req, res, alloc);
try std.testing.expectEqualStrings("/test", ctx.request.url);
try std.testing.expectEqual(Request.Method.POST, ctx.request.method);
}
test "PageContext: has response field" {
var buffer: [4096]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const alloc = fba.allocator();
const req = (Request.Builder{ .arena = alloc }).build();
const res = (Response.Builder{
.status = 201,
.arena = alloc,
}).build();
const ctx = PageContext.init(req, res, alloc);
try std.testing.expectEqual(@as(u16, 201), ctx.response.status);
try std.testing.expect(ctx.response.ok);
try std.testing.expectEqualStrings("Created", ctx.response.statusText);
}
test "PageContext: has allocator and arena fields" {
var buffer: [4096]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const alloc = fba.allocator();
const req = (Request.Builder{ .arena = alloc }).build();
const res = (Response.Builder{ .arena = alloc }).build();
const ctx = PageContext.init(req, res, alloc);
// Verify fields are accessible
_ = ctx.allocator;
_ = ctx.arena;
}
test "PageContext: parent_ctx is null by default" {
var buffer: [4096]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const alloc = fba.allocator();
const req = (Request.Builder{ .arena = alloc }).build();
const res = (Response.Builder{ .arena = alloc }).build();
const ctx = PageContext.init(req, res, alloc);
try std.testing.expect(ctx.parent_ctx == null);
}
// --- ErrorContext --- //
test "ErrorContext: has error field" {
var buffer: [4096]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const alloc = fba.allocator();
const req = (Request.Builder{ .arena = alloc }).build();
const res = (Response.Builder{ .arena = alloc }).build();
const err = error.OutOfMemory;
const ctx = ErrorContext.init(req, res, alloc, err);
try std.testing.expectEqual(error.OutOfMemory, ctx.err);
}
test "ErrorContext: has request and response fields" {
var buffer: [4096]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const alloc = fba.allocator();
const req = (Request.Builder{
.url = "/error-page",
.arena = alloc,
}).build();
const res = (Response.Builder{
.status = 500,
.arena = alloc,
}).build();
const ctx = ErrorContext.init(req, res, alloc, error.Unexpected);
try std.testing.expectEqualStrings("/error-page", ctx.request.url);
try std.testing.expectEqual(@as(u16, 500), ctx.response.status);
}
test "ErrorContext: has allocator and arena fields" {
var buffer: [4096]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const alloc = fba.allocator();
const req = (Request.Builder{ .arena = alloc }).build();
const res = (Response.Builder{ .arena = alloc }).build();
const ctx = ErrorContext.init(req, res, alloc, error.Unexpected);
_ = ctx.allocator;
_ = ctx.arena;
}

View File

@ -1,4 +1,5 @@
test "init" {
if (!test_util.shouldRunSlowTest()) return error.SkipZigTest;
try test_cmd(.{
.args = &.{"init"},
.expected_exit_code = 0,
@ -22,6 +23,7 @@ test "init" {
}
test "init → init" {
if (!test_util.shouldRunSlowTest()) return error.SkipZigTest;
try test_cmd(.{
.args = &.{"init"},
.expected_exit_code = 0,
@ -32,6 +34,7 @@ test "init → init" {
}
test "init --force" {
if (!test_util.shouldRunSlowTest()) return error.SkipZigTest;
try test_cmd(.{
.args = &.{ "init", "--force" },
.expected_exit_code = 0,
@ -52,6 +55,7 @@ test "init --force" {
}
test "init -t react" {
if (!test_util.shouldRunSlowTest()) return error.SkipZigTest;
try test_cmd(.{
.args = &.{ "init", "react", "--template", "react" },
.expected_exit_code = 0,
@ -137,7 +141,7 @@ test "init -t react" {
// }
test "init → build" {
if (!sholdRunSlowTest()) return error.SkipZigTest;
if (!test_util.shouldRunSlowTest()) return error.SkipZigTest;
const test_dir_abs = try getTestDirPath();
defer allocator.free(test_dir_abs);
@ -155,7 +159,7 @@ test "init → build" {
}
test "export" {
if (builtin.os.tag == .windows or !sholdRunSlowTest()) return error.SkipZigTest; // Export doesn't work on Windows yet
if (builtin.os.tag == .windows or !test_util.shouldRunSlowTest()) return error.SkipZigTest; // Export doesn't work on Windows yet
try test_cmd(.{
.args = &.{"export"},
.expected_exit_code = 0,
@ -177,7 +181,7 @@ test "export" {
}
test "bundle" {
if (!sholdRunSlowTest()) return error.SkipZigTest;
if (!test_util.shouldRunSlowTest()) return error.SkipZigTest;
try test_cmd(.{
.args = &.{"bundle"},
.expected_exit_code = 0,
@ -197,7 +201,7 @@ test "bundle" {
}
test "bundle --docker" {
if (!sholdRunSlowTest()) return error.SkipZigTest;
if (!test_util.shouldRunSlowTest()) return error.SkipZigTest;
try test_cmd(.{
.args = &.{ "bundle", "--docker" },
.expected_exit_code = 0,
@ -217,7 +221,7 @@ test "bundle --docker" {
}
test "bundle --docker-compose" {
if (!sholdRunSlowTest()) return error.SkipZigTest;
if (!test_util.shouldRunSlowTest()) return error.SkipZigTest;
try test_cmd(.{
.args = &.{ "bundle", "--docker-compose" },
.expected_exit_code = 0,
@ -344,16 +348,6 @@ test "tests:afterAll" {
// std.fs.cwd().deleteTree("test/tmp") catch {};
}
fn sholdRunSlowTest() bool {
// E2E environment variable is set
const slow_tests = std.process.getEnvVarOwned(allocator, "E2E") catch {
return false;
};
defer allocator.free(slow_tests);
return true;
}
fn getZxPath() ![]const u8 {
const zx_bin_rel = if (builtin.os.tag == .windows) "zig-out/bin/zx.exe" else "zig-out/bin/zx";
const zx_bin_abs = try std.fs.cwd().realpathAlloc(allocator, zx_bin_rel);
@ -397,6 +391,7 @@ fn killPort(port: []const u8) !void {
}
const allocator = std.testing.allocator;
const test_util = @import("./../test_util.zig");
const std = @import("std");
const builtin = @import("builtin");

View File

@ -279,7 +279,7 @@ test "zx_comments" {
}
test "performance > fmt" {
// if (true) return error.Todo;
if (!shouldRunSlowTest()) return;
const MAX_TIME_MS = 50.0 * 8; // 50ms is on M1 Pro
const MAX_TIME_PER_FILE_MS = 8.0 * 10; // 5ms is on M1 Pro
@ -349,8 +349,9 @@ fn expectLessThan(expected: f64, actual: f64) !void {
var test_file_cache: ?TestFileCache = null;
var gpa_state: ?std.heap.GeneralPurposeAllocator(.{}) = null;
const TestFileCache = @import("./../test_util.zig").TestFileCache;
const test_util = @import("./../test_util.zig");
const TestFileCache = test_util.TestFileCache;
const shouldRunSlowTest = test_util.shouldRunSlowTest;
const std = @import("std");
const testing = std.testing;

View File

@ -4,6 +4,11 @@ test {
_ = @import("zx/ast.zig");
_ = @import("cli/fmt.zig");
_ = @import("cli/cli.zig");
_ = @import("app/headers.zig");
_ = @import("app/request.zig");
_ = @import("app/response.zig");
_ = @import("app/common.zig");
_ = @import("app/routing.zig");
}
pub const std_options = std.Options{

View File

@ -165,6 +165,18 @@ pub const TestFileCache = struct {
}
};
pub fn shouldRunSlowTest() bool {
const allocator = std.testing.allocator;
// E2E environment variable is set
const slow_tests = std.process.getEnvVarOwned(allocator, "E2E") catch {
return false;
};
defer allocator.free(slow_tests);
return true;
}
const std = @import("std");
const testing = std.testing;
const zx = @import("zx");

View File

@ -334,6 +334,7 @@ test "component_caching" {
}
test "performance > transpile" {
if (!test_util.shouldRunSlowTest()) return;
const MAX_TIME_MS = 50.0 * 8; // 50ms is on M1 Pro
const MAX_TIME_PER_FILE_MS = 8.0 * 10; // 5ms is on M1 Pro
@ -586,7 +587,8 @@ var test_file_cache: ?TestFileCache = null;
var gpa_state: ?std.heap.GeneralPurposeAllocator(.{}) = null;
const native_os = @import("builtin").os.tag;
const TestFileCache = @import("./../test_util.zig").TestFileCache;
const test_util = @import("./../test_util.zig");
const TestFileCache = test_util.TestFileCache;
const std = @import("std");
const testing = std.testing;