Skip to content

Add router features: regex escaping, constraints, any(), and group()#41

Merged
jjn1056 merged 58 commits intomainfrom
router-features
Feb 19, 2026
Merged

Add router features: regex escaping, constraints, any(), and group()#41
jjn1056 merged 58 commits intomainfrom
router-features

Conversation

@jjn1056
Copy link
Owner

@jjn1056 jjn1056 commented Feb 17, 2026

Summary

  • Regex escaping: Literal segments in route paths are now properly escaped with quotemeta, preventing regex metacharacters (., +, (, etc.) from being interpreted as patterns
  • Inline constraints: {name:pattern} syntax in paths (e.g., /users/{id:\d+}) for compile-time parameter validation
  • Chained constraints: ->constraints(name => qr/.../) method for adding constraints after route definition
  • any() method: Register routes matching any HTTP method, or a specific list of methods via method => [...]
  • group() method: Flatten routes under a shared prefix with shared middleware — three forms:
    • Callback: $router->group('/api' => [$mw] => sub { ... })
    • Router-object: $router->group('/api' => $other_router) (snapshot semantics)
    • String: $router->group('/api' => 'MyApp::Routes::Users') (auto-require)
  • Nested groups: Prefix and middleware accumulate naturally across nested group() calls
  • Named routes in groups: Full prefixed path, conflict detection (croak on duplicate), as() namespacing
  • Constraint storage refactor: Split inline vs chained constraints for clean route copying
  • Security fixes: CSRF/Session secure random, Debug XSS escape, Static double-decode, chunked DoS limit, RequestId secure random, CORS wildcard warning, rate limiter cleanup
  • SSE over HTTP/2: Full SSE support in HTTP/2 code path — detection via Accept header, streaming DATA frames (no chunked encoding), keepalive comments, disconnect handling, connection cleanup, idle timeout documentation with trade-offs

Test plan

  • prove -l t/app-router.t — existing router tests + constraint storage test
  • prove -l t/router-named-routes.t — named route tests unchanged
  • prove -l t/app-router-group.t — 22 subtests covering all group() forms, middleware, nesting, named routes, as(), WS/SSE, error handling, integration
  • prove -l t/router-middleware.t t/endpoint-router.t t/sse-router-support.t — no regressions
  • prove -l t/http2/13-sse-detection.t — SSE detection + scope type over H2
  • prove -l t/http2/14-sse-events.t — full send/receive SSE session over H2
  • prove -l t/http2/15-sse-keepalive.t — keepalive comments over H2
  • prove -l t/http2/16-sse-cleanup.t — disconnect and cleanup over H2
  • prove -l t/sse/12-format-helpers.t — SSE formatting helper unit tests
  • prove -l t/ — full suite passes (minus known pre-existing failures in t/42-file-response.t, t/app-file.t)

🤖 Generated with Claude Code

jjn1056 and others added 30 commits February 17, 2026 13:26
CPAN testers with older versions of Net::HTTP2::nghttp2 hit failures:
- NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE constant missing (undefined sub)
- max_header_list_size setting silently ignored in SETTINGS frames
- nghttp2 C library assertion crash in submit_data for WebSocket/HTTP2

Fix by enforcing minimum version 0.007 in two places:

1. HTTP2.pm $AVAILABLE check now calls ->VERSION(0.007), so older
   installs are treated as unavailable. This cascades to
   PAGI::Server->has_http2 returning false, which also covers the
   three test files (02-server-config, 03-detection, 09-cli) that
   use subtest-level has_http2 skip guards.

2. All 10 HTTP/2 test files with BEGIN block skip guards now check
   Net::HTTP2::nghttp2->VERSION(0.007) so they cleanly skip_all
   instead of failing on older installations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add notes about extending ->constraints() to accept coderefs that
receive ($value, $scope) for rich post-match validation. Cross-
reference from feature #2 to the detailed design notes under #9b.
This requires feature #8 (pass/fall-through) for proper failure
semantics and is deferred to a future release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Documents the approved design for three router improvements:
- #1: Tokenizer-based _compile_path() with quotemeta() on literals
- #2: Inline {id:\d+} and chained ->constraints() syntax
- #3: any() multi-method matcher with wildcard and explicit list modes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
15-task TDD plan covering regex escaping, constraints, any() method,
POD updates, and final consistency review. Each task follows red-green
cycle with commits at every step.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Literal path segments are now escaped with quotemeta(), fixing incorrect
matching of regex metacharacters like dots and brackets. Also adds support
for {name} and {name:pattern} token types (used by constraint feature).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Routes with inline constraints {name:pattern} now properly filter during
dispatch. If a path parameter fails its constraint regex, the route is
skipped and the next matching route is tried.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Allows post-registration constraint application via chaining:
  $router->get('/posts/:id' => $h)->constraints(id => qr/^\d+$/);

Constraints are merged into the route's constraint list and checked
during dispatch alongside inline {name:pattern} constraints.
Supports wildcard (all methods) and explicit method list:
  $router->any('/health' => $handler);            # all methods
  $router->any('/res' => $handler, method => ['GET','POST']);

Wildcard routes match any HTTP method. Explicit lists produce 405
with correct Allow header for non-matching methods.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove unnecessary $self_ref alias; use $self directly in closures
- Eliminate double regex match in dispatch loops (match + capture in one)
- Fix POD: routes are checked before mounts, not the other way around

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Approved design for route grouping with prefix stack implementation,
three forms (callback, router-object, string), constraint storage
refactor, and named route conflict detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
14-task TDD plan for implementing route grouping with prefix stack
approach, constraint storage refactor, and all three forms (callback,
router-object, string).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Chained constraints (from ->constraints() method) are now stored in
_user_constraints separately from inline constraints (from {name:pattern}
syntax). This ensures chained constraints survive route copying when
routes are moved between routers (needed for upcoming group() feature).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add _group_stack to router constructor and make route(), websocket(),
and sse() apply accumulated group prefix/middleware from the stack.
The stack starts empty so this has zero behavioral change on existing
code. This prepares for group() implementation in the next task.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fix nested group iteration order — stack must be traversed in reverse
so innermost prefix is prepended first, producing correct path
composition (e.g., /orgs/:org_id/teams/:team_id/members) and correct
middleware ordering (outer before inner before route).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Allow passing a PAGI::App::Router instance to group() to re-register
its routes with the parent router's prefix and middleware applied.
Implements snapshot semantics, named route propagation, and constraint
preservation via _include_router().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
jjn1056 and others added 2 commits February 17, 2026 22:15
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Prevents as() from accidentally re-namespacing names from a previous
group call. Also adds tests for as() with router-object form and for
the clearing behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jjn1056 jjn1056 changed the title Add router regex escaping, constraints, and any() method Add router features: regex escaping, constraints, any(), and group() Feb 18, 2026
jjn1056 and others added 25 commits February 17, 2026 22:30
Explains key differences (route storage, path handling, 405 behavior,
named routes, middleware, introspection) with code examples showing
when to use each and how to combine them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
$router->mount('/admin' => 'MyApp::Admin') now auto-requires the
package and calls ->to_app as a class method. Consistent with
group()'s string form. Note: ->as() is not available for stringy
mounts since to_app returns a coderef, not a Router object.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Design doc for 6 code fixes + 1 doc fix from the 5-person
conceptual review. Covers secure random, double URL-decode,
HTTP/2 path decoding, session cookie defaults, CORS warnings,
rate limiter cleanup, and SIGHUP doc correction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Detailed TDD implementation plan for 7 fixes from the conceptual
review: secure random utility, double URL-decode, HTTP/2 path
decoding, session cookie defaults, CORS warning, rate limiter
cleanup, and SIGHUP docs correction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The server already URL-decodes $scope->{path}, so _resolve_path() was
decoding a second time. This created a double-decode vulnerability where
%252e%252e in the original request would become .. after two decode passes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Centralizes secure random byte generation that was duplicated in CSRF
and Session middleware. Uses /dev/urandom with Crypt::URandom fallback,
and dies (instead of silently falling back to rand()) when no secure
source is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
HTTP/2 was using a manual regex for percent-decoding without UTF-8
decode. Now uses URI::Escape::uri_unescape + Encode::decode('UTF-8')
with FB_CROAK fallback, matching the HTTP/1.1 path in HTTP1.pm.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace inline _secure_random_bytes with the shared
secure_random_bytes from PAGI::Utils::Random, eliminating
the insecure rand() fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SIGHUP documentation incorrectly implied it could be used for code
deploys. Updated to clarify it only restarts worker processes (useful
for memory reclamation) and that new workers inherit the parent's
already-loaded code. Added note directing users to full restart for
code deploys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace inline _secure_random_bytes with the shared
secure_random_bytes from PAGI::Utils::Random, eliminating
the insecure rand() fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add SameSite=Lax to the default cookie_options to prevent CSRF
via cross-site form submissions. Custom cookie_options still
override the default. Updated POD to recommend secure => 1 for
production HTTPS deployments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wildcard origins with credentials enabled reflects any Origin with
Access-Control-Allow-Credentials, allowing any website to make
credentialed cross-origin requests. Emit a warning at init time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stale buckets are cleaned up periodically (configurable via
cleanup_interval). A max_buckets safety valve evicts the oldest half
when exceeded. Prevents unbounded memory growth in long-running servers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The _h2_create_websocket_scope method was still using the old manual
regex for percent-decoding, while _h2_create_scope was already fixed.
Apply the same uri_unescape + UTF-8 decode with fallback pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Method, path, query_string, and scheme were interpolated into HTML
without escaping. Apply the existing _html_escape() helper to all
scope values, matching how headers were already handled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
parse_chunked_body accepted arbitrarily large chunk size values,
allowing an attacker to claim enormous chunks and stall the connection.
Add max_chunk_size (default 10MB) with early rejection returning 413.
Also guard against Perl warnings on oversized hex strings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace rand() with PAGI::Utils::Random::secure_random_bytes for the
random component of request IDs. Also fix PID field to be consistently
4 hex chars by masking to lower 16 bits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… writes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jjn1056 jjn1056 merged commit f622cd8 into main Feb 19, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant