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);

# Static files as fallback
$router->mount('/' => $static_files);

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.

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->to_app);

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.

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)

Mounts are checked before regular routes. 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, captured as params->{id}

  • /files/*path - Wildcard, captures rest of path as params->{path}

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.