File Coverage

blib/lib/PAGI/Request.pm
Criterion Covered Total %
statement 286 318 89.9
branch 72 102 70.5
condition 52 78 66.6
subroutine 70 79 88.6
pod 57 62 91.9
total 537 639 84.0


line stmt bran cond sub pod time code
1             package PAGI::Request;
2 22     22   2583743 use strict;
  22         42  
  22         762  
3 22     22   95 use warnings;
  22         68  
  22         1020  
4 22     22   9505 use Hash::MultiValue;
  22         54257  
  22         1339  
5 22     22   5953 use Encode qw(decode FB_CROAK FB_DEFAULT LEAVE_SRC);
  22         199629  
  22         2105  
6 22     22   8989 use Cookie::Baker qw(crush_cookie);
  22         91308  
  22         1833  
7 22     22   8523 use MIME::Base64 qw(decode_base64);
  22         14888  
  22         1619  
8 22     22   4778 use Future::AsyncAwait;
  22         167136  
  22         189  
9 22     22   9199 use JSON::MaybeXS qw(decode_json);
  22         208530  
  22         1937  
10 22     22   137 use Carp qw(croak carp);
  22         28  
  22         1007  
11 22     22   9624 use PAGI::Request::MultiPartHandler;
  22         157  
  22         1562  
12 22     22   151 use PAGI::Request::Upload;
  22         31  
  22         470  
13 22     22   8401 use PAGI::Request::Negotiate;
  22         52  
  22         1090  
14 22     22   8680 use PAGI::Request::BodyStream;
  22         43  
  22         126963  
