File Coverage

blib/lib/PAGI/Middleware/Builder.pm
Criterion Covered Total %
statement 125 126 99.2
branch 34 40 85.0
condition 5 5 100.0
subroutine 22 22 100.0
pod 6 9 66.6
total 192 202 95.0


line stmt bran cond sub pod time code
1             package PAGI::Middleware::Builder;
2             $PAGI::Middleware::Builder::VERSION = '0.002000';
3 3     3   161736 use strict;
  3         4  
  3         98  
4 3     3   10 use warnings;
  3         4  
  3         113  
5 3     3   11 use Future::AsyncAwait;
  3         2  
  3         12  
6 3     3   129 use Carp 'croak';
  3         8  
  3         211  
7 3     3   11 use Scalar::Util qw(blessed);
  3         16  
  3         115  
8 3     3   1205 use PAGI::Utils ();
  3         7  
  3         116  
9              
10             # Note: We use traditional Perl subs because prototypes don't work with signatures.
11              
12             =encoding UTF-8
13              
14             =head1 NAME
15              
16             PAGI::Middleware::Builder - DSL for composing PAGI middleware
17              
18             =head1 SYNOPSIS
19              
20             use PAGI::Middleware::Builder;
21              
22             # Functional DSL
23             my $app = builder {
24             enable 'ContentLength';
25             enable 'CORS', origins => ['*'];
26             enable_if { $_[0]->{path} =~ m{^/api/} } 'RateLimit', limit => 100;
27             mount '/static' => $static_app;
28             $my_app;
29             };
30              
31             # Object-oriented interface
32             my $builder = PAGI::Middleware::Builder->new;
33             $builder->enable('ContentLength');
34             $builder->enable('CORS', origins => ['*']);
35             $builder->mount('/admin', $admin_app);
36             my $app = $builder->to_app($my_app);
37              
38             =head1 DESCRIPTION
39              
40             PAGI::Middleware::Builder provides a DSL for composing middleware into
41             a PAGI application. It supports:
42              
43             =over 4
44              
45             =item * Enabling middleware with configuration
46              
47             =item * Conditional middleware application
48              
49             =item * Path-based routing (mount)
50              
51             =item * Middleware ordering
52              
53             =back
54              
55             =head1 EXPORTS
56              
57             =cut
58              
59 3     3   16 use Exporter 'import';
  3         5  
  3         5415  
