File Coverage

blib/lib/PAGI/Middleware/Builder.pm
Criterion Covered Total %
statement 91 98 92.8
branch 15 22 68.1
condition 4 5 80.0
subroutine 17 19 89.4
pod 6 9 66.6
total 133 153 86.9


line stmt bran cond sub pod time code
1             package PAGI::Middleware::Builder;
2              
3 3     3   154286 use strict;
  3         4  
  3         91  
4 3     3   11 use warnings;
  3         4  
  3         161  
5 3     3   369 use Future::AsyncAwait;
  3         16684  
  3         20  
6 3     3   169 use Carp 'croak';
  3         3  
  3         190  
7              
8             # Note: We use traditional Perl subs because prototypes don't work with signatures.
9              
10             =head1 NAME
11              
12             PAGI::Middleware::Builder - DSL for composing PAGI middleware
13              
14             =head1 SYNOPSIS
15              
16             use PAGI::Middleware::Builder;
17              
18             # Functional DSL
19             my $app = builder {
20             enable 'ContentLength';
21             enable 'CORS', origins => ['*'];
22             enable_if { $_[0]->{path} =~ m{^/api/} } 'RateLimit', limit => 100;
23             mount '/static' => $static_app;
24             $my_app;
25             };
26              
27             # Object-oriented interface
28             my $builder = PAGI::Middleware::Builder->new;
29             $builder->enable('ContentLength');
30             $builder->enable('CORS', origins => ['*']);
31             $builder->mount('/admin', $admin_app);
32             my $app = $builder->to_app($my_app);
33              
34             =head1 DESCRIPTION
35              
36             PAGI::Middleware::Builder provides a DSL for composing middleware into
37             a PAGI application. It supports:
38              
39             =over 4
40              
41             =item * Enabling middleware with configuration
42              
43             =item * Conditional middleware application
44              
45             =item * Path-based routing (mount)
46              
47             =item * Middleware ordering
48              
49             =back
50              
51             =head1 EXPORTS
52              
53             =cut
54              
55 3     3   13 use Exporter 'import';
  3         4  
  3         4475  