15              
16             # Class-level configuration defaults
17             our %CONFIG = (
18             max_body_size => 10 * 1024 * 1024, # 10MB total request body
19             max_field_size => 1 * 1024 * 1024, # 1MB per form field (non-file)
20             max_file_size => 10 * 1024 * 1024, # 10MB per file upload
21             max_files => 20,
22             max_fields => 1000,
23             path_param_strict => 0, # Die if path_params not in scope
24             spool_threshold => 64 * 1024, # 64KB
25             temp_dir => $ENV{TMPDIR} // '/tmp',
26             );
27              
28             sub configure {
29 5     5 1 618 my ($class, %opts) = @_;
30 5         11 for my $key (keys %opts) {
31 13 50       37 $CONFIG{$key} = $opts{$key} if exists $CONFIG{$key};
32             }
33             }
34              
35             sub config {
36 4     4 1 173413 my $class = shift;
37 4         16 return \%CONFIG;
38             }
39              
40             sub new {
41 131     131 1 1994878 my ($class, $scope, $receive) = @_;
42 131         452 return bless {
43             scope => $scope,
44             receive => $receive,
45             }, $class;
46             }
47              
48             # Basic properties from scope
49 55     55 1 410 sub method { shift->{scope}{method} }
50 1     1 1 4 sub path { shift->{scope}{path} }
51 4   66 4 1 13 sub raw_path { my $s = shift; $s->{scope}{raw_path} // $s->{scope}{path} }
  4         22  
52 11   100 11 1 51 sub query_string { shift->{scope}{query_string} // '' }
53 3   100 3 1 41 sub scheme { shift->{scope}{scheme} // 'http' }
54 4   100 4 1 30 sub http_version { shift->{scope}{http_version} // '1.1' }
55 1     1 1 4 sub client { shift->{scope}{client} }
56 2     2 1 12 sub raw { shift->{scope} }
57              
58             # Internal: URL decode a string (handles + as space)
59             sub _url_decode {
60 38     38   44 my ($str) = @_;
61 38 50       50 return '' unless defined $str;
62 38         69 $str =~ s/\+/ /g;
63 38         55 $str =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/ge;
  13         31  
64 38         49 return $str;
65             }
66              
67             # Internal: Decode UTF-8 with replacement or croak in strict mode
68             sub _decode_utf8 {
69 39     39   53 my ($str, $strict) = @_;
70 39 50       106 return '' unless defined $str;
71 39 50       53 my $flag = $strict ? FB_CROAK : FB_DEFAULT;
72 39         40 $flag |= LEAVE_SRC;
73 39         151 return decode('UTF-8', $str, $flag);
74             }
75              
76             # Host from headers
77             sub host {
78 1     1 1 2 my $self = shift;
79 1         7 return $self->header('host');
80             }
81              
82             # Content-Type shortcut
83             sub content_type {
84 20     20 1 29 my $self = shift;
85 20   50     39 my $ct = $self->header('content-type') // '';
86             # Strip parameters like charset
87 20         74 $ct =~ s/;.*//;
88 20         37 return $ct;
89             }
90              
91             # Content-Length shortcut
92             sub content_length {
93 4     4 1 13 my $self = shift;
94 4         11 return $self->header('content-length');
95             }
96              
97             # Single header lookup (case-insensitive, returns last value)
98             sub header {
99 54     54 1 110 my ($self, $name) = @_;
100 54         84 $name = lc($name);
101 54         50 my $value;
102 54   50     59 for my $pair (@{$self->{scope}{headers} // []}) {
  54         156  
103 84 100       193 if (lc($pair->[0]) eq $name) {
104 51         69 $value = $pair->[1];
105             }
106             }
107 54         151 return $value;
108             }
109              
110             # All headers as Hash::MultiValue (cached in scope, case-insensitive keys)
111             sub headers {
112 18     18 1 28 my $self = shift;
113 18 100       95 return $self->{scope}{'pagi.request.headers'} if $self->{scope}{'pagi.request.headers'};
114              
115 5         8 my @pairs;
116 5   50     6 for my $pair (@{$self->{scope}{headers} // []}) {
  5         22  
117 10         30 push @pairs, lc($pair->[0]), $pair->[1];
118             }
119              
120 5         66 $self->{scope}{'pagi.request.headers'} = Hash::MultiValue->new(@pairs);
121 5         310 return $self->{scope}{'pagi.request.headers'};
122             }
123              
124             # All values for a header
125             sub header_all {
126 17     17 1 1545 my ($self, $name) = @_;
127 17         42 return $self->headers->get_all(lc($name));
128             }
129              
130             # Query params as Hash::MultiValue (cached in scope)
131             # Options: strict => 1 (croak on invalid UTF-8), raw => 1 (skip UTF-8 decoding)
132             sub query_params {
133 14     14 1 138 my ($self, %opts) = @_;
134 14   50     53 my $strict = delete $opts{strict} // 0;
135 14   50     41 my $raw = delete $opts{raw} // 0;
136 14 50       22 croak("Unknown options to query_params: " . join(', ', keys %opts)) if %opts;
137              
138 14 50       36 my $cache_key = $raw ? 'pagi.request.query.raw' : ($strict ? 'pagi.request.query.strict' : 'pagi.request.query');
    50          
139 14 100       51 return $self->{scope}{$cache_key} if $self->{scope}{$cache_key};
140              
141 8         22 my $qs = $self->query_string;
142 8         10 my @pairs;
143              
144 8         36 for my $part (split /[&;]/, $qs) {
145 13 50       21 next unless length $part;
146 13         30 my ($key, $val) = split /=/, $part, 2;
147 13   50     25 $key //= '';
148 13   50     20 $val //= '';
149              
150             # URL decode (handles + as space)
151 13         26 my $key_decoded = _url_decode($key);
152 13         17 my $val_decoded = _url_decode($val);
153              
154             # UTF-8 decode unless raw mode
155 13 50       29 my $key_final = $raw ? $key_decoded : _decode_utf8($key_decoded, $strict);
156 13 50       424 my $val_final = $raw ? $val_decoded : _decode_utf8($val_decoded, $strict);
157              
158 13         309 push @pairs, $key_final, $val_final;
159             }
160              
161 8         44 $self->{scope}{$cache_key} = Hash::MultiValue->new(@pairs);
162 8         391 return $self->{scope}{$cache_key};
163             }
164              
165             # Raw query params (no UTF-8 decoding)
166             sub raw_query_params {
167 0     0 0 0 my $self = shift;
168 0         0 return $self->query_params(raw => 1);
169             }
170              
171             # Shortcut for single query param
172             sub query_param {
173 11     11 1 1601 my ($self, $name, %opts) = @_;
174 11         29 return $self->query_params(%opts)->get($name);
175             }
176              
177             # DEPRECATED: Alias with warning
178             sub query {
179 0     0 0 0 my $self = shift;
180 0         0 carp "query() is deprecated; use query_param() instead";
181 0         0 return $self->query_param(@_);
182             }
183              
184             # Raw single query param
185             sub raw_query_param {
186 0     0 1 0 my ($self, $name) = @_;
187 0         0 return $self->query_param($name, raw => 1);
188             }
189              
190             # DEPRECATED: Alias with warning
191             sub raw_query {
192 0     0 0 0 my $self = shift;
193 0         0 carp "raw_query() is deprecated; use raw_query_param() instead";
194 0         0 return $self->raw_query_param(@_);
195             }
196              
197             # All cookies as hashref (cached in scope)
198             sub cookies {
199 7     7 1 124 my $self = shift;
200 7 100       28 return $self->{scope}{'pagi.request.cookies'} if exists $self->{scope}{'pagi.request.cookies'};
201              
202 4   100     9 my $cookie_header = $self->header('cookie') // '';
203 4         15 $self->{scope}{'pagi.request.cookies'} = crush_cookie($cookie_header);
204 4         195 return $self->{scope}{'pagi.request.cookies'};
205             }
206              
207             # Single cookie value
208             sub cookie {
209 5     5 1 15 my ($self, $name) = @_;
210 5         9 return $self->cookies->{$name};
211             }
212              
213             # Method predicates
214 9   50 9 1 69 sub is_get { uc(shift->method // '') eq 'GET' }
215 9   50 9 1 1492 sub is_post { uc(shift->method // '') eq 'POST' }
216 7   50 7 1 1329 sub is_put { uc(shift->method // '') eq 'PUT' }
217 7   50 7 1 1503 sub is_patch { uc(shift->method // '') eq 'PATCH' }
218 7   50 7 1 1381 sub is_delete { uc(shift->method // '') eq 'DELETE' }
219 7   50 7 1 1240 sub is_head { uc(shift->method // '') eq 'HEAD' }
220 7   50 7 1 1472 sub is_options { uc(shift->method // '') eq 'OPTIONS' }
221              
222             # =============================================================================
223             # Connection State Methods (PAGI spec 0.3)
224             #
225             # These methods provide non-destructive disconnect detection via the
226             # pagi.connection scope key, which is a PAGI::Server::ConnectionState object.
227             # =============================================================================
228              
229             # Get the connection state object
230             sub connection {
231 36     36 1 41 my $self = shift;
232 36         52 return $self->{scope}{'pagi.connection'};
233             }
234              
235             # Check if client is still connected (synchronous, non-destructive)
236             sub is_connected {
237 12     12 1 21 my $self = shift;
238 12         18 my $conn = $self->connection;
239 12 100       29 return 0 unless $conn;
240 10         18 return $conn->is_connected;
241             }
242              
243             # Check if client has disconnected (synchronous, non-destructive)
244             # This is the inverse of is_connected - preferred for new code
245             sub is_disconnected {
246 6     6 1 16 my $self = shift;
247 6         11 return !$self->is_connected;
248             }
249              
250             # Get the disconnect reason string, or undef if still connected
251             sub disconnect_reason {
252 13     13 1 30 my $self = shift;
253 13         20 my $conn = $self->connection;
254 13 100       27 return undef unless $conn;
255 12         22 return $conn->disconnect_reason;
256             }
257              
258             # Register a callback to be invoked when disconnect occurs
259             sub on_disconnect {
260 4     4 1 20 my ($self, $cb) = @_;
261 4         6 my $conn = $self->connection;
262 4 100       8 return unless $conn;
263 3         6 $conn->on_disconnect($cb);
264             }
265              
266             # Get a Future that resolves when the client disconnects
267             sub disconnect_future {
268 4     4 1 387 my $self = shift;
269 4         8 my $conn = $self->connection;
270 4 100       12 return undef unless $conn;
271 3         8 return $conn->disconnect_future;
272             }
273              
274             # Content-type predicates
275             sub is_json {
276 3     3 1 4 my $self = shift;
277 3         101 my $ct = $self->content_type;
278 3         13 return $ct eq 'application/json';
279             }
280              
281             sub is_form {
282 3     3 1 5 my $self = shift;
283 3         5 my $ct = $self->content_type;
284 3   100     26 return $ct eq 'application/x-www-form-urlencoded'
285             || $ct =~ m{^multipart/form-data};
286             }
287              
288             sub is_multipart {
289 10     10 1 9 my $self = shift;
290 10         22 my $ct = $self->content_type;
291 10         31 return $ct =~ m{^multipart/form-data};
292             }
293              
294             # Accept header check using Negotiate module
295             # Combines multiple Accept headers per RFC 7230 Section 3.2.2
296             sub accepts {
297 12     12 1 41 my ($self, $mime_type) = @_;
298 12         28 my @accept_values = $self->header_all('accept');
299 12         190 my $accept = join(', ', @accept_values);
300 12         45 return PAGI::Request::Negotiate->accepts_type($accept, $mime_type);
301             }
302              
303             # Find best matching content type from supported list
304             # Combines multiple Accept headers per RFC 7230 Section 3.2.2
305             sub preferred_type {
306 4     4 1 15 my ($self, @types) = @_;
307 4         15 my @accept_values = $self->header_all('accept');
308 4         58 my $accept = join(', ', @accept_values);
309 4         17 return PAGI::Request::Negotiate->best_match(\@types, $accept);
310             }
311              
312             # Extract Bearer token from Authorization header
313             sub bearer_token {
314 3     3 1 142 my $self = shift;
315 3   100     12 my $auth = $self->header('authorization') // '';
316 3 100       19 if ($auth =~ /^Bearer\s+(.+)$/i) {
317 1         11 return $1;
318             }
319 2         10 return undef;
320             }
321              
322             # Extract Basic auth credentials
323             sub basic_auth {
324 3     3 1 9 my $self = shift;
325 3   100     11 my $auth = $self->header('authorization') // '';
326 3 100       21 if ($auth =~ /^Basic\s+(.+)$/i) {
327 2         10 my $decoded = decode_base64($1);
328 2         10 my ($user, $pass) = split /:/, $decoded, 2;
329 2         11 return ($user, $pass);
330             }
331 1         4 return (undef, undef);
332             }
333              
334             # Path parameters - captured from URL path by router
335             # Stored in scope->{path_params} for router-agnostic access
336             sub path_params {
337 29     29 1 67 my $self = shift;
338 29         38 my $params = $self->{scope}{path_params};
339 29 100 100     78 if (!defined $params && $CONFIG{path_param_strict}) {
340 2         170 croak "path_params not set in scope (no router configured?). "
341             . "Set PAGI::Request->configure(path_param_strict => 0) to allow this.";
342             }
343 27   100     69 return $params // {};
344             }
345              
346 18     18   23 sub _default_path_param_strict_opt { return 1 }
347              
348             sub path_param {
349 24     24 1 2916 my ($self, $name, %opts) = @_;
350 24 100       64 my $strict = exists $opts{strict} ? delete $opts{strict} : $self->_default_path_param_strict_opt;
351 24 50       45 croak("Unknown options to path_param: " . join(', ', keys %opts)) if %opts;
352              
353 24         40 my $params = $self->path_params;
354              
355 23 100 100     75 if ($strict && !exists $params->{$name}) {
356 7         15 my @available = keys %$params;
357 7 100       807 croak "path_param '$name' not found. "
358             . (@available ? "Available: " . join(', ', sort @available) : "No path params set (no router?)");
359             }
360              
361 16         58 return $params->{$name};
362             }
363              
364             # Per-request storage - lives in scope, shared across Request/Response/WebSocket/SSE
365             #
366             # DESIGN NOTE: Stash is intentionally scope-based, not object-based. When middleware
367             # creates a shallow copy of scope ({ %$scope, key => val }), the inner 'pagi.stash'
368             # hashref is preserved by reference. This means:
369             # 1. All Request/Response objects created from the same scope chain share stash
370             # 2. Middleware modifications to stash are visible to downstream handlers
371             # 3. The stash "transcends" the middleware chain via scope, not via object identity
372             #
373             # This addresses a potential concern about Request objects being ephemeral - stash
374             # works correctly because it lives in scope, which IS shared across the chain.
375             sub stash {
376 37     37 1 119 my $self = shift;
377 37   100     428 return $self->{scope}{'pagi.stash'} //= {};
378             }
379              
380             # Application state (injected by PAGI::Lifespan, read-only)
381             sub state {
382 13     13 1 37 my $self = shift;
383 13   100     80 return $self->{scope}{state} // {};
384             }
385              
386             # Body streaming - mutually exclusive with buffered body methods
387             sub body_stream {
388 3     3 1 42 my ($self, %opts) = @_;
389              
390 3 100       106 croak "Body already consumed; streaming not available" if $self->{scope}{'pagi.request.body.read'};
391 2 50       4 croak "Body streaming already started" if $self->{scope}{'pagi.request.body.stream.created'};
392              
393 2         3 $self->{scope}{'pagi.request.body.stream.created'} = 1;
394              
395 2         3 my $max_bytes = $opts{max_bytes};
396 2 50       5 my $limit_name = defined $max_bytes ? 'max_bytes' : undef;
397 2 50       5 if (!defined $max_bytes) {
398 2         4 my $cl = $self->content_length;
399 2 50       11 if (defined $cl) {
400 0         0 $max_bytes = $cl;
401 0         0 $limit_name = 'content-length';
402             }
403             }
404              
405             return PAGI::Request::BodyStream->new(
406             receive => $self->{receive},
407             max_bytes => $max_bytes,
408             limit_name => $limit_name,
409             decode => $opts{decode},
410             strict => $opts{strict},
411 2         13 );
412             }
413              
414             # Read raw body bytes (async, cached in scope)
415 13     13 1 191 async sub body {
416 13         15 my $self = shift;
417              
418             croak "Body streaming already started; buffered helpers unavailable"
419 13 100       123 if $self->{scope}{'pagi.request.body.stream.created'};
420              
421             # Return cached body if already read
422 12 100       24 return $self->{scope}{'pagi.request.body'} if $self->{scope}{'pagi.request.body.read'};
423              
424 11         13 my $receive = $self->{receive};
425 11 50       19 die "No receive callback provided" unless $receive;
426              
427 11         14 my $body = '';
428 11         9 while (1) {
429 13         21 my $message = await $receive->();
430 13 50 33     616 last unless $message && $message->{type};
431 13 100       25 last if $message->{type} eq 'http.disconnect';
432              
433 12   50     41 $body .= $message->{body} // '';
434 12 100       32 last unless $message->{more};
435             }
436              
437 11         19 $self->{scope}{'pagi.request.body'} = $body;
438 11         14 $self->{scope}{'pagi.request.body.read'} = 1;
439 11         39 return $body;
440             }
441              
442             # Read body as decoded UTF-8 text (async)
443             # Options: strict => 1 (croak on invalid UTF-8)
444 1     1 1 8 async sub text {
445 1         2 my ($self, %opts) = @_;
446 1   50     5 my $strict = delete $opts{strict} // 0;
447 1 50       4 croak("Unknown options to text: " . join(', ', keys %opts)) if %opts;
448              
449 1         2 my $body = await $self->body;
450 1         25 return _decode_utf8($body, $strict);
451             }
452              
453             # Parse body as JSON (async, dies on error)
454 2     2 1 17 async sub json {
455 2         2 my $self = shift;
456 2         3 my $body = await $self->body;
457 2         66 return decode_json($body);
458             }
459              
460             # Parse URL-encoded form body (async, returns Hash::MultiValue, cached in scope)
461             # Options: strict => 1 (croak on invalid UTF-8), raw => 1 (skip UTF-8 decoding)
462 6     6 1 86 async sub form_params {
463 6         9 my ($self, %opts) = @_;
464 6   50     118 my $strict = delete $opts{strict} // 0;
465 6   50     15 my $raw = delete $opts{raw} // 0;
466              
467             # Extract multipart options before checking for unknown opts
468 6         7 my %multipart_opts;
469 6         10 for my $key (qw(max_field_size max_file_size spool_threshold max_files max_fields temp_dir)) {
470 36 50       62 $multipart_opts{$key} = delete $opts{$key} if exists $opts{$key};
471             }
472 6 50       9 croak("Unknown options to form_params: " . join(', ', keys %opts)) if %opts;
473              
474 6 50       14 my $cache_key = $raw ? 'pagi.request.form.raw' : ($strict ? 'pagi.request.form.strict' : 'pagi.request.form');
    50          
475              
476             # Return cached if available
477 6 100       18 return $self->{scope}{$cache_key} if $self->{scope}{$cache_key};
478              
479             # For multipart, delegate to uploads handling
480 5 100       11 if ($self->is_multipart) {
481             # Multipart always parses to default cache, then copy
482 1         3 my $form = await $self->_parse_multipart_form(%multipart_opts);
483 1         24 $self->{scope}{$cache_key} = $form;
484 1         3 return $form;
485             }
486              
487             # URL-encoded form
488 4         7 my $body = await $self->body;
489 4         91 my @pairs;
490              
491 4         12 for my $part (split /[&;]/, $body) {
492 6 50       9 next unless length $part;
493 6         12 my ($key, $val) = split /=/, $part, 2;
494 6   50     9 $key //= '';
495 6   50     8 $val //= '';
496              
497             # URL decode (handles + as space)
498 6         10 my $key_decoded = _url_decode($key);
499 6         6 my $val_decoded = _url_decode($val);
500              
501             # UTF-8 decode unless raw mode
502 6 50       35 my $key_final = $raw ? $key_decoded : _decode_utf8($key_decoded, $strict);
503 6 50       179 my $val_final = $raw ? $val_decoded : _decode_utf8($val_decoded, $strict);
504              
505 6         119 push @pairs, $key_final, $val_final;
506             }
507              
508 4         17 $self->{scope}{$cache_key} = Hash::MultiValue->new(@pairs);
509 4         151 return $self->{scope}{$cache_key};
510             }
511              
512             # DEPRECATED: Alias with warning
513 0     0 0 0 async sub form {
514 0         0 my $self = shift;
515 0         0 carp "form() is deprecated; use form_params() instead";
516 0         0 return await $self->form_params(@_);
517             }
518              
519             # Singular accessor for form params
520 0     0 1 0 async sub form_param {
521 0         0 my ($self, $name, %opts) = @_;
522 0         0 my $form = await $self->form_params(%opts);
523 0         0 return $form->get($name);
524             }
525              
526             # Raw form params (no UTF-8 decoding)
527 0     0 1 0 async sub raw_form_params {
528 0         0 my ($self, %opts) = @_;
529 0         0 return await $self->form_params(%opts, raw => 1);
530             }
531              
532             # DEPRECATED: Alias with warning
533 0     0 0 0 async sub raw_form {
534 0         0 my $self = shift;
535 0         0 carp "raw_form() is deprecated; use raw_form_params() instead";
536 0         0 return await $self->raw_form_params(@_);
537             }
538              
539             # Raw singular accessor
540 0     0 1 0 async sub raw_form_param {
541 0         0 my ($self, $name) = @_;
542 0         0 return await $self->form_param($name, raw => 1);
543             }
544              
545             # Parse multipart form (internal, cached in scope)
546 4     4   4 async sub _parse_multipart_form {
547 4         4 my ($self, %opts) = @_;
548              
549             # Already parsed?
550             return $self->{scope}{'pagi.request.form'}
551 4 0 33     7 if $self->{scope}{'pagi.request.form'} && $self->{scope}{'pagi.request.uploads'};
552              
553             # Extract boundary from content-type
554 4   50     5 my $ct = $self->header('content-type') // '';
555 4         14 my ($boundary) = $ct =~ /boundary=([^;\s]+)/;
556 4 50       15 $boundary =~ s/^["']|["']$//g if $boundary; # Strip quotes
557              
558 4 50       8 die "No boundary found in Content-Type" unless $boundary;
559              
560             my $handler = PAGI::Request::MultiPartHandler->new(
561             boundary => $boundary,
562             receive => $self->{receive},
563             max_field_size => $opts{max_field_size},
564             max_file_size => $opts{max_file_size},
565             spool_threshold => $opts{spool_threshold},
566             max_files => $opts{max_files},
567             max_fields => $opts{max_fields},
568             temp_dir => $opts{temp_dir},
569 4         35 );
570              
571 4         16 my ($form, $uploads) = await $handler->parse;
572              
573 4         402 $self->{scope}{'pagi.request.form'} = $form;
574 4         5 $self->{scope}{'pagi.request.uploads'} = $uploads;
575 4         4 $self->{scope}{'pagi.request.body.read'} = 1; # Body has been consumed
576              
577 4         20 return $form;
578             }
579              
580             # Get all uploads as Hash::MultiValue (cached in scope)
581 4     4 1 36 async sub uploads {
582 4         4 my ($self, %opts) = @_;
583              
584 4 100       17 return $self->{scope}{'pagi.request.uploads'} if $self->{scope}{'pagi.request.uploads'};
585              
586 3 50       6 if ($self->is_multipart) {
587 3         7 await $self->_parse_multipart_form(%opts);
588 3         72 return $self->{scope}{'pagi.request.uploads'};
589             }
590              
591             # Not multipart - return empty
592 0         0 $self->{scope}{'pagi.request.uploads'} = Hash::MultiValue->new();
593 0         0 return $self->{scope}{'pagi.request.uploads'};
594             }
595              
596             # Get single upload by field name
597 2     2 1 9 async sub upload {
598 2         3 my ($self, $name, %opts) = @_;
599 2         4 my $uploads = await $self->uploads(%opts);
600 2         53 return $uploads->get($name);
601             }
602              
603             # Get all uploads for a field name
604 1     1 1 7 async sub upload_all {
605 1         3 my ($self, $name, %opts) = @_;
606 1         2 my $uploads = await $self->uploads(%opts);
607 1         21 return $uploads->get_all($name);
608             }
609              
610             1;
611              
612             __END__