Chapter 16: Routing Requests and Building Responses
Chapter 15 showed the raw web protocol:
function __request__ ( env ) {
return [ 200, { "Content-Type": "text/plain" }, [ "hello\n" ] ];
}
That protocol is still the boundary with zuzu-rust-server and zuzu-plackup. For larger applications, the std/web module adds a friendlier layer on top of it:
from std/web import Request, Response, Routes;
Request wraps the incoming environment, Response builds the outgoing three-item response array, and Routes dispatches paths to actions. The module is a Pure Zuzu Module, so it is loaded, parsed, and evaluated like ordinary ZuzuScript code.
16.1 A Routed App
The smallest routed app creates a router once, then uses it from __request__:
from std/web import Request, Routes;
let routes := new Routes();
routes.get("/").to(
action: fn req -> "The home page is open.\n",
);
routes.get("/hello/:name").to(
action: fn req -> [ "Hello ", req.param("name"), ".\n" ],
);
function __request__ ( env ) {
return routes.dispatch( new Request( env: env ) );
}
The host still calls __request__(env). The difference is that the app turns env into a Request, and Routes.dispatch returns the raw response array that the host already understands.
Route actions receive one argument: the Request. They may return a Response, a raw [ status, headers, body ] array, a string, a binary string, a Path, a body chunk array, or null.
Simple return values are normalized into responses:
- a string or binary string becomes a
200response body, - an array that is not already
[ status, headers, body ]becomes the response body, nullbecomes204 No Content,- a
Responseis finalized directly.
16.2 Reading Requests
Request is modelled on Plack::Request, but it is built from the portable Zuzu web environment.
function inspect ( req ) {
let text := "";
text _= "method: " _ req.request_method() _ "\n";
text _= "path: " _ req.path() _ "\n";
text _= "query: " _ req.query_string() _ "\n";
text _= "agent: " _ req.user_agent() _ "\n";
return text;
}
The HTTP method accessor is named request_method() because method is a ZuzuScript keyword. The raw value is also available as req.env().get("method").
Common request methods include:
env(): the original environmentDict,request_method(): HTTP method, such as"GET"or"POST",path()andpath_info(): the route path,raw_path(): the raw path where the host provides it,request_uri(): path plus query string,query_string(): raw query string without?,scheme(),secure(),uri(), andbase(),headers()andheader(name),content_type(),content_length(),referer(), anduser_agent(),body(),content(),raw_body(), andbody_text(),address(),remote_host(), anduser().
Headers are case-insensitive when read through header(name).
16.3 Parameters and Cookies
Query strings and form bodies are parsed into PairList values. That preserves ordering and duplicate keys.
routes.post("/search").to(
action: function ( req ) {
let q := req.param("q");
let all_tags := req.parameters().get_all("tag");
return [ "search=", q, " tags=", all_tags.length(), "\n" ];
},
);
Request provides:
query_parameters(): values from the URL query string,body_parameters(): values from anapplication/x-www-form-urlencodedrequest body,parameters(): query, body, and route captures merged in that order,param(name): the first merged value for a name,param(): all merged parameter names,cookies(): request cookies as aDict.
Route captures are available through param, parameters, captures, and stash:
routes.get("/users/:id").to(
action: fn req -> `user id: ${req.param("id")}\n`,
);
The current hosts read request bodies into memory. This is fine for small forms, JSON payloads, and webhooks. Do not use it for unbounded uploads.
16.4 Building Responses
Response is modelled on Plack::Response:
from std/web import Response;
function created () {
let res := new Response(
status: 201,
headers: { "Content-Type": "text/plain; charset=UTF-8" },
body: [ "created\n" ],
);
res.header( "X-App", "example" );
return res;
}
Useful methods include:
status(value?)andcode(value?),headers(value?),header(name, value?),body(value?)andcontent(value?),render(template, data := {}),render_json(data),content_type(value?),content_length(value?),content_encoding(value?),location(value?),redirect(url, status := 302),set_cookie(name, value, options := {}),finalize().
finalize() returns the raw [ status, headers, body ] array. You normally do not need to call it yourself when returning from a route action, because Routes.dispatch normalizes Response objects.
Use a PairList when response header order or duplicates matter:
let res := new Response( status: 200, body: [ "ok\n" ] );
res.header( "Content-Type", "text/plain; charset=UTF-8" );
res.set_cookie( "session", token, { Path: "/", HttpOnly: true } );
return res;
redirect sets the status and Location header:
return new Response().redirect("/login");
render fills a std/template/z template and sets the response body to the rendered string. The template can be a ZTemplate, another object with the same process(data) interface, or a std/io Path. Path templates are compiled as ZTemplate objects and cached with std/cache/lru:
from std/io import Path;
return new Response()
.content_type("text/html; charset=UTF-8")
.render(
new Path("templates/user.zt"),
{ user: user },
);
render_json encodes data as JSON, sets the response body, and sets the content type to application/json; charset=UTF-8:
return new Response().render_json({
ok: true,
user: user,
});
16.5 Route Patterns
Routes are checked in definition order. Matching stops at the first route that fits the path and HTTP method.
routes.get("/articles/:id").to(action: show_article);
routes.post("/articles").to(action: create_article);
routes.any("/health").to(action: fn req -> "ok\n");
Supported HTTP helpers include:
get post put patch delete options head any
Standard placeholders use :name and match one path segment, excluding dots:
routes.get("/users/:id").to(action: show_user);
Angle brackets are another spelling for a whole-segment standard placeholder:
routes.get("/users/<id>").to(action: show_user);
Relaxed placeholders use #name and allow dots:
routes.get("/assets/#filename").to(action: asset);
Wildcard placeholders use *name and can capture multiple path segments:
routes.get("/download/*path").to(action: download);
Typed placeholders use <name:type>. The built-in num type matches non-negative whole numbers:
routes.get("/orders/<id:num>").to(action: show_order);
Add custom types with add_type:
routes.add_type( "slug", /^[a-z0-9-]+$/ );
routes.get("/posts/<slug:slug>").to(action: show_post);
When a route has the right path but the wrong method, Routes.dispatch returns 405 Method Not Allowed with an Allow header. When no route matches, it returns 404 Not Found.
16.6 Nested Routes
Use under for a shared path prefix:
let api := routes.under("/api");
api.get("/status").to(action: fn req -> "ok\n");
api.get("/users/:id").to(action: show_api_user);
Nested routes combine the parent prefix and child path. Captures from the parent remain available to the child:
let account := routes.under("/accounts/:account_id");
account.get("/settings").to(
action: fn req -> `settings for ${req.param("account_id")}\n`,
);
An under route may also have its own action. If that action returns a false value, dispatch stops searching below it. This can be used for small guards:
let admin := routes.under("/admin").to(
action: function ( req ) {
return req.header("X-Admin") eq "yes";
},
);
admin.get("/dashboard").to(action: admin_dashboard);
16.7 Controllers
For small apps, route actions can be functions:
function welcome ( req ) {
return "welcome\n";
}
routes.get("/welcome").to(action: welcome);
For larger apps, use controllers. A controller may be a class with a static method:
class Pages {
static method about ( req ) {
return "about\n";
}
}
routes.get("/about").to(
controller: Pages,
action: "about",
);
It may be an object with an instance method:
class Counter {
let Number count := 0;
method hit ( req ) {
count++;
return `hits: ${count}\n`;
}
}
let counter := new Counter();
routes.get("/hits").to(
controller: counter,
action: "hit",
);
It may also be a class exported from another module:
routes.get("/users/:id").to(
controller: "app/controllers/users#Users",
action: "show",
);
String controller targets have the form:
module/path#ClassName
They are lazy loaded. Defining the route does not load the module. The first matching dispatch calls std/internals.load_module(module, class) and caches the returned class for later requests.
That keeps application startup cheap and avoids loading controllers for routes that are never used.
16.8 Route Names and URL Rendering
Routes get a generated name from their path, and you can set one explicitly:
routes.get("/users/:id").name("user_show").to(action: show_user);
Find a named route with find or lookup:
let user_route := routes.find("user_show");
let path := user_route.render( { id: 42 } );
render fills placeholders and percent-encodes values:
say routes.find("user_show").render( { id: "Bob Smith" } );
// /users/Bob%20Smith
This is useful for links and redirects:
return new Response().redirect(
routes.find("user_show").render( { id: current_user_id } )
);
16.9 Running the Same App
The app still runs with the same commands as the raw protocol chapter:
zuzu-rust-server --listen 127.0.0.1:3000 app.zzs
or:
bin/zuzu-plackup -Imodules app.zzs -- -p 5000
Both hosts pass the same core environment fields used by Request, including method, protocol, scheme, host, server_name, server_port, remote_addr, remote_host, remote_user, script_name, path, raw_path, request_uri, query_string, headers, body, and body_text.
The raw protocol remains available. You can mix direct raw responses, Response objects, and routed actions in the same application while gradually moving from simple request handlers to a fuller router.