NAME
Punk::Context - the per-request object
DESCRIPTION
Every guard, hook, plugin helper and controller receives one argument: the context. It wraps the PSGI environment lazily and carries the response builders. See Punk for the framework overview.
The class is entirely XS: storage is array slots and every method is an XSUB reading them directly, so the context costs nothing per request beyond its construction.
METHODS
env
The raw PSGI environment hashref.
app
The compiled Punk::App.
req
The lazy Punk::Request.
res
The lazy Punk::Response builder - only constructed when used.
ua
my $res = $c->ua->get($url)->get; # blocking
get '/proxy' => sub { # or hand the future back
my ($c) = @_;
$c->ua->get($url)->then(sub { $c->json({ got => $_[0]->content }) });
};
The outbound Fetch agent. Unlike the accessors above this is not per request: it is one agent per worker, shared by every request that worker serves, so its keep-alive pool and DNS state survive between them. The context only memoises the lookup. Configure it with the "ua" in Punk keyword.
On a Hyperman worker the agent runs on the same event loop serving inbound requests, so returning its future from a handler lets the worker answer others while the call is in flight. Anywhere else - a test, a script, punk console - there is no loop to join, Fetch uses its own, and ->get blocks.
Each worker builds its own on first use, so no two share a socket.
With ua cookie_jar => 1 this returns a per-request clone carrying its own jar, over the same pool. The context memoises whichever it is, so calling $c->ua twice in one request gets one agent and one jar. See Punk::UA.
stash
A per-request hashref for passing values between guards, hooks and the controller.
param($name)
Validated OpenAPI parameters first (path, then query), then web route captures, then the request (query, then form body).
params
params(@names)
The same layers, several names at a time. With no names, all of them merged into one hashref, stacked in that precedence.
With names, only those: a list of values in the order asked for (undef for a name no layer has) in list context, and in scalar context a hashref of just the names that were there, so a set of optional filters is one call rather than a loop -
my %filter = %{ $c->params(qw(state queue task worker)) };
Note that %{ } is scalar context, but an argument list is not: reach for scalar where the call sits somewhere already in list context. A list of names that happens to be empty is the same call as no names, and so gives everything. See "params" in Punk::Request, which this defers to for the last layer.
openapi
The validated parameter hash from "validate_request" in Open::API on API routes; undef elsewhere.
model($name)
The registered Punk::Model instance (per-worker, built on first access).
render($template, \%data, %options)
Render through the app's view engines; returns a finished response. See Punk::Views.
json($data, $status?)
text($body, $status?)
html($body, $status?)
redirect($url, $status?)
not_found
Finished responses. Status and headers previously set through "status" and "header" are folded in.
redirect sends where it is told. When the destination came out of the request, put it through "safe_path" first.
safe_path($path, $fallback?)
$c->redirect($c->safe_path($c->param('to'), '/'));
Returns $path when it is a same-origin relative path, and $fallback (undef by default) when it is not. This is the guard for a redirect target the request supplied - ?to=, ?return=, ?next= - which is otherwise an open redirect: an attacker sends a victim to your login page with ?to=//evil.example, and your own site bounces them somewhere else once they authenticate.
A path passes only if it starts with /, does not start with //, and contains no C0 control byte, no DEL, and no backslash. The last two rules are blunter than they look necessary, because a browser does not read the string the way this check does: it removes every TAB, CR and LF from a URL before parsing it, so "/\tevil.example" reaches it as "//evil.example", and under a special scheme it treats \ as /. Both leave the site while passing a naive "starts with a slash" test. A path that genuinely wants one of these characters percent-encodes it.
auth_guard hands you exactly such a parameter when it redirects to login_path with ?to=, so a login form that honours it wants this. These are the same rules as Punk::OAuth2's same_origin_path, which is where they were learned: CVE-2026-75628.
send_file($source, %options)
get '/invoice/:id' => sub {
my $c = shift;
return $c->send_file("/var/store/$id.pdf",
filename => "invoice-$id.pdf");
};
# bytes already in memory (a generated document)
return $c->send_file(\$pdf_bytes, type => 'application/pdf');
A finished download response, returned by the handler like any other. The source is a file path or a reference to a scalar of bytes. The whole download story is handled here: ETag (strong, from mtime and size) and Last-Modified with 304 answers to If-None-Match / If-Modified-Since; a single byte Range served as 206 with Content-Range (416 when unsatisfiable, and a multi-range or malformed header is legally answered with the full 200); If-Range honoured on an exact validator match; HEAD answered with the real headers and no body. Headers previously set through "header" are folded in. Ranged file bodies ride Punk::SendFile::Reader, so no more than 64KB of the file is in memory at once; a full-file body is a plain filehandle the server streams.
Options: type (Content-Type; otherwise inferred from the path or filename extension, else application/octet-stream), filename (sets Content-Disposition: attachment with the name, RFC 5987-encoded when it is not ASCII), inline (disposition inline instead), ranges => 0 (ignore Range and stop advertising Accept-Ranges), mtime / etag (override the validators; mtime is what gives a scalar source one), cache_control (a freshness lifetime, sent on the 304 as well as the 200 - a 304 that omitted it would leave the stored copy with the lifetime that has just run out), and missing => 'not_found' (answer the house 404 for an unreadable path instead of croaking).
The path is served as given - if any part of it came from the request, the traversal guard is yours.
origin
my $base = $c->origin; # 'https://acme.example.com'
The request's scheme and host, when that host is the application's declared "host" in Punk or matches its allow list; the canonical origin when it is anything else; undef when no host was declared. Never the raw Host header, so the value can be joined onto and reflected - in a sitemap, a redirect, a link in a mail - without handing a client the power to choose it. Honours the "proxy" in Punk keyword, since it reads the same resolved environment. No path: $app->host keeps one if it was declared with one, this is the origin in the browser's sense.
host_allowed
True when the request's Host is one the application declared: the canonical host, or a match on the allowlist. The signal for an application that would rather answer 421 to an unknown host than serve the canonical site under a name it does not own.
asset($url)
The content-addressed URL for a file under a static ... fingerprint => 1 mount: /static/app.css becomes /static/app.9f3a1c2b0d4e5f60.css, which serves with a year and immutable because that URL cannot come to mean anything else. A URL under no static mount, under one that has not opted in, or naming a file that cannot be read comes back exactly as it went in - so a template can be written against this before the mount asks for it. See "Freshness" in Punk::Static.
respond_to(%format_handlers)
return $c->respond_to(
json => sub { $_[0]->json({ book => $book }) },
html => sub { $_[0]->render('book/view', { book => $book }) },
any => sub { $_[0]->text('book', 200) },
);
Accept negotiation: calls the handler for the most acceptable offered format and returns its response. Formats are json, html, text, xml or any full media type ('application/vnd.book+json'); q-values order the choice and q=0 excludes. A client that expressed no preference - no Accept, or only a wildcard match - gets the format its own request Content-Type names when that is offered, else the first registered. When nothing fits, the any handler is called if given; otherwise the response is a 406. Every outcome carries Vary: Accept.
status($code)
header($name => $value)
Set response status / add a response header; chainable. With no arguments status returns the pending status.
cookie($name)
cookie($name => $value, %opts)
With one argument, read a request cookie. With a value, set a Set-Cookie on the response (an undef value deletes it); options path (default /), domain, max_age, secure, httponly, samesite. The set form chains.
session
The session hashref (see Punk::Session); requires the session keyword. Read and write it; it is written back at the end of the request if it changed - to the cookie, or to the store when one is configured.
session_expire
Log out: empty the session and delete its cookie. With a store it also deletes the entry, so the session is revoked rather than merely dropped by the browser in front of you. Chainable.
session_rotate
post '/login' => sub {
my ($c) = @_;
...
$c->session_rotate; # a new id, the same session
$c->session->{user_id} = $user->id;
$c->redirect('/');
};
Keep the session, give it a new id, and delete the entry under the old one. Chainable.
Call it at the privilege boundary, which is a login or an elevation, and nowhere else. Session fixation is the attack it prevents: somebody plants a known id in a victim's browser and waits for them to log in, and if logging in writes the user into the session the attacker planted, the attacker's id is now an authenticated session.
Without a store this is a no-op, and deliberately so rather than an error: a cookie session's value changes wholesale when its contents do, so there is nothing to rotate. Saying that out loud matters, because an application may be written against the cookie session and later given a store.
flash
$c->flash(notice => 'Saved.'); # set, for the NEXT request
my $note = $c->flash('notice'); # read this request's inbound
my $all = $c->flash; # the whole inbound hashref
One-request messages over the session (requires the session keyword): set with pairs (chainable), read by key, or take the whole inbound hashref for a template. See "FLASH" in Punk::Session for the lifecycle.
flash_keep
Re-arm this request's inbound flash for one more request. Chainable.
validate($schema?, $data?)
my $v = $c->validate(\%json_schema); # run a validation now
return $c->json({ errors => $v->errors }, 400) if $v->has_errors;
my $v = $c->validate; # no args: the last Result
Collecting request validation - never croaks on invalid data. With a schema, runs: $data defaults to the decoded JSON body for a JSON request, the merged params otherwise; returns a Punk::Validate Result. With no arguments, reads: the last Result this request produced (a route-level validate option ran before the handler), or undef.
login($user_or_id)
logout
auth_id
current_user
check_password($user, $password)
issue_token($user_id, $kind, $ttl)
take_token($token, @kinds)
The authentication battery's surface; all need the auth keyword. login records the identity in the session and logout expires it; auth_id is the raw session id, current_user the row loaded once per request through the configured model. check_password burns the same PBKDF2 work when there is no user or hash, so login timing reveals nothing. The token pair mints and spends single-use email tokens - spending deletes first, then validates. See Punk::Auth.
upload($name)
The Punk::Upload for a multipart/form-data file field (the first if several), via $c->req->upload.
log
The request Punk::Logger (cached for the request): $c->log->info(...), debug, warn, error, fatal. Its lines carry the request's method and path, and are delivered to the server's psgix.logger when one is present. Configure with the logging keyword. See Punk::Logger.
match
Routing information for the matched route:
captures- the path captures, as a hashref.route- the matched route record. Itspathis the route as declared ("/users/:id", not"/users/7"), and itsmethodthe verb it was declared under. Absent for a 404, a 405, anything answered by a mount, and inside a "before_request" in Punk hook, none of which have a route to name.operation- theoperationId, for a request answered by anapimount. A route record and an operation are mutually exclusive.
promise
A new pending Punk::Future - loop-backed on a live Hyperman worker, self-contained (blocking) otherwise. Return it (or a then of it) from a handler to defer the response; settle it later from whatever wakes it.
get '/wait' => sub {
my ($c) = @_;
my $p = $c->promise;
$c->timer(1)->on_done(sub { $p->done($c->json({ ready => 1 })) });
return $p; # answered when $p is settled
};
timer($secs)
after($secs)
A Punk::Future that settles after $secs: a loop timer on a worker, a sleep off it. $c->timer(2)->then(sub { ... }) answers the request two seconds later without pinning the worker.
await($future)
Block until $future is ready and return its values (rethrowing a failure) - pumping the loop re-entrantly on a worker, blocking off it. The imperative escape hatch; return $future is the non-blocking way.
stash_hv
openapi_params
The raw storage slots behind "stash" and "openapi", read or written directly (the accessor pair the class is built from). Prefer stash and openapi, which lazily build and coerce; these exist for the framework and for code that wants the slot untouched.
AUTHOR
LNATION <email@lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION <email@lnation.org>.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)