NAME
PAGI::Response - Fluent response builder for PAGI applications
SYNOPSIS
use PAGI::Response;
use Future::AsyncAwait;
# A response is a VALUE: build it, then send it (or return it, or mount it).
# Raw PAGI app: build the value, send it with respond($send)
async sub app ($scope, $receive, $send) {
my $res = PAGI::Response->new($scope); # detached -- no connection
await $res->status(200)
->header('X-Custom' => 'value')
->json({ message => 'Hello' }) # sets the body, returns $self
->respond($send); # the single send step
}
# In an endpoint you just RETURN it; dispatch sends it for you:
async sub get ($self, $ctx) {
return $ctx->json({ message => 'Hello' }, status => 200);
}
# Class-method factories build a detached response in one call;
# status/content_type/headers go as trailing options:
my $res = PAGI::Response->text("Hello World");
my $res = PAGI::Response->html("<h1>Hello</h1>");
my $res = PAGI::Response->json({ data => 'value' });
my $res = PAGI::Response->json({ error => 'not found' }, status => 404);
my $res = PAGI::Response->redirect('/login');
# Because it's a value, it works anywhere an app does:
$router->mount('/health' => PAGI::Response->json({ ok => \1 }));
# Streaming: the callback runs at send time (auto-closes when done)
await PAGI::Response->new($scope)
->content_type('text/csv')
->stream(async sub ($writer) {
await $writer->write("id,name\n");
await $writer->write("1,Alice\n");
})
->respond($send);
# File downloads:
await PAGI::Response->new($scope)
->send_file('/path/to/file.pdf', filename => 'doc.pdf')
->respond($send);
DESCRIPTION
PAGI::Response provides a fluent interface for building HTTP responses in PAGI applications. It is a detached value object: it holds status, headers, and body but has no connection. Sending is done via "respond" or "to_app".
Chainable methods (status, header, content_type, cookie) return $self for fluent chaining.
Body methods (text, html, json, redirect, etc.) set the response body and also return $self. They can be called as class-method factories (PAGI::Response->json($data)) or as instance methods ($res->json($data)).
CONSTRUCTOR
new
my $res = PAGI::Response->new;
my $res = PAGI::Response->new($scope);
Creates a detached response value. The response holds no connection and no $send callback — it is a pure value object that accumulates status, headers, and body via the chainer methods.
$scope- Optional. A PAGI scope hashref. When provided it is stored inert (for accessors likescope()and helpers like PAGI::Stash). It is not used as a connection — no$sendis stored here.
To actually send the response, call "respond" with the $send callback, or mount it as a PAGI app via "to_app".
Because the constructor stores no connection, the same response value can be served to multiple connections (re-entrantly) by calling respond more than once.
CHAINABLE METHODS
These methods return $self for fluent chaining.
status
$res->status(404);
my $code = $res->status;
Set or get the HTTP status code (100-599). Returns $self when setting for fluent chaining. When getting, returns 200 if no status has been set.
my $res = PAGI::Response->new($scope);
$res->status; # 200 (default, nothing set yet)
$res->has_status; # false
$res->status(201); # set explicitly
$res->has_status; # true
status_try
$res->status_try(404);
Set the HTTP status code only if one hasn't been set yet. Useful in middleware or error handlers to provide fallback status codes without overriding choices made by the application:
$res->status_try(202); # sets to 202 (nothing was set)
$res->status_try(500); # no-op, 202 already set
header
$res->header('X-Custom' => 'value');
my $value = $res->header('X-Custom');
Add a response header. Can be called multiple times to add multiple headers. If called with only a name, returns the last value for that header or undef.
headers
my $headers = $res->headers;
Returns the response headers as a PAGI::Headers object. The object's @{} overload yields a copy of the [name, value] pairs in insertion order, so existing code that iterates @{$res-headers}> continues to work.
header_all
my @values = $res->header_all('Set-Cookie');
Returns all values for the given header name (case-insensitive).
header_try
$res->header_try('X-Custom' => 'value');
Add a response header only if that header name has not already been set.
remove_header
$res->remove_header('X-Custom');
Remove all instances of the named header (case-insensitive). Returns $self for fluent chaining. No-op if the header was not set.
content_type
$res->content_type('text/html; charset=utf-8');
my $type = $res->content_type;
$res->content_type(undef); # clears Content-Type so a body method can re-default it
Set the Content-Type header, replacing any existing one. Passing undef removes Content-Type entirely, which lets a subsequent body method (html, text, json) re-apply its default.
content_type_try
$res->content_type_try('text/html; charset=utf-8');
Set the Content-Type header only if it has not already been set.
cookie
$res->cookie('session' => 'abc123',
max_age => 3600,
path => '/',
domain => 'example.com',
secure => 1,
httponly => 1,
samesite => 'Strict',
);
Set a response cookie. Options: max_age, expires, path, domain, secure, httponly, samesite.
delete_cookie
$res->delete_cookie('session');
Delete a cookie by setting it with Max-Age=0.
scope
my $scope = $res->scope;
Returns the raw PAGI scope hashref. Useful for constructing helper objects like PAGI::Stash and PAGI::Session:
my $stash = PAGI::Stash->new($res);
Per-Request Shared State
See PAGI::Stash for per-request shared state. Construct from a Response object or from the shared scope:
use PAGI::Stash;
my $stash = PAGI::Stash->new($res);
is_sent
if ($res->is_sent) {
warn "Response already sent, cannot send error";
return;
}
Returns true if the server-owned pagi.connection object for this request reports response_started — meaning the response has started on this connection (headers have been emitted). Reflects a server-owned fact, not a flag on this Response value.
Returns 0 if there is no pagi.connection in scope (server-less / not started). Dies (croak) if a pagi.connection is present but lacks the response_started method, which indicates a non-conforming server.
has_status
if ($res->has_status) { ... }
Returns true if a status code has been explicitly set via status or status_try.
has_header
if ($res->has_header('content-type')) { ... }
Returns true if the given header name has been set via header or header_try. Header names are case-insensitive.
has_content_type
if ($res->has_content_type) { ... }
Returns true if Content-Type has been explicitly set via content_type, content_type_try, or header/header_try with a Content-Type name.
has_body_source
if ($res->has_body_source) { ... }
Returns true if a body source has been registered on the response — a buffered body (via text/html/json/send/send_raw/empty/ redirect), a file (via send_file), or a stream callback (via stream).
This is a build-phase, intent-level signal. It answers "did the handler register something to send?", not "have any bytes been produced or sent":
For a
stream, it is true the instant the callback is registered, beforerespondruns it and before a single byte is written. A registered stream that has produced zero bytes still reportshas_body_sourcetrue — that is the only coherent meaning, sincerespondis what drives the stream.An intentional empty body counts:
empty,redirect, andsend_raw('')all register a body (the empty string), so they report true. A response that has had no body method called reports false.It is independent of "is_sent".
has_body_sourcedescribes the value;is_sentdescribes whether the value has gone out on a connection. For "has the response been emitted", useis_sent; for "has the stream finished",awaitthe Future returned byrespond.
A response whose handler set only a status or a header (no body method) reports has_body_source false even though it is a legitimate response (e.g. a bare 204 or a redirect built only via status + header). Frameworks deciding whether to auto-send should therefore test $res->has_body_source || $res->has_status. See the "RESPONSE STATE & LIFECYCLE" in PAGI::Cookbook section for the full state model.
cors
# Allow all origins (simplest case)
$res->cors->json({ data => 'value' });
# Allow specific origin
$res->cors(origin => 'https://example.com')->json($data);
# Full configuration
$res->cors(
origin => 'https://example.com',
methods => [qw(GET POST PUT DELETE)],
headers => [qw(Content-Type Authorization)],
expose => [qw(X-Request-Id X-RateLimit-Remaining)],
credentials => 1,
max_age => 86400,
preflight => 0,
)->json($data);
Add CORS (Cross-Origin Resource Sharing) headers to the response. Returns $self for chaining.
Options:
origin- Allowed origin. Default:'*'(all origins). Can be a specific origin like'https://example.com'or'*'for any.methods- Arrayref of allowed HTTP methods for preflight. Default:[qw(GET POST PUT DELETE PATCH OPTIONS)].headers- Arrayref of allowed request headers for preflight. Default:[qw(Content-Type Authorization X-Requested-With)].expose- Arrayref of response headers to expose to the client. By default, only simple headers (Cache-Control, Content-Language, etc.) are accessible. Use this to expose custom headers.credentials- Boolean. If true, setsAccess-Control-Allow-Credentials: true, allowing cookies and Authorization headers. Default:0.max_age- How long (in seconds) browsers should cache preflight results. Default:86400(24 hours).preflight- Boolean. If true, includes preflight response headers (Allow-Methods, Allow-Headers, Max-Age). Set this when handling OPTIONS requests. Default:0.request_origin- The Origin header value from the request. Required whencredentialsis true andoriginis'*', because the CORS spec forbids using'*'with credentials. Pass the actual request origin to echo it back.
Important CORS Notes:
When
credentialsis true, you cannot useorigin => '*'. Either specify an exact origin, or passrequest_originwith the client's actual Origin header.The
Vary: Originheader is always set to ensure proper caching when origin-specific responses are used.For preflight (OPTIONS) requests, set
preflight => 1and typically respond with$res->status(204)->empty().
SEND PRIMITIVE AND APP MOUNTING
respond
await $res->respond($send);
The single send primitive for a detached response value. Reads the accumulated status, headers, and body from $self and emits the appropriate PAGI protocol events via $send.
$send must be a coderef (the PAGI send callback). respond does not mutate the response object, so the same response value can be passed to respond multiple times for different connections.
For streaming responses (set up via the _stream slot), respond sends the start event, runs the stream callback with a PAGI::Response::Writer, and ensures the writer is closed.
Returns a Future.
to_app
my $app = $res->to_app;
Returns a PAGI application coderef sub ($scope, $receive, $send) that calls "respond" with the given $send when invoked. Use this to mount a response value directly as a PAGI app:
my $not_found = PAGI::Response->new
->status(404)
->_set_body('Not Found', 'text/plain');
# Mount as a fallback app
my $app = $not_found->to_app;
BODY METHODS
These methods set the response body and return $self. Sending happens via "respond" / "to_app" or the endpoint return contract.
Each method works as both a class-method factory and an instance method:
# Class-method factory — creates a new detached response and returns it
return $ctx->json($data); # instance method on existing $res
return PAGI::Response->json($data); # factory shorthand
# Chain body with other setters before sending
PAGI::Response->json($data)->status(201)->respond($send)->get;
The Content-Type these methods set is a default: an explicit content_type set beforehand is preserved, not overridden.
These helpers UTF-8-encode the body, so they make the Content-Type advertise that encoding. When you preset a charset-less type they append ; charset=utf-8 to it — content_type('application/xml')->html($xml) sends application/xml; charset=utf-8 (charset is meaningful for XML and text/*, RFC 7303). The exceptions are application/json and the +json structured-suffix types, which are left bare: JSON is always UTF-8 and defines no charset parameter (RFC 8259). An explicit charset you set yourself is never overridden. If you need a body in some other encoding, encode it yourself and use "send_raw".
Trailing options (status, content_type, headers)
The body methods text, html, json, send_raw, and empty accept trailing named options as a convenience so you can set status, content-type, and extra headers in a single call without chaining:
PAGI::Response->json($data, status => 404);
PAGI::Response->text('Hi', status => 201, headers => ['X-Foo' => 'bar']);
PAGI::Response->send_raw($bytes, content_type => 'application/octet-stream');
PAGI::Response->empty(status => 304);
Recognised options:
- status — HTTP status code (integer).
- content_type — sets the Content-Type header, overriding any default.
- headers — a flat arrayref of
name => valuepairs to append. Example:headers => ['X-Foo' => 'bar', 'X-Baz' => 'qux'].
An unrecognised option name causes an immediate croak, catching typos such as status_code = 404> before they silently send 200.
The existing chaining form ->json($data)->status(404) keeps working.
text
$res->text("Hello World");
PAGI::Response->text("Hello World");
PAGI::Response->text("Not found", status => 404);
Set body to the UTF-8–encoded string with Content-Type: text/plain; charset=utf-8. Accepts trailing options (status, content_type, headers). Returns $self.
html
$res->html("<h1>Hello</h1>");
PAGI::Response->html("<h1>Hello</h1>");
PAGI::Response->html("<p>Error</p>", status => 500);
Set body to the UTF-8–encoded string with Content-Type: text/html; charset=utf-8. Accepts trailing options (status, content_type, headers). Returns $self.
json
$res->json({ message => 'Hello' });
PAGI::Response->json({ message => 'Hello' });
PAGI::Response->json({ error => 'nope' }, status => 404);
Set body to the JSON-encoded data with Content-Type: application/json. No charset parameter is added — JSON is always UTF-8 and application/json defines none (RFC 8259). Accepts trailing options (status, content_type, headers). Returns $self.
redirect
$res->redirect('/login');
$res->redirect('/new-url', 301);
PAGI::Response->redirect('/login');
Set an empty body and a Location header. Default status is 302. Returns $self.
Why no body? While RFC 7231 suggests including a short HTML body with a hyperlink for clients that don't auto-follow redirects, all modern browsers and HTTP clients ignore redirect bodies. If you need a body for legacy compatibility, set it explicitly after calling redirect.
empty
$res->empty;
PAGI::Response->new->empty;
PAGI::Response->empty(status => 304);
Set an empty body with status 204 No Content (or keep a previously set status). Accepts trailing options (status, content_type, headers); an explicit status option overrides the 204 default. Returns $self.
send
$res->send($text);
$res->send($text, charset => 'iso-8859-1');
Set body to the encoded text (UTF-8 by default, or the specified charset). Defaults the Content-Type to text/plain and appends the charset to a charset-less type, on the same rules as "text" (application/json and +json types stay bare). Returns $self.
send_raw
$res->send_raw($bytes);
PAGI::Response->send_raw($bytes, content_type => 'application/octet-stream');
Set body to raw bytes without any encoding. Use for binary data or pre-encoded content. Accepts trailing options (status, content_type, headers). Returns $self.
stream
$res->stream(async sub {
my ($writer) = @_;
await $writer->write("chunk1");
await $writer->write("chunk2");
await $writer->close();
});
PAGI::Response->stream($callback);
Store a streaming callback. When the response is sent via "respond", the callback receives a PAGI::Response::Writer and streams chunks. Returns $self.
writer
my $writer = await $res->writer($send);
my $writer = await $res->writer($send, on_close => sub { cleanup() });
my $writer = await $res->writer($send, on_close => async sub { await cleanup() });
Returns a PAGI::Response::Writer directly, sending headers immediately. Unlike stream(), the writer is not scoped to a callback — you own it and must call close() when done.
$send must be a coderef (the PAGI send callback). This is the same $send you would pass to "respond".
This is useful when the writer needs to be passed to event handlers, pub/sub callbacks, timers, or other contexts outside a single function:
async sub live_feed {
my ($self, $ctx) = @_;
my $writer = await $ctx->response
->content_type('text/plain')
->writer($ctx->send, on_close => sub { $bus->unsubscribe($id) });
my $id = $bus->subscribe(async sub ($line) {
await $writer->write("$line\n");
});
await $ctx->receive; # wait for disconnect
await $writer->close;
}
The optional on_close callback is registered before headers are sent, eliminating any race window with fast client disconnects. Sync and async callbacks are both supported — see "on_close" under "WRITER OBJECT".
send_file
$res->send_file('/path/to/file.pdf');
$res->send_file('/path/to/file.pdf',
filename => 'download.pdf',
inline => 1,
);
PAGI::Response->send_file('/path/to/file.pdf');
# Partial file (for range requests)
$res->send_file('/path/to/video.mp4',
offset => 1024, # Start from byte 1024
length => 65536, # Send 64KB
);
Set the response to serve a file. Stats the file and sets Content-Type, Content-Length, and Content-Disposition at call time. The PAGI protocol's file key is used for efficient server-side streaming (file not read into memory) when "respond" is called. Returns $self.
For production, use PAGI::Middleware::XSendfile to delegate file serving to your reverse proxy.
Options:
filename- Set Content-Disposition attachment filenameinline- Use Content-Disposition: inline instead of attachmentoffset- Start position in bytes (default: 0). For range requests.length- Number of bytes to send. Defaults to file size minus offset.
Range Request Example:
# Manual range request handling
async sub handle_video {
my ($req, $send) = @_;
my $path = '/videos/movie.mp4';
my $size = -s $path;
my $range = $req->header('Range');
if ($range && $range =~ /bytes=(\d+)-(\d*)/) {
my $start = $1;
my $end = $2 || ($size - 1);
my $length = $end - $start + 1;
return await PAGI::Response->new
->status(206)
->header('Content-Range' => "bytes $start-$end/$size")
->header('Accept-Ranges' => 'bytes')
->send_file($path, offset => $start, length => $length)
->respond($send);
}
return await PAGI::Response->new
->header('Accept-Ranges' => 'bytes')
->send_file($path)
->respond($send);
}
Note: For production file serving with full features (ETag caching, automatic range request handling, conditional GETs, directory indexes), use PAGI::App::File instead:
use PAGI::App::File;
my $files = PAGI::App::File->new(root => '/var/www/static');
my $app = $files->to_app;
EXAMPLES
Complete Raw PAGI Application
use Future::AsyncAwait;
use PAGI::Request;
use PAGI::Response;
my $app = async sub ($scope, $receive, $send) {
return await handle_lifespan($scope, $receive, $send)
if $scope->{type} eq 'lifespan';
my $req = PAGI::Request->new($scope, $receive);
my $res = $req->response;
if ($req->method eq 'GET' && $req->path eq '/') {
return await $res->html('<h1>Welcome</h1>')->respond($send);
}
if ($req->method eq 'POST' && $req->path eq '/api/users') {
my $data = await $req->json;
# ... create user ...
return await $res->status(201)
->header('Location' => '/api/users/123')
->json({ id => 123, name => $data->{name} })
->respond($send);
}
return await $res->status(404)->json({ error => 'Not Found' })->respond($send);
};
Form Validation with Error Response
async sub handle_contact ($req, $send) {
my $res = $req->response;
my $form = await $req->form_params;
my @errors;
my $email = $form->get('email') // '';
my $message = $form->get('message') // '';
push @errors, 'Email required' unless $email;
push @errors, 'Invalid email' unless $email =~ /@/;
push @errors, 'Message required' unless $message;
if (@errors) {
return await $res->status(422)
->json({ error => 'Validation failed', errors => \@errors })
->respond($send);
}
# Process valid form...
return await $res->json({ success => 1 })->respond($send);
}
Authentication with Cookies
async sub handle_login ($req, $send) {
my $res = $req->response;
my $data = await $req->json;
my $user = authenticate($data->{email}, $data->{password});
unless ($user) {
return await $res->status(401)->json({ error => 'Invalid credentials' })->respond($send);
}
my $session_id = create_session($user);
return await $res->cookie('session' => $session_id,
path => '/',
httponly => 1,
secure => 1,
samesite => 'Strict',
max_age => 86400, # 24 hours
)
->json({ user => { id => $user->{id}, name => $user->{name} } })
->respond($send);
}
async sub handle_logout ($req, $send) {
my $res = $req->response;
return await $res->delete_cookie('session', path => '/')
->json({ logged_out => 1 })
->respond($send);
}
File Download
async sub handle_download ($req, $send) {
my $res = $req->response;
my $file_id = $req->path_param('id');
my $file = get_file($file_id); # Be sure to clean $file
unless ($file && -f $file->{path}) {
return await $res->status(404)->json({ error => 'File not found' })->respond($send);
}
return await $res->send_file($file->{path},
filename => $file->{original_name},
)->respond($send);
}
Streaming Large Data
async sub handle_export ($req, $send) {
my $res = $req->response;
await $res->content_type('text/csv')
->header('Content-Disposition' => 'attachment; filename="export.csv"')
->stream(async sub ($writer) {
# Write CSV header
await $writer->write("id,name,email\n");
# Stream rows from database
my $cursor = get_all_users_cursor();
while (my $user = $cursor->next) {
await $writer->write("$user->{id},$user->{name},$user->{email}\n");
}
})
->respond($send);
}
Server-Sent Events Style Streaming
async sub handle_events ($req, $send) {
my $res = $req->response;
await $res->content_type('text/event-stream')
->header('Cache-Control' => 'no-cache')
->stream(async sub ($writer) {
for my $i (1..10) {
await $writer->write("data: Event $i\n\n");
await some_delay(1); # Wait 1 second
}
})
->respond($send);
}
Conditional Responses
async sub handle_resource ($req, $send) {
my $res = $req->response;
my $etag = '"abc123"';
# Check If-None-Match for caching
my $if_none_match = $req->header('If-None-Match') // '';
if ($if_none_match eq $etag) {
return await $res->status(304)->empty()->respond($send);
}
return await $res->header('ETag' => $etag)
->header('Cache-Control' => 'max-age=3600')
->json({ data => 'expensive computation result' })
->respond($send);
}
CORS API Endpoint
# Simple CORS - allow all origins
async sub handle_api ($scope, $receive, $send) {
my $res = PAGI::Response->new($scope);
return await $res->cors->json({ status => 'ok' })->respond($send);
}
# CORS with credentials (e.g., cookies, auth headers)
async sub handle_api_with_auth ($scope, $receive, $send) {
my $req = PAGI::Request->new($scope, $receive);
my $res = $req->response;
# Get the Origin header from request
my $origin = $req->header('Origin');
return await $res->cors(
origin => 'https://myapp.com', # Or use request_origin
credentials => 1,
expose => [qw(X-Request-Id)],
)->json({ user => 'authenticated' })->respond($send);
}
CORS Preflight Handler
# Handle OPTIONS preflight requests
async sub app ($scope, $receive, $send) {
my $req = PAGI::Request->new($scope, $receive);
my $res = $req->response;
# Handle preflight
if ($req->method eq 'OPTIONS') {
return await $res->cors(
origin => 'https://myapp.com',
methods => [qw(GET POST PUT DELETE)],
headers => [qw(Content-Type Authorization X-Custom-Header)],
credentials => 1,
max_age => 86400,
preflight => 1, # Include preflight headers
)->status(204)->empty()->respond($send);
}
# Handle actual request
return await $res->cors(
origin => 'https://myapp.com',
credentials => 1,
)->json({ data => 'response' })->respond($send);
}
Dynamic CORS Origin
# Allow multiple origins dynamically
my %ALLOWED_ORIGINS = map { $_ => 1 } qw(
https://app1.example.com
https://app2.example.com
https://localhost:3000
);
async sub handle_api ($scope, $receive, $send) {
my $req = PAGI::Request->new($scope, $receive);
my $res = $req->response;
my $request_origin = $req->header('Origin') // '';
# Check if origin is allowed
if ($ALLOWED_ORIGINS{$request_origin}) {
return await $res->cors(
origin => $request_origin, # Echo back the allowed origin
credentials => 1,
)->json({ data => 'allowed' })->respond($send);
}
# Origin not allowed - respond without CORS headers
return await $res->status(403)->json({ error => 'Origin not allowed' })->respond($send);
}
WRITER OBJECT
The stream() method passes a writer object to its callback, and writer() returns one directly. The writer has the following methods:
write
await $writer->write($chunk);
Write a chunk of data to the response stream. Returns a Future.
Writing after close returns a failed Future rather than throwing. This allows cleanup code that races with close to handle the error gracefully via await.
close
await $writer->close;
Close the stream. Returns a Future. Calling close multiple times is safe — subsequent calls are no-ops.
bytes_written
my $n = $writer->bytes_written;
Returns the total number of bytes written so far.
on_close
# Sync callback
$writer->on_close(sub { cleanup() });
# Async callback — return value is awaited automatically
$writer->on_close(async sub {
await notify_stream_ended();
});
# Chaining
$writer->on_close(sub { ... })
->on_close(sub { ... });
Registers a callback to fire when the writer closes (either explicitly or via stream() auto-close). Callbacks can be regular subs or async subs — async results are automatically awaited. Multiple callbacks run in registration order. Exceptions are caught and warned but do not prevent other callbacks from running. Returns $self for chaining.
Circular reference note: If your callback captures the writer object in a closure, use Scalar::Util::weaken to avoid a memory leak:
use Scalar::Util qw(weaken);
my $weak_writer = $writer;
weaken($weak_writer);
$writer->on_close(sub { $weak_writer->... if $weak_writer });
The callback array is cleared after firing, so any cycle via a closure is broken when the writer closes, but weaken prevents the object from being kept alive until that point.
is_closed
if ($writer->is_closed) { ... }
Returns true if the writer has been closed.
The writer automatically closes when the stream() callback completes, but calling close() explicitly is recommended for clarity.
ERROR AND ALTERNATE RESPONSES
A response is a value, so "produce a 404 instead" is just returning a different value -- no exceptions needed:
async sub show ($self, $ctx) {
my $user = await find_user($ctx->req->path_param('id'));
return PAGI::Response->json({ error => 'not found' }, status => 404)
unless $user;
return $ctx->json($user);
}
For cases that recur across handlers, prefer modeling the absence as a value (a "null object") whose own method returns the right response, instead of throwing from deep in the stack:
my $user = await find_user($ctx) // UnauthenticatedUser->new($ctx);
return $user->dashboard; # a real user renders; an UnauthenticatedUser
# returns a 401 / login response
Here UnauthenticatedUser is a class you define; its dashboard method returns a PAGI::Response just as a real user's would.
SUBCLASSING (FRAMEWORK INTEGRATION)
Framework authors can subclass PAGI::Response to add their own response sugar while reusing the value machinery. The contract is small and stable:
Construct via
$class->new($scope). The scope is optional and inert (used only forscope()and helpers like PAGI::Stash); a response never holds a connection. A Moose subclass canextends 'PAGI::Response'and provideFOREIGNBUILDARGSreturning($scope).Override
respond($send)to customize how the response is sent. Call$self->SUPER::respond($send)to do the actual emission. The connection ($send) arrives as the argument; do not store or re-bind it -- a response value is connection-free until the moment it is sent.Build on the public surface --
status,header,headers,content_type,cookie,cors,is_sent, thehas_*predicates (has_status,has_header,has_content_type,has_body_source), and the body methods (text/html/json/send_raw/empty/redirect/stream/send_file, with trailing options). Do not reach into the_-prefixed internals (_headers,_body,_status,_stream, ...); they are private and may change.Adding response sugar via a role/mixin works unchanged -- a role that calls the public chainers and body methods needs no special support.
A response value never needs $send until it is sent, so "I don't have a connection here" just means "I am not sending yet": hold the value and call respond (or return it from an endpoint, where dispatch sends it) when a connection is available.
SEE ALSO
PAGI, PAGI::Request, PAGI::Server
AUTHOR
PAGI Contributors