NAME
Punk::Request - a lazy wrapper over the PSGI environment
DESCRIPTION
Nothing is parsed until asked for, and everything parsed is cached on the object: query pairs, form bodies, cookies, the raw body. Reached through "req" in Punk::Context.
The object is a plain blessed array with a fixed slot layout and an all-C implementation - percent-decoding, pair splitting, multi-value promotion and header lookup without the regex engine; the body read runs through PerlIO and JSON decodes through File::Raw::JSON's C ABI. Load this module through Punk, which loads the compiled core first.
METHODS
new($env)
Constructed by the dispatcher; wraps the PSGI environment.
env
method
path
The raw environment, request method and path (/ when empty).
address
The client's address: REMOTE_ADDR. On a directly-exposed application that is the socket peer. Behind a reverse proxy with the proxy keyword in force it is the resolved client, because Punk rewrites the env key rather than adding a second one - see "proxy" in Punk. The connecting address is then available as $c->env->{'punk.peer_addr'}.
header($name)
A request header by name, case-insensitively (Content-Type and Content-Length included).
headers
Every request header as a hashref, keyed lowercase and dash-separated - x-forwarded-for, content-type, content-length - which is how HTTP/2 spells them and the same shape the OpenAPI validation path builds.
Reach for "header" when you know the name: it folds case, and a plain hash cannot, so $req->headers->{'X-Foo'} misses where $req->header('X-Foo') hits. This is for the cases where you want them all - logging, proxying, signing.
The hash is built fresh each call, so keeping or changing it is safe.
param($name)
Query parameter first, then form body parameter. A repeated parameter yields an arrayref.
params
params(@names)
With no names, one merged hashref, query winning over form.
With names, only those, looked up the way "param" looks one up. The return follows context: a list of values in the order asked for, undef for a name neither table has -
my ($page, $size) = $req->params(qw(page size));
or, in scalar context, a hashref holding only the names that were there, which is the shape to build a filter from -
my %filter = %{ $req->params(qw(state queue task)) };
A dereference block puts what it wraps in scalar context, so the %{ } above gets the hashref. Somewhere already in list context - an argument list, a hash constructor - it takes the slice instead, so ask for the hashref explicitly there with scalar.
Passing a list that happens to be empty is passing no names at all, and so gives everything: guard the call where the names are built at runtime.
query
form
The parsed body: application/x-www-form-urlencoded pairs, or the field parts of a multipart/form-data submission (whose file parts become uploads).
upload($name)
uploads
The Punk::Upload for a multipart file field - the first if several - and the { name => upload | [uploads] } hash of all of them. Both parse the body once.
body
The raw request body bytes (undef when there is none), read once and rewound.
json
The body decoded as JSON through File::Raw::JSON's C ABI.
xml
The body parsed as XML through File::Raw::XML's C ABI, as a File::Raw::XML::Document; undef when there is no body. Walk it with ->root and the node methods, or query it with ->xpath.
Punk maps nothing between XML and Perl data. JSON's model is Perl's, so a hash reference has one obvious encoding; XML's is not, and every convention for elements against attributes, ordering, mixed content and repeated elements is wrong for some schema. What you get is the document.
The parse is strict: a document type declaration is refused wherever it stands, which is what removes external entities, parameter entities, the external DTD fetch, XXE and the billion laughs - not as a setting that could be turned off, but as a shape the parser will not accept. There are no options, and there will not be: a profile or a resolver reachable from a request is the switch that would give all of that back. A body that is not well-formed dies, as a malformed JSON body does; the size ceiling is max_body, which refuses an oversized body before it is read at all.
The document is parsed once and kept for the rest of the request, so two calls answer with the same object rather than two copies - which is what makes a node's ->doc and a by_id result name one document. That tree is editable, so a change one caller makes is a change the next one sees, the way body hands back the one cached scalar.
body_each($code, %options)
my $bytes = $c->req->body_each(sub {
my ($chunk, $req) = @_;
$digest->add($chunk);
});
The body a window at a time instead of all at once. $code is called with each chunk and the request; the return value is the total byte count.
body copies the whole request into one scalar, which is right for JSON and wrong for anything large - the server is already holding those bytes, and the copy doubles them for as long as the handler runs. This is the same window the multipart parser has always read uploads through, for a body that is not multipart: an import, an application/octet-stream PUT, a feed of newline-delimited JSON.
Options: chunk, the window size in bytes (default 65536), and max, a ceiling after which the read croaks (default 0, no ceiling).
body_to($dest, %options)
my $bytes = $c->req->body_to('/var/spool/import.ndjson');
my $bytes = $c->req->body_to($fh, chunk => 1024 * 1024);
The body straight to a file - a path, which is opened and closed here, or a handle already open for writing. Nothing larger than one window is ever held in the application. Takes the same chunk and max options, and returns the byte count.
Reading the body once
A body can be read whole or in chunks, not both ways round. After body_each or body_to the bytes have gone, and body, json and form croak saying so rather than answering with nothing - an empty string where a body was expected is a bug that ships. The other order is not a trap: a body already read whole is replayed to body_each from the copy, so the order two pieces of code happen to run in cannot break either.
A body with no CONTENT_LENGTH
An HTTP/2 or HTTP/3 client streaming an upload declares no length: both versions forbid Transfer-Encoding, so such a request carries no framing header at all. HTTP/1.1 spells the same thing Transfer-Encoding: chunked. Either way there is no length to read to, and what decides whether the body can be read is psgix.input.buffered.
A server that sets it - Hyperman on every protocol version, Starman, anything that buffers the request before calling the application - is holding a finite body already, so it is read to EOF. A server that does not is handing over a live socket, where reading to EOF is how an application hangs: an HTTP/1.1 chunked body croaks with that reason rather than hanging, and a request with no framing header at all is taken to have no body and nothing is read. That last case is the ordinary bodyless POST, and going looking for a body there would turn it into an error.
max is the ceiling in all of this. Without an explicit one the route's "max_body" in Punk applies, which is the ceiling max_body could not enforce up front for want of a declared length to compare against.
cookies
cookie($name)
The request cookie jar (first value wins) / one cookie value.
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)