56             our @EXPORT = qw(builder enable enable_if mount);
57              
58             # Current builder context for DSL
59             our $_current_builder;
60              
61             =head2 builder
62              
63             my $app = builder { ... };
64              
65             Create a composed application using the DSL. The block should
66             call enable(), enable_if(), mount(), and return the final app.
67              
68             =cut
69              
70             sub builder (&) {
71 7     7 1 227523 my ($block) = @_;
72 7         61 local $_current_builder = PAGI::Middleware::Builder->new;
73 7         18 my $app = $block->();
74 7         43 return $_current_builder->to_app($app);
75             }
76              
77             =head2 enable
78              
79             enable 'MiddlewareName', %config;
80             enable 'Auth::Basic', %config; # PAGI::Middleware::Auth::Basic
81             enable '^My::Custom::Middleware'; # My::Custom::Middleware (no prefix)
82              
83             Enable a middleware. The name is automatically prefixed with
84             'PAGI::Middleware::' unless it starts with '^', which indicates
85             a fully qualified class name (the '^' is stripped).
86              
87             =cut
88              
89             sub enable {
90 7     7 1 74 my ($name, %config) = @_;
91 7 50       25 croak "enable() must be called inside builder {}" unless $_current_builder;
92 7         29 $_current_builder->add_middleware($name, %config);
93             }
94              
95             =head2 enable_if
96              
97             enable_if { $condition } 'MiddlewareName', %config;
98              
99             Conditionally enable middleware. The condition block receives
100             the scope and returns true/false.
101              
102             =cut
103              
104             sub enable_if (&$;@) {
105 0     0 1 0 my ($condition, $name, %config) = @_;
106 0 0       0 croak "enable_if() must be called inside builder {}" unless $_current_builder;
107 0         0 $_current_builder->add_middleware_if($condition, $name, %config);
108             }
109              
110             =head2 mount
111              
112             mount '/path' => $app;
113              
114             Mount an application at a path prefix. Requests matching the
115             prefix are routed to the mounted app with adjusted paths.
116              
117             =cut
118              
119             sub mount {
120 0     0 1 0 my ($path, $app) = @_;
121 0 0       0 croak "mount() must be called inside builder {}" unless $_current_builder;
122 0         0 $_current_builder->add_mount($path, $app);
123             }
124              
125             =head1 METHODS
126              
127             =head2 new
128              
129             my $builder = PAGI::Middleware::Builder->new;
130              
131             Create a new builder instance.
132              
133             =cut
134              
135             sub new {
136 12     12 1 187468 my ($class) = @_;
137 12         57 return bless {
138             middleware => [],
139             mounts => [],
140             }, $class;
141             }
142              
143             =head2 enable
144              
145             $builder->enable('MiddlewareName', %config);
146              
147             Add middleware to the stack (OO interface).
148              
149             =cut
150              
151             sub add_middleware {
152 9     9 0 31 my ($self, $name, %config) = @_;
153 9         36 my $class = $self->_resolve_middleware($name);
154 9         15 push @{$self->{middleware}}, {
  9         52  
155             class => $class,
156             config => \%config,
157             condition => undef,
158             };
159 9         25 return $self;
160             }
161              
162             =head2 enable_if
163              
164             $builder->enable_if(\&condition, 'MiddlewareName', %config);
165              
166             Add conditional middleware to the stack (OO interface).
167              
168             =cut
169              
170             sub add_middleware_if {
171 1     1 0 7 my ($self, $condition, $name, %config) = @_;
172 1         4 my $class = $self->_resolve_middleware($name);
173 1         1 push @{$self->{middleware}}, {
  1         18  
174             class => $class,
175             config => \%config,
176             condition => $condition,
177             };
178 1         2 return $self;
179             }
180              
181             =head2 mount
182              
183             $builder->mount('/path', $app);
184              
185             Add a path-based mount point (OO interface).
186              
187             =cut
188              
189             sub add_mount {
190 1     1 0 6 my ($self, $path, $app) = @_;
191             # Normalize path (remove trailing slash, ensure leading slash)
192 1         3 $path =~ s{/$}{};
193 1 50       5 $path = "/$path" unless $path =~ m{^/};
194              
195 1         1 push @{$self->{mounts}}, {
  1         4  
196             path => $path,
197             app => $app,
198             };
199 1         1 return $self;
200             }
201              
202             =head2 to_app
203              
204             my $app = $builder->to_app($inner_app);
205              
206             Build the composed application.
207              
208             =cut
209              
210             sub to_app {
211 10     10 1 27 my ($self, $app) = @_;
212              
213             # Apply mounts first (innermost)
214 10 100       12 if (@{$self->{mounts}}) {
  10         29  
215 1         3 $app = $self->_build_mount_app($app);
216             }
217              
218             # Apply middleware in reverse order (outermost first in execution)
219 10         14 for my $mw (reverse @{$self->{middleware}}) {
  10         26  
220 10         57 $app = $self->_wrap_middleware($mw, $app);
221             }
222              
223 10         44 return $app;
224             }
225              
226             # Private: resolve middleware class name
227             sub _resolve_middleware {
228 21     21   550 my ($self, $name) = @_;
229              
230             # Always prepend PAGI::Middleware::, then strip everything up to ^ if present
231             # Examples:
232             # 'GZIP' -> 'PAGI::Middleware::GZIP'
233             # 'Auth::Basic' -> 'PAGI::Middleware::Auth::Basic'
234             # '^My::Custom' -> 'My::Custom' (prefix removed)
235             # '^TopLevel' -> 'TopLevel' (prefix removed)
236 21         101 my $class = "PAGI::Middleware::$name" =~ s{^.+\^}{}r;
237              
238             # Load the module
239 21         29 my $file = $class;
240 21         108 $file =~ s{::}{/}g;
241 21         32 $file .= '.pm';
242              
243 21         21 eval { require $file };
  21         7216  
244 21 100       67 if ($@) {
245             # If loading fails, the error will surface when instantiating
246             # This allows for forward declarations
247 7 50       20 warn "Warning: Could not load $class: $@" if $ENV{PAGI_DEBUG};
248             }
249              
250 21         114 return $class;
251             }
252              
253             # Private: wrap a middleware around an app
254             sub _wrap_middleware {
255 10     10   19 my ($self, $mw, $app) = @_;
256 10         22 my $class = $mw->{class};
257 10         19 my $config = $mw->{config};
258 10         19 my $condition = $mw->{condition};
259              
260 10 100       25 if ($condition) {
261             # Conditional middleware
262 2     2   451 return async sub {
263 2         3 my ($scope, $receive, $send) = @_;
264 2 100       6 if ($condition->($scope)) {
265 1         31 my $instance = $class->new(%$config);
266 1         4 my $wrapped = $instance->wrap($app);
267 1         8 await $wrapped->($scope, $receive, $send);
268             } else {
269 1         5 await $app->($scope, $receive, $send);
270             }
271 1         7 };
272             } else {
273             # Unconditional middleware
274 9         68 my $instance = $class->new(%$config);
275 9         31 return $instance->wrap($app);
276             }
277             }
278              
279             # Private: build mount routing app
280             sub _build_mount_app {
281 1     1   2 my ($self, $fallback_app) = @_;
282 1         1 my @mounts = sort { length($b->{path}) <=> length($a->{path}) } @{$self->{mounts}};
  0         0  
  1         3  
283              
284 3     3   1497 return async sub {
285 3         5 my ($scope, $receive, $send) = @_;
286 3         3 my $path = $scope->{path};
287              
288 3         5 for my $mount (@mounts) {
289 3         4 my $prefix = $mount->{path};
290              
291             # Check if path matches mount point
292 3 100 100     37 if ($path eq $prefix || $path =~ m{^\Q$prefix\E/}) {
293             # Adjust path and root_path for mounted app
294 2         3 my $new_path = $path;
295 2         11 $new_path =~ s{^\Q$prefix\E}{};
296 2 100       5 $new_path = '/' if $new_path eq '';
297              
298 2   50     5 my $new_root = ($scope->{root_path} // '') . $prefix;
299              
300 2         7 my $mounted_scope = {
301             %$scope,
302             path => $new_path,
303             root_path => $new_root,
304             };
305              
306 2         5 await $mount->{app}->($mounted_scope, $receive, $send);
307 2         216 return;
308             }
309             }
310              
311             # No mount matched, use fallback
312 1         5 await $fallback_app->($scope, $receive, $send);
313 1         6 };
314             }
315              
316             1;
317              
318             __END__