NAME
PAGI::Response - Fluent response builder for PAGI applications
SYNOPSIS
use PAGI::Response;
use Future::AsyncAwait;
# Basic usage in a raw PAGI app
async sub app ($scope, $receive, $send) {
my $res = PAGI::Response->new($scope, $send);
# Fluent chaining - set status, headers, then send
await $res->status(200)
->header('X-Custom' => 'value')
->json({ message => 'Hello' });
}
# Various response types
await $res->text("Hello World");
await $res->html("<h1>Hello</h1>");
await $res->json({ data => 'value' });
await $res->redirect('/login');
# Streaming large responses
await $res->stream(async sub ($writer) {
await $writer->write("chunk1");
await $writer->write("chunk2");
await $writer->close();
});
# File downloads
await $res->send_file('/path/to/file.pdf', filename => 'doc.pdf');
DESCRIPTION
PAGI::Response provides a fluent interface for building HTTP responses in raw PAGI applications. It wraps the low-level $send callback and provides convenient methods for common response types.
Chainable methods (status, header, content_type, cookie) return $self for fluent chaining.
Finisher methods (text, html, json, redirect, etc.) return Futures and actually send the response. Once a finisher is called, the response is sent and cannot be modified.
Important: Each PAGI::Response instance can only send one response. Attempting to call a finisher method twice will throw an error.
CONSTRUCTOR
new
my $res = PAGI::Response->new($scope, $send);
Creates a new response builder.
The scope is required because PAGI::Response stores the "response sent" flag in $scope->{'pagi.response.sent'}. This ensures that if multiple Response objects are created from the same scope (e.g., in middleware chains), they all share the same "sent" state and prevent double-sending responses.
Note: Per-object state like status and headers is NOT shared between Response objects. Only the "sent" flag is shared via scope. This matches the ASGI pattern where middleware wraps the $send callable to intercept/modify responses, and Response objects build their own status/headers before sending.
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, $send);
$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 full header arrayref [ name, value ] in order.
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.
content_type
$res->content_type('text/html; charset=utf-8');
my $type = $res->content_type;
Set the Content-Type header, replacing any existing one.
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.
stash
my $user = $res->stash->{user};
Returns the per-request stash hashref. This is the same stash accessible via $req->stash, $ws->stash, and $sse->stash - it lives in $scope->{'pagi.stash'} and is shared across all objects in the request chain.
This allows handlers to read values set by middleware:
async sub handler {
my ($self, $req, $res) = @_;
my $user = $res->stash->{user}; # Set by auth middleware
await $res->json({ greeting => "Hello, $user->{name}" });
}
See "stash" in PAGI::Request for detailed documentation on how stash works.
is_sent
if ($res->is_sent) {
warn "Response already sent, cannot send error";
return;
}
Returns true if the response has already been finalized (sent to the client). Useful in error handlers or middleware that need to check whether they can still send a response.
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.
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().
FINISHER METHODS
These methods return Futures and send the response.
text
await $res->text("Hello World");
Send a plain text response with Content-Type: text/plain; charset=utf-8.
html
await $res->html("<h1>Hello</h1>");
Send an HTML response with Content-Type: text/html; charset=utf-8.
json
await $res->json({ message => 'Hello' });
Send a JSON response with Content-Type: application/json; charset=utf-8.
redirect
await $res->redirect('/login');
await $res->redirect('/new-url', 301);
Send a redirect response with an empty body. Default status is 302.
Note: This method sends the response but does NOT stop Perl execution. Use return after redirect if you have more code below:
await $res->redirect('/login');
return; # Important! Code below would still run otherwise
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, use the lower-level $send->() calls directly.
empty
await $res->empty();
Send an empty response with status 204 No Content (or custom status if set).
send
await $res->send($text);
await $res->send($text, charset => 'iso-8859-1');
Send text, encoding it to UTF-8 (or specified charset). Adds charset to Content-Type if not present. This is the high-level method for sending text responses.
send_raw
await $res->send_raw($bytes);
Send raw bytes as the response body without any encoding. Use this for binary data or when you've already encoded the content yourself.
stream
await $res->stream(async sub ($writer) {
await $writer->write("chunk1");
await $writer->write("chunk2");
await $writer->close();
});
Stream response chunks via callback. The callback receives a writer object with write($chunk), close(), and bytes_written() methods.
send_file
await $res->send_file('/path/to/file.pdf');
await $res->send_file('/path/to/file.pdf',
filename => 'download.pdf',
inline => 1,
);
# Partial file (for range requests)
await $res->send_file('/path/to/video.mp4',
offset => 1024, # Start from byte 1024
length => 65536, # Send 64KB
);
Send a file as the response. This method uses the PAGI protocol's file key for efficient server-side streaming. The file is not read into memory. 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 ($req, $send) {
my $res = PAGI::Response->new($scope, $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 $res->status(206)
->header('Content-Range' => "bytes $start-$end/$size")
->header('Accept-Ranges' => 'bytes')
->send_file($path, offset => $start, length => $length);
}
return await $res->header('Accept-Ranges' => 'bytes')
->send_file($path);
}
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 = PAGI::Response->new($scope, $send);
if ($req->method eq 'GET' && $req->path eq '/') {
return await $res->html('<h1>Welcome</h1>');
}
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} });
}
return await $res->status(404)->json({ error => 'Not Found' });
};
Form Validation with Error Response
async sub handle_contact ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
my $form = await $req->form;
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 });
}
# Process valid form...
return await $res->json({ success => 1 });
}
Authentication with Cookies
async sub handle_login ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
my $data = await $req->json;
my $user = authenticate($data->{email}, $data->{password});
unless ($user) {
return await $res->status(401)->json({ error => 'Invalid credentials' });
}
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} } });
}
async sub handle_logout ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
return await $res->delete_cookie('session', path => '/')
->json({ logged_out => 1 });
}
File Download
async sub handle_download ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
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' });
}
return await $res->send_file($file->{path},
filename => $file->{original_name},
);
}
Streaming Large Data
async sub handle_export ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
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");
}
});
}
Server-Sent Events Style Streaming
async sub handle_events ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
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
}
});
}
Conditional Responses
async sub handle_resource ($req, $send) {
my $res = PAGI::Response->new($scope, $send);
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();
}
return await $res->header('ETag' => $etag)
->header('Cache-Control' => 'max-age=3600')
->json({ data => 'expensive computation result' });
}
CORS API Endpoint
# Simple CORS - allow all origins
async sub handle_api ($scope, $receive, $send) {
my $res = PAGI::Response->new($scope, $send);
return await $res->cors->json({ status => 'ok' });
}
# 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 = PAGI::Response->new($scope, $send);
# 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' });
}
CORS Preflight Handler
# Handle OPTIONS preflight requests
async sub app ($scope, $receive, $send) {
my $req = PAGI::Request->new($scope, $receive);
my $res = PAGI::Response->new($scope, $send);
# 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();
}
# Handle actual request
return await $res->cors(
origin => 'https://myapp.com',
credentials => 1,
)->json({ data => 'response' });
}
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 = PAGI::Response->new($scope, $send);
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' });
}
# Origin not allowed - respond without CORS headers
return await $res->status(403)->json({ error => 'Origin not allowed' });
}
WRITER OBJECT
The stream() method passes a writer object to its callback with these methods:
write($chunk)- Write a chunk (returns Future)close()- Close the stream (returns Future)bytes_written()- Get total bytes written so far
The writer automatically closes when the callback completes, but calling close() explicitly is recommended for clarity.
ERROR HANDLING
All finisher methods return Futures. Errors in encoding (e.g., invalid UTF-8 when strict mode would be enabled) will cause the Future to fail.
use Syntax::Keyword::Try;
try {
await $res->json($data);
}
catch ($e) {
warn "Failed to send response: $e";
}
SEE ALSO
PAGI, PAGI::Request, PAGI::Server
AUTHOR
PAGI Contributors