60             our @EXPORT = qw(builder enable enable_if mount);
61              
62             # Current builder context for DSL
63             our $_current_builder;
64              
65             =head2 builder
66              
67             my $app = builder { ... };
68              
69             Create a composed application using the DSL. The block should
70             call enable(), enable_if(), mount(), and return the final app.
71             The final value of the block is coerced via L,
72             so you can return a component object or class name directly:
73              
74             my $app = builder {
75             enable 'ContentLength';
76             PAGI::App::NotFound->new;
77             };
78              
79             =cut
80              
81             sub builder (&) {
82 4     4 1 5135 my ($block) = @_;
83 4         17 local $_current_builder = PAGI::Middleware::Builder->new;
84 4         12 my $app = $block->();
85 4         40 return $_current_builder->to_app($app);
86             }
87              
88             =head2 enable
89              
90             enable 'MiddlewareName', %config;
91             enable 'Auth::Basic', %config; # PAGI::Middleware::Auth::Basic
92             enable '^My::Custom::Middleware'; # My::Custom::Middleware (no prefix)
93             enable(PAGI::Middleware::GZip->new(level => 9)); # pre-configured instance
94              
95             Enable a middleware. The name is automatically prefixed with
96             'PAGI::Middleware::' unless it starts with '^', which indicates
97             a fully qualified class name (the '^' is stripped).
98              
99             When passed an already-configured middleware instance (an object with a
100             C method), it is used directly. Passing config args alongside an
101             instance is an error — configure the instance at construction time.
102              
103             The parentheses are required for the instance form: C without
104             them is parsed as an indirect method call and dies with a confusing error.
105              
106             =cut
107              
108             sub enable {
109 1     1 1 2 my ($name, %config) = @_;
110 1 50       19 croak "enable() must be called inside builder {}" unless $_current_builder;
111 1         5 $_current_builder->add_middleware($name, %config);
112             }
113              
114             =head2 enable_if
115              
116             enable_if { $condition } 'MiddlewareName', %config;
117             enable_if { $condition } (PAGI::Middleware::GZip->new(level => 9));
118              
119             Conditionally enable middleware. The condition block receives
120             the scope and returns true/false. A pre-configured middleware instance
121             may be passed instead of a class name; config args alongside an instance
122             are an error.
123              
124             =cut
125              
126             sub enable_if (&$;@) {
127 2     2 1 4 my ($condition, $name, %config) = @_;
128 2 50       7 croak "enable_if() must be called inside builder {}" unless $_current_builder;
129 2         8 $_current_builder->add_middleware_if($condition, $name, %config);
130             }
131              
132             =head2 mount
133              
134             mount '/path' => $app;
135             mount '/static' => PAGI::App::File->new(root => $dir);
136             mount '/api' => 'MyApp::API';
137              
138             Mount an application at a path prefix. Requests matching the
139             prefix are routed to the mounted app with adjusted paths. The app
140             argument accepts anything L accepts: a coderef,
141             a component object with C, or a class name.
142              
143             =cut
144              
145             sub mount {
146 1     1 1 17 my ($path, $app) = @_;
147 1 50       7 croak "mount() must be called inside builder {}" unless $_current_builder;
148 1         5 $_current_builder->add_mount($path, $app);
149             }
150              
151             =head1 METHODS
152              
153             =head2 new
154              
155             my $builder = PAGI::Middleware::Builder->new;
156              
157             Create a new builder instance.
158              
159             =cut
160              
161             sub new {
162 12     12 1 185783 my ($class) = @_;
163 12         57 return bless {
164             middleware => [],
165             mounts => [],
166             }, $class;
167             }
168              
169             =head2 enable
170              
171             $builder->enable('MiddlewareName', %config);
172              
173             Add middleware to the stack (OO interface).
174              
175             =cut
176              
177             sub add_middleware {
178 5     5 0 53 my ($self, $name, %config) = @_;
179              
180 5 100       15 if (blessed($name)) {
181 3 100       258 croak "enable() with a middleware instance takes no config"
182             . " (configure it at construction time)" if %config;
183 2 100       110 croak ref($name) . " does not look like middleware (no wrap method)"
184             unless $name->can('wrap');
185 1         2 push @{$self->{middleware}}, {
  1         4  
186             instance => $name,
187             condition => undef,
188             };
189 1         3 return $self;
190             }
191              
192 2         6 my $class = $self->_resolve_middleware($name);
193 2         2 push @{$self->{middleware}}, {
  2         13  
194             class => $class,
195             config => \%config,
196             condition => undef,
197             };
198 2         5 return $self;
199             }
200              
201             =head2 enable_if
202              
203             $builder->enable_if(\&condition, 'MiddlewareName', %config);
204              
205             Add conditional middleware to the stack (OO interface).
206              
207             =cut
208              
209             sub add_middleware_if {
210 4     4 0 19 my ($self, $condition, $name, %config) = @_;
211              
212 4 100       11 if (blessed($name)) {
213 3 100       141 croak "enable_if() with a middleware instance takes no config"
214             . " (configure it at construction time)" if %config;
215 2 50       8 croak ref($name) . " does not look like middleware (no wrap method)"
216             unless $name->can('wrap');
217 2         3 push @{$self->{middleware}}, {
  2         7  
218             instance => $name,
219             condition => $condition,
220             };
221 2         5 return $self;
222             }
223              
224 1         4 my $class = $self->_resolve_middleware($name);
225 1         1 push @{$self->{middleware}}, {
  1         5  
226             class => $class,
227             config => \%config,
228             condition => $condition,
229             };
230 1         2 return $self;
231             }
232              
233             =head2 mount
234              
235             $builder->mount('/path', $app);
236              
237             Add a path-based mount point (OO interface).
238              
239             =cut
240              
241             sub add_mount {
242 2     2 0 10 my ($self, $path, $app) = @_;
243             # Normalize path (remove trailing slash, ensure leading slash)
244 2         7 $path =~ s{/$}{};
245 2 50       10 $path = "/$path" unless $path =~ m{^/};
246              
247 2         4 push @{$self->{mounts}}, {
  2         9  
248             path => $path,
249             app => PAGI::Utils::to_app($app),
250             };
251 2         18 return $self;
252             }
253              
254             =head2 to_app
255              
256             my $app = $builder->to_app($inner_app);
257              
258             Build the composed application. C<$inner_app> accepts anything
259             L accepts: a coderef, a component object with
260             C, or a class name. This means C and
261             Cnew }> work without an explicit
262             C<< ->to_app >> call.
263              
264             =cut
265              
266             sub to_app {
267 7     7 1 19 my ($self, $app) = @_;
268 7         54 $app = PAGI::Utils::to_app($app);
269              
270             # Apply mounts first (innermost)
271 7 100       53 if (@{$self->{mounts}}) {
  7         19  
272 2         11 $app = $self->_build_mount_app($app);
273             }
274              
275             # Apply middleware in reverse order (outermost first in execution)
276 7         10 for my $mw (reverse @{$self->{middleware}}) {
  7         15  
277 6         30 $app = $self->_wrap_middleware($mw, $app);
278             }
279              
280 7         34 return $app;
281             }
282              
283             # Private: resolve middleware class name
284             sub _resolve_middleware {
285 14     14   946 my ($self, $name) = @_;
286              
287             # Always prepend PAGI::Middleware::, then strip everything up to ^ if present
288             # Examples:
289             # 'GZIP' -> 'PAGI::Middleware::GZIP'
290             # 'Auth::Basic' -> 'PAGI::Middleware::Auth::Basic'
291             # '^My::Custom' -> 'My::Custom' (prefix removed)
292             # '^TopLevel' -> 'TopLevel' (prefix removed)
293 14         66 my $class = "PAGI::Middleware::$name" =~ s{^.+\^}{}r;
294              
295             # Load the module
296 14         17 my $file = $class;
297 14         52 $file =~ s{::}{/}g;
298 14         20 $file .= '.pm';
299              
300 14         15 eval { require $file };
  14         4417  
301 14 100       60 if ($@) {
302             # If loading fails, the error will surface when instantiating
303             # This allows for forward declarations
304 7 50       19 warn "Warning: Could not load $class: $@" if $ENV{PAGI_DEBUG};
305             }
306              
307 14         75 return $class;
308             }
309              
310             # Private: wrap a middleware around an app
311             sub _wrap_middleware {
312 6     6   9 my ($self, $mw, $app) = @_;
313 6         10 my $condition = $mw->{condition};
314              
315             # Pre-configured instance path
316 6 100       15 if (my $instance = $mw->{instance}) {
317 3         7 my $wrapped = $instance->wrap($app);
318 3 100       6 return $wrapped unless $condition;
319 2     2   25 return async sub {
320 2         3 my ($scope, $receive, $send) = @_;
321 2 100       6 if ($condition->($scope)) {
322 1         4 await $wrapped->($scope, $receive, $send);
323             } else {
324 1         7 await $app->($scope, $receive, $send);
325             }
326 2         34 };
327             }
328              
329             # Class name + config path
330 3         4 my $class = $mw->{class};
331 3         3 my $config = $mw->{config};
332              
333 3 100       5 if ($condition) {
334             # Conditional middleware
335 2     2   394 return async sub {
336 2         3 my ($scope, $receive, $send) = @_;
337 2 100       6 if ($condition->($scope)) {
338 1         13 my $instance = $class->new(%$config);
339 1         3 my $wrapped = $instance->wrap($app);
340 1         7 await $wrapped->($scope, $receive, $send);
341             } else {
342 1         5 await $app->($scope, $receive, $send);
343             }
344 1         6 };
345             } else {
346             # Unconditional middleware
347 2         55 my $instance = $class->new(%$config);
348 2         6 return $instance->wrap($app);
349             }
350             }
351              
352             # Private: build mount routing app
353             sub _build_mount_app {
354 2     2   4 my ($self, $fallback_app) = @_;
355 2         4 my @mounts = sort { length($b->{path}) <=> length($a->{path}) } @{$self->{mounts}};
  0         0  
  2         8  
356              
357 5     5   1991 return async sub {
358 5         10 my ($scope, $receive, $send) = @_;
359 5         9 my $path = $scope->{path};
360              
361 5         10 for my $mount (@mounts) {
362 5         10 my $prefix = $mount->{path};
363              
364             # Check if path matches mount point
365 5 100 100     139 if ($path eq $prefix || $path =~ m{^\Q$prefix\E/}) {
366             # Adjust path and root_path for mounted app
367 3         8 my $new_path = $path;
368 3         30 $new_path =~ s{^\Q$prefix\E}{};
369 3 100       11 $new_path = '/' if $new_path eq '';
370              
371 3   100     14 my $new_root = ($scope->{root_path} // '') . $prefix;
372              
373 3         20 my $mounted_scope = {
374             %$scope,
375             path => $new_path,
376             root_path => $new_root,
377             };
378              
379 3         36 await $mount->{app}->($mounted_scope, $receive, $send);
380 3         383 return;
381             }
382             }
383              
384             # No mount matched, use fallback
385 2         51 await $fallback_app->($scope, $receive, $send);
386 2         11 };
387             }
388              
389             1;
390              
391             __END__