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 as params->{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 as params->{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. If GET /api/users exists but someone sends DELETE /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