NAME
PAGI::App::Router - Unified routing for HTTP, WebSocket, and SSE
SYNOPSIS
use PAGI::App::Router;
my $router = PAGI::App::Router->new;
# HTTP routes (method + path)
$router->get('/users/:id' => $get_user);
$router->post('/users' => $create_user);
$router->delete('/users/:id' => $delete_user);
# Routes with middleware
$router->get('/admin' => [$auth_mw, $log_mw] => $admin_handler);
$router->post('/api/data' => [$rate_limit] => $data_handler);
# WebSocket routes (path only)
$router->websocket('/ws/chat/:room' => $chat_handler);
# SSE routes (path only)
$router->sse('/events/:channel' => $events_handler);
# Mount with middleware (applies to all sub-routes)
$router->mount('/api' => [$auth_mw] => $api_router->to_app);
# Mount from a package (auto-require + to_app)
$router->mount('/admin' => 'MyApp::Admin');
# Static files as fallback
$router->mount('/' => $static_files);
# Named routes for URL generation
$router->get('/users/:id' => $get_user)->name('users.get');
$router->post('/users' => $create_user)->name('users.create');
my $url = $router->uri_for('users.get', { id => 42 });
# Returns: "/users/42"
# Namespace mounted routers
$router->mount('/api/v1' => $api_router)->as('api');
$router->uri_for('api.users.get', { id => 42 });
# Returns: "/api/v1/users/42"
# Match any HTTP method
$router->any('/health' => $health_handler);
$router->any('/resource' => $handler, method => ['GET', 'POST']);
# Path constraints (inline)
$router->get('/users/{id:\d+}' => $get_user);
# Path constraints (chained)
$router->get('/posts/:slug' => $get_post)
->constraints(slug => qr/^[a-z0-9-]+$/);
# Route grouping (flattened into parent)
$router->group('/api' => [$auth_mw] => sub {
my ($r) = @_;
$r->get('/users' => $list_users);
$r->post('/users' => $create_user);
});
# Include routes from another router
$router->group('/api/v2' => $v2_router);
# Include routes from a package
$router->group('/api/users' => 'MyApp::Routes::Users');
my $app = $router->to_app; # Handles all scope types
DESCRIPTION
Unified router supporting HTTP, WebSocket, and SSE in a single declarative interface. Routes requests based on scope type first, then path pattern. HTTP routes additionally match on method. Returns 404 for unmatched paths and 405 for unmatched HTTP methods. Lifespan events are automatically ignored.
OPTIONS
not_found- Custom app to handle unmatched routes (all scope types)
METHODS
HTTP Route Methods
$router->get($path => $app);
$router->post($path => $app);
$router->put($path => $app);
$router->patch($path => $app);
$router->delete($path => $app);
$router->head($path => $app);
$router->options($path => $app);
Register a route for the given HTTP method. Returns $self for chaining.
any
$router->any('/health' => $app); # all methods
$router->any('/resource' => $app, method => ['GET', 'POST']); # specific methods
$router->any('/path' => \@middleware => $app); # with middleware
Register a route that matches multiple or all HTTP methods. Without a method option, matches any HTTP method. With method, only matches the specified methods and returns 405 for others.
Returns $self for chaining (supports name(), constraints()).
group
# Callback form
$router->group('/prefix' => sub { my ($r) = @_; ... });
$router->group('/prefix' => \@middleware => sub { my ($r) = @_; ... });
# Router-object form
$router->group('/prefix' => $other_router);
$router->group('/prefix' => \@middleware => $other_router);
# String form (auto-require)
$router->group('/prefix' => 'MyApp::Routes::Users');
$router->group('/prefix' => \@middleware => 'MyApp::Routes::Users');
Flatten routes under a shared prefix with optional shared middleware. Unlike mount(), grouped routes are registered directly on the parent router — there is no separate dispatch context, 405 handling is unified, and named routes are directly accessible.
Callback form: The coderef receives the router itself. All route registrations inside the callback are prefixed automatically.
Router-object form: Routes are copied from the source router at call time (snapshot semantics). Later modifications to the source do not affect the parent.
String form: The package is loaded via require, then $package->router is called. The result must be a PAGI::App::Router instance.
Group middleware is prepended to each route's middleware chain:
$router->group('/api' => [$auth] => sub {
my ($r) = @_;
$r->get('/data' => [$rate_limit] => $handler);
# Middleware chain: $auth -> $rate_limit -> $handler
});
Groups can be nested:
$router->group('/orgs/:org_id' => [$load_org] => sub {
my ($r) = @_;
$r->group('/teams/:team_id' => [$load_team] => sub {
my ($r) = @_;
$r->get('/members' => $handler);
# Path: /orgs/:org_id/teams/:team_id/members
# Middleware: $load_org -> $load_team -> $handler
});
});
Returns $self for chaining (supports as() for named route namespacing).
See "GROUP VS MOUNT" for a detailed comparison.
websocket
$router->websocket('/ws/chat/:room' => $chat_handler);
Register a WebSocket route. Matches requests where $scope->{type} is 'websocket'. Path parameters work the same as HTTP routes.
sse
$router->sse('/events/:channel' => $events_handler);
Register an SSE (Server-Sent Events) route. Matches requests where $scope->{type} is 'sse'. Path parameters work the same as HTTP routes.
mount
$router->mount('/api' => $api_app);
$router->mount('/admin' => $admin_router);
# String form (auto-require)
$router->mount('/admin' => 'MyApp::Admin');
$router->mount('/admin' => \@middleware => 'MyApp::Admin');
Mount a PAGI app under a path prefix. The mounted app receives requests with the prefix stripped from the path and added to root_path.
The target can be a PAGI app coderef, a PAGI::App::Router object, or a package name string. When a Router object is passed directly, ->as() can be used to namespace its named routes. When a coderef or string form is used, ->as() is not available because there is no router object to import names from.
String form: The package is loaded via require, then $package->to_app is called. The result must be a PAGI app coderef. This is useful for packages that implement to_app as a class method:
package MyApp::Admin;
sub to_app {
my $r = PAGI::App::Router->new;
$r->get('/dashboard' => $dashboard);
return $r->to_app;
}
# Then in your main router:
$router->mount('/admin' => 'MyApp::Admin');
When a request for /api/users/42 hits a router with /api mounted:
The mounted app sees
$scope->{path}as/users/42$scope->{root_path}becomes/api(or appends to existing)
Routes are checked before mounts. If no route matches, mounts are tried as a fallback. Longer prefixes match first, so /api/v2 takes priority over /api.
Example: Organizing a large application
# API routes
my $api = PAGI::App::Router->new;
$api->get('/users' => $list_users);
$api->get('/users/:id' => $get_user);
$api->post('/users' => $create_user);
# Admin routes
my $admin = PAGI::App::Router->new;
$admin->get('/dashboard' => $dashboard);
$admin->get('/settings' => $settings);
# Main router
my $main = PAGI::App::Router->new;
$main->get('/' => $home);
$main->mount('/api' => $api->to_app);
$main->mount('/admin' => $admin->to_app);
# Resulting routes:
# GET / -> $home
# GET /api/users -> $list_users (path=/users, root_path=/api)
# GET /admin/dashboard -> $dashboard (path=/dashboard, root_path=/admin)
to_app
my $app = $router->to_app;
Returns a PAGI application coderef that dispatches requests.
ROUTE-LEVEL MIDDLEWARE
All route methods accept an optional middleware arrayref before the app:
$router->get('/path' => \@middleware => $app);
$router->post('/path' => \@middleware => $app);
$router->mount('/prefix' => \@middleware => $sub_app);
$router->websocket('/ws' => \@middleware => $handler);
$router->sse('/events' => \@middleware => $handler);
Middleware Types
PAGI::Middleware instance
Any object with a
call($scope, $receive, $send, $app)method:use PAGI::Middleware::RateLimit; my $rate_limit = PAGI::Middleware::RateLimit->new(limit => 100); $router->get('/api/data' => [$rate_limit] => $handler);Coderef with $next signature
my $timing = async sub ($scope, $receive, $send, $next) { my $start = time; await $next->(); # Call next middleware or app warn sprintf "Request took %.3fs", time - $start; }; $router->get('/api/data' => [$timing] => $handler);
Execution Order
Middleware executes in array order for requests, reverse order for responses (onion model):
$router->get('/' => [$mw1, $mw2, $mw3] => $app);
# Request flow: mw1 -> mw2 -> mw3 -> app
# Response flow: mw1 <- mw2 <- mw3 <- app
Short-Circuiting
Middleware can skip calling $next to short-circuit the chain:
my $auth = async sub ($scope, $receive, $send, $next) {
unless ($scope->{user}) {
await $send->({
type => 'http.response.start',
status => 401,
headers => [['content-type', 'text/plain']],
});
await $send->({
type => 'http.response.body',
body => 'Unauthorized',
});
return; # Don't call $next
}
await $next->();
};
Stacking with Mount
Mount middleware runs before any sub-router middleware:
my $api = PAGI::App::Router->new;
$api->get('/users' => [$rate_limit] => $list_users);
$router->mount('/api' => [$auth] => $api->to_app);
# Request to /api/users runs: $auth -> $rate_limit -> $list_users
PATH PATTERNS
/users/:id- Named parameter (colon syntax), captured asparams->{id}/users/{id}- Named parameter (brace syntax), same as:id/users/{id:\d+}- Constrained parameter, only matches if value matches\d+/files/*path- Wildcard, captures rest of path asparams->{path}
Literal path segments are properly escaped, so metacharacters like ., (, [ in paths match literally. For example, /api/v1.0/users only matches a literal dot, not any character.
CONSTRAINTS
Path parameters can be constrained with regex patterns. A constrained parameter must match its pattern for the route to match; if it doesn't, the router tries the next route.
Inline Constraints
Embed the pattern directly in the path:
$router->get('/users/{id:\d+}' => $handler);
$router->get('/posts/{slug:[a-z0-9-]+}' => $handler);
Chained Constraints
Apply constraints after route registration using constraints():
$router->get('/users/:id' => $handler)
->constraints(id => qr/^\d+$/);
Constraint values must be compiled regexes (qr//). The regex is anchored to the full parameter value during matching.
Both syntaxes can be combined. Chained constraints are merged with any inline constraints.
constraints
$router->get('/path/:param' => $handler)->constraints(param => qr/pattern/);
Apply regex constraints to path parameters. Returns $self for chaining. Croaks if called without a preceding route or with a non-Regexp constraint.
SCOPE ADDITIONS
The router adds the following to scope when a route matches:
$scope->{path_params} # Captured path parameters (hashref)
$scope->{'pagi.router'}{route} # Matched route pattern
Path parameters use a router-agnostic key (path_params) so that PAGI::Request, PAGI::Response, PAGI::WebSocket, and PAGI::SSE can access them via ->path_param('name') regardless of which router implementation populated them.
For mounted apps, root_path is updated to include the mount prefix.
NAMED ROUTES
Routes can be named for URL generation using the name() method:
$router->get('/users/:id' => $handler)->name('users.get');
$router->post('/users' => $handler)->name('users.create');
name
$router->get('/path' => $handler)->name('route.name');
Assign a name to the most recently added route. Returns $self for chaining. Croaks if called without a preceding route or with an empty name.
uri_for
my $path = $router->uri_for($name, \%path_params, \%query_params);
Generate a URL path for a named route.
$router->uri_for('users.get', { id => 42 });
# Returns: "/users/42"
$router->uri_for('users.list', {}, { page => 2, limit => 10 });
# Returns: "/users?limit=10&page=2"
Croaks if the route name is unknown or if a required path parameter is missing.
named_routes
my $routes = $router->named_routes;
Returns a hashref of all named routes for inspection.
as
$router->mount('/api' => $sub_router)->as('api');
$router->group('/api' => $api_router)->as('api');
Assign a namespace to named routes from a mounted router or group.
$router->group('/api/v1' => sub {
my ($r) = @_;
$r->get('/users' => $h)->name('users.list');
})->as('v1');
$router->uri_for('v1.users.list');
# Returns: "/api/v1/users"
For mounts, imports all named routes from the sub-router into the parent with the namespace prefix:
my $api = PAGI::App::Router->new;
$api->get('/users/:id' => $h)->name('users.get');
my $main = PAGI::App::Router->new;
$main->mount('/api/v1' => $api)->as('api');
$main->uri_for('api.users.get', { id => 42 });
# Returns: "/api/v1/users/42"
Croaks if called without a preceding mount or group, or if the mount target is an app coderef rather than a router object.
GROUP VS MOUNT
group() and mount() both organize routes under a prefix, but they work very differently. Choosing the wrong one leads to surprising behavior, so it's worth understanding the distinction.
The Short Version
group() flattens routes into the parent router. mount() delegates to a separate application.
# group: routes live in the parent
$router->group('/api' => sub {
my ($r) = @_;
$r->get('/users' => $list_users); # registered on $router
});
# mount: routes live in a separate app
$router->mount('/api' => $api->to_app); # $api is independent
Key Differences
- Route storage
-
group()registers every route directly on the parent router.mount()keeps the mounted app opaque — the parent knows nothing about individual routes inside it. - Path handling
-
group()prepends the prefix to each route's path at registration time. The handler sees the full original path.mount()strips the prefix before dispatching. The mounted app sees a shorter path in$scope->{path}and the stripped prefix in$scope->{root_path}.# group: handler sees full path $router->group('/api' => sub { my ($r) = @_; $r->get('/users' => sub { my ($scope, $receive, $send) = @_; # $scope->{path} is "/api/users" }); }); # mount: handler sees stripped path $router->mount('/api' => $api->to_app); # Inside $api, handler sees $scope->{path} = "/users" # $scope->{root_path} = "/api" - 405 Method Not Allowed
-
group()routes participate in the parent's unified 405 detection. IfGET /api/usersexists but someone sendsDELETE /api/users, the parent router knows to return 405 instead of 404.mount()handles 405 independently. The parent router tries the mount as a fallback and whatever the mounted app returns is final. - Named routes
-
group()named routes are directly accessible on the parent:$router->group('/api' => sub { my ($r) = @_; $r->get('/users' => $h)->name('users.list'); }); $router->uri_for('users.list'); # "/api/users"mount()named routes require->as()to import them:$router->mount('/api' => $api)->as('api'); $router->uri_for('api.users.list'); # "/api/users" - Middleware
-
group()middleware is prepended to each individual route's middleware chain at registration time. The parent's middleware chain is a single flat list.mount()middleware wraps the entire mounted application. The mounted app also has its own middleware chains internally. - Route introspection
-
Grouped routes are visible when inspecting the parent router's route table. Mounted routes are hidden inside the mounted app.
When to Use group()
Use group() when routes are part of one logical application and you want them to share a prefix, middleware, or both:
# Versioned API with shared auth
$router->group('/api/v1' => [$auth_mw] => sub {
my ($r) = @_;
$r->get('/users' => $list_users);
$r->get('/users/:id' => $get_user);
$r->post('/users' => $create_user);
});
# Organize routes from separate files
$router->group('/api/users' => 'MyApp::Routes::Users');
$router->group('/api/posts' => 'MyApp::Routes::Posts');
# Nested resource hierarchy
$router->group('/orgs/:org_id' => [$load_org] => sub {
my ($r) = @_;
$r->get('/info' => $org_info);
$r->group('/teams/:team_id' => [$load_team] => sub {
my ($r) = @_;
$r->get('/members' => $team_members);
});
});
When to Use mount()
Use mount() when composing independent applications that manage their own routing, middleware, and error handling:
# Mount a completely separate admin app
$router->mount('/admin' => MyApp::Admin->to_app);
# Mount a PSGI/Plack application
$router->mount('/legacy' => $plack_app);
# Mount a static file server
$router->mount('/static' => PAGI::App::File->new(root => './public'));
Can I Combine Them?
Yes. group() and mount() serve different purposes and work well together:
my $router = PAGI::App::Router->new;
# Grouped API routes (unified 405, shared middleware, named routes)
$router->group('/api' => [$auth] => sub {
my ($r) = @_;
$r->get('/users' => $list_users)->name('users.list');
$r->post('/users' => $create_user)->name('users.create');
});
# Mounted independent apps
$router->mount('/admin' => MyApp::Admin->to_app);
$router->mount('/docs' => PAGI::App::File->new(root => './docs'));
1 POD Error
The following errors were encountered while parsing the POD:
- Around line 867:
Non-ASCII character seen before =encoding in '—'. Assuming UTF-8