File Coverage

blib/lib/PAGI/Request.pm
Criterion Covered Total %
statement 285 317 89.9
branch 72 102 70.5
condition 50 76 65.7
subroutine 70 79 88.6
pod 57 62 91.9
total 534 636 83.9


line stmt bran cond sub pod time code
1             package PAGI::Request;
2 22     22   2372805 use strict;
  22         34  
  22         721  
3 22     22   92 use warnings;
  22         45  
  22         882  
4 22     22   8701 use Hash::MultiValue;
  22         51249  
  22         1233  
5 22     22   5717 use Encode qw(decode FB_CROAK FB_DEFAULT LEAVE_SRC);
  22         199172  
  22         1905  
6 22     22   8227 use Cookie::Baker qw(crush_cookie);
  22         75750  
  22         1683  
7 22     22   7630 use MIME::Base64 qw(decode_base64);
  22         14323  
  22         1472  
8 22     22   4441 use Future::AsyncAwait;
  22         164130  
  22         154  
9 22     22   8123 use JSON::MaybeXS qw(decode_json);
  22         181189  
  22         1801  
10 22     22   142 use Carp qw(croak carp);
  22         29  
  22         1008  
11 22     22   9460 use PAGI::Request::MultiPartHandler;
  22         103  
  22         1739  
12 22     22   171 use PAGI::Request::Upload;
  22         29  
  22         477  
13 22     22   8684 use PAGI::Request::Negotiate;
  22         147  
  22         1245  
14 22     22   8918 use PAGI::Request::BodyStream;
  22         43  
  22         118943  
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 632 my ($class, %opts) = @_;
30 5         54 for my $key (keys %opts) {
31 13 50       36 $CONFIG{$key} = $opts{$key} if exists $CONFIG{$key};
32             }
33             }
34              
35             sub config {
36 4     4 1 171748 my $class = shift;
37 4         17 return \%CONFIG;
38             }
39              
40             sub new {
41 121     121 1 2238931 my ($class, $scope, $receive) = @_;
42 121         504 return bless {
43             scope => $scope,
44             receive => $receive,
45             }, $class;
46             }
47              
48             # Basic properties from scope
49 55     55 1 847 sub method { shift->{scope}{method} }
50 2     2 1 8 sub path { shift->{scope}{path} }
51 4   66 4 1 14 sub raw_path { my $s = shift; $s->{scope}{raw_path} // $s->{scope}{path} }
  4         19  
52 12   100 12 1 51 sub query_string { shift->{scope}{query_string} // '' }
53 3   100 3 1 19 sub scheme { shift->{scope}{scheme} // 'http' }
54 4   100 4 1 31 sub http_version { shift->{scope}{http_version} // '1.1' }
55 1     1 1 4 sub client { shift->{scope}{client} }
56 2     2 1 13 sub raw { shift->{scope} }
57              
58             # Internal: URL decode a string (handles + as space)
59             sub _url_decode {
60 40     40   50 my ($str) = @_;
61 40 50       50 return '' unless defined $str;
62 40         50 $str =~ s/\+/ /g;
63 40         70 $str =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/ge;
  13         30  
64 40         173 return $str;
65             }
66              
67             # Internal: Decode UTF-8 with replacement or croak in strict mode
68             sub _decode_utf8 {
69 41     41   55 my ($str, $strict) = @_;
70 41 50       57 return '' unless defined $str;
71 41 50       54 my $flag = $strict ? FB_CROAK : FB_DEFAULT;
72 41         45 $flag |= LEAVE_SRC;
73 41         162 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 30 my $self = shift;
85 20   50     39 my $ct = $self->header('content-type') // '';
86             # Strip parameters like charset
87 20         83 $ct =~ s/;.*//;
88 20         40 return $ct;
89             }
90              
91             # Content-Length shortcut
92             sub content_length {
93 4     4 1 12 my $self = shift;
94 4         17 return $self->header('content-length');
95             }
96              
97             # Single header lookup (case-insensitive, returns last value)
98             sub header {
99 52     52 1 81 my ($self, $name) = @_;
100 52         87 $name = lc($name);
101 52         155 my $value;
102 52   50     59 for my $pair (@{$self->{scope}{headers} // []}) {
  52         167  
103 83 100       189 if (lc($pair->[0]) eq $name) {
104 50         75 $value = $pair->[1];
105             }
106             }
107 52         170 return $value;
108             }
109              
110             # All headers as Hash::MultiValue (cached in scope, case-insensitive keys)
111             sub headers {
112 18     18 1 24 my $self = shift;
113 18 100       90 return $self->{scope}{'pagi.request.headers'} if $self->{scope}{'pagi.request.headers'};
114              
115 5         8 my @pairs;
116 5   50     11 for my $pair (@{$self->{scope}{headers} // []}) {
  5         20  
117 10         30 push @pairs, lc($pair->[0]), $pair->[1];
118             }
119              
120 5         32 $self->{scope}{'pagi.request.headers'} = Hash::MultiValue->new(@pairs);
121 5         232 return $self->{scope}{'pagi.request.headers'};
122             }
123              
124             # All values for a header
125             sub header_all {
126 17     17 1 1648 my ($self, $name) = @_;
127 17         33 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 15     15 1 32 my ($self, %opts) = @_;
134 15   50     156 my $strict = delete $opts{strict} // 0;
135 15   50     43 my $raw = delete $opts{raw} // 0;
136 15 50       27 croak("Unknown options to query_params: " . join(', ', keys %opts)) if %opts;
137              
138 15 50       39 my $cache_key = $raw ? 'pagi.request.query.raw' : ($strict ? 'pagi.request.query.strict' : 'pagi.request.query');
    50          
139 15 100       50 return $self->{scope}{$cache_key} if $self->{scope}{$cache_key};
140              
141 9         27 my $qs = $self->query_string;
142 9         15 my @pairs;
143              
144 9         42 for my $part (split /[&;]/, $qs) {
145 14 50       34 next unless length $part;
146 14         38 my ($key, $val) = split /=/, $part, 2;
147 14   50     26 $key //= '';
148 14   50     25 $val //= '';
149              
150             # URL decode (handles + as space)
151 14         28 my $key_decoded = _url_decode($key);
152 14         55 my $val_decoded = _url_decode($val);
153              
154             # UTF-8 decode unless raw mode
155 14 50       33 my $key_final = $raw ? $key_decoded : _decode_utf8($key_decoded, $strict);
156 14 50       522 my $val_final = $raw ? $val_decoded : _decode_utf8($val_decoded, $strict);
157              
158 14         337 push @pairs, $key_final, $val_final;
159             }
160              
161 9         52 $self->{scope}{$cache_key} = Hash::MultiValue->new(@pairs);
162 9         427 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 12     12 1 1820 my ($self, $name, %opts) = @_;
174 12         36 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 112 my $self = shift;
200 7 100       31 return $self->{scope}{'pagi.request.cookies'} if exists $self->{scope}{'pagi.request.cookies'};
201              
202 4   100     10 my $cookie_header = $self->header('cookie') // '';
203 4         14 $self->{scope}{'pagi.request.cookies'} = crush_cookie($cookie_header);
204 4         200 return $self->{scope}{'pagi.request.cookies'};
205             }
206              
207             # Single cookie value
208             sub cookie {
209 5     5 1 18 my ($self, $name) = @_;
210 5         10 return $self->cookies->{$name};
211             }
212              
213             # Method predicates
214 9   50 9 1 54 sub is_get { uc(shift->method // '') eq 'GET' }
215 9   50 9 1 1473 sub is_post { uc(shift->method // '') eq 'POST' }
216 7   50 7 1 1277 sub is_put { uc(shift->method // '') eq 'PUT' }
217 7   50 7 1 1316 sub is_patch { uc(shift->method // '') eq 'PATCH' }
218 7   50 7 1 1255 sub is_delete { uc(shift->method // '') eq 'DELETE' }
219 7   50 7 1 1252 sub is_head { uc(shift->method // '') eq 'HEAD' }
220 7   50 7 1 1370 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 39     39 1 44 my $self = shift;
232 39         64 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         23 my $conn = $self->connection;
239 12 100       26 return 0 unless $conn;
240 10         20 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 15 my $self = shift;
247 6         10 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 31 my $self = shift;
253 13         24 my $conn = $self->connection;
254 13 100       24 return undef unless $conn;
255 12         24 return $conn->disconnect_reason;
256             }
257              
258             # Register a callback to be invoked when disconnect occurs
259             sub on_disconnect {
260 7     7 1 35 my ($self, $cb) = @_;
261 7         16 my $conn = $self->connection;
262 7 100       26 $conn->on_disconnect($cb) if $conn;
263 7         21 return $self;
264             }
265              
266             # Get a Future that resolves when the client disconnects
267             sub disconnect_future {
268 4     4 1 408 my $self = shift;
269 4         7 my $conn = $self->connection;
270 4 100       9 return undef unless $conn;
271 3         7 return $conn->disconnect_future;
272             }
273              
274             # Content-type predicates
275             sub is_json {
276 3     3 1 3 my $self = shift;
277 3         6 my $ct = $self->content_type;
278 3         13 return $ct eq 'application/json';
279             }
280              
281             sub is_form {
282 3     3 1 3 my $self = shift;
283 3         13 my $ct = $self->content_type;
284 3   100     16 return $ct eq 'application/x-www-form-urlencoded'
285             || $ct =~ m{^multipart/form-data};
286             }
287              
288             sub is_multipart {
289 10     10 1 10 my $self = shift;
290 10         40 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 32 my ($self, $mime_type) = @_;
298 12         28 my @accept_values = $self->header_all('accept');
299 12         139 my $accept = join(', ', @accept_values);
300 12         35 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 17 my ($self, @types) = @_;
307 4         12 my @accept_values = $self->header_all('accept');
308 4         48 my $accept = join(', ', @accept_values);
309 4         18 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 104 my $self = shift;
315 3   100     8 my $auth = $self->header('authorization') // '';
316 3 100       15 if ($auth =~ /^Bearer\s+(.+)$/i) {
317 1         8 return $1;
318             }
319 2         9 return undef;
320             }
321              
322             # Extract Basic auth credentials
323             sub basic_auth {
324 3     3 1 11 my $self = shift;
325 3   100     7 my $auth = $self->header('authorization') // '';
326 3 100       19 if ($auth =~ /^Basic\s+(.+)$/i) {
327 2         8 my $decoded = decode_base64($1);
328 2         7 my ($user, $pass) = split /:/, $decoded, 2;
329 2         7 return ($user, $pass);
330             }
331 1         3 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 30     30 1 153 my $self = shift;
338 30         104 my $params = $self->{scope}{path_params};
339 30 100 100     96 if (!defined $params && $CONFIG{path_param_strict}) {
340 2         194 croak "path_params not set in scope (no router configured?). "
341             . "Set PAGI::Request->configure(path_param_strict => 0) to allow this.";
342             }
343 28   100     93 return $params // {};
344             }
345              
346 19     19   30 sub _default_path_param_strict_opt { return 1 }
347              
348             sub path_param {
349 25     25 1 3812 my ($self, $name, %opts) = @_;
350 25 100       80 my $strict = exists $opts{strict} ? delete $opts{strict} : $self->_default_path_param_strict_opt;
351 25 50       65 croak("Unknown options to path_param: " . join(', ', keys %opts)) if %opts;
352              
353 25         42 my $params = $self->path_params;
354              
355 24 100 100     101 if ($strict && !exists $params->{$name}) {
356 7         17 my @available = keys %$params;
357 7 100       955 croak "path_param '$name' not found. "
358             . (@available ? "Available: " . join(', ', sort @available) : "No path params set (no router?)");
359             }
360              
361 17         89 return $params->{$name};
362             }
363              
364 9     9 1 40 sub scope { shift->{scope} }
365              
366              
367             # Application state (injected by PAGI::Lifespan, read-only)
368             sub state {
369 7     7 1 33 my $self = shift;
370 7   100     66 return $self->{scope}{state} // {};
371             }
372              
373             # Body streaming - mutually exclusive with buffered body methods
374             sub body_stream {
375 3     3 1 48 my ($self, %opts) = @_;
376              
377 3 100       96 croak "Body already consumed; streaming not available" if $self->{scope}{'pagi.request.body.read'};
378 2 50       9 croak "Body streaming already started" if $self->{scope}{'pagi.request.body.stream.created'};
379              
380 2         5 $self->{scope}{'pagi.request.body.stream.created'} = 1;
381              
382 2         4 my $max_bytes = $opts{max_bytes};
383 2 50       7 my $limit_name = defined $max_bytes ? 'max_bytes' : undef;
384 2 50       8 if (!defined $max_bytes) {
385 2         8 my $cl = $self->content_length;
386 2 50       5 if (defined $cl) {
387 0         0 $max_bytes = $cl;
388 0         0 $limit_name = 'content-length';
389             }
390             }
391              
392             return PAGI::Request::BodyStream->new(
393             receive => $self->{receive},
394             max_bytes => $max_bytes,
395             limit_name => $limit_name,
396             decode => $opts{decode},
397             strict => $opts{strict},
398 2         22 );
399             }
400              
401             # Read raw body bytes (async, cached in scope)
402 13     13 1 143 async sub body {
403 13         12 my $self = shift;
404              
405             croak "Body streaming already started; buffered helpers unavailable"
406 13 100       173 if $self->{scope}{'pagi.request.body.stream.created'};
407              
408             # Return cached body if already read
409 12 100       25 return $self->{scope}{'pagi.request.body'} if $self->{scope}{'pagi.request.body.read'};
410              
411 11         9 my $receive = $self->{receive};
412 11 50       22 die "No receive callback provided" unless $receive;
413              
414 11         11 my $body = '';
415 11         9 while (1) {
416 13         44 my $message = await $receive->();
417 13 50 33     656 last unless $message && $message->{type};
418 13 100       26 last if $message->{type} eq 'http.disconnect';
419              
420 12   50     26 $body .= $message->{body} // '';
421 12 100       32 last unless $message->{more};
422             }
423              
424 11         17 $self->{scope}{'pagi.request.body'} = $body;
425 11         21 $self->{scope}{'pagi.request.body.read'} = 1;
426 11         32 return $body;
427             }
428              
429             # Read body as decoded UTF-8 text (async)
430             # Options: strict => 1 (croak on invalid UTF-8)
431 1     1 1 8 async sub text {
432 1         2 my ($self, %opts) = @_;
433 1   50     6 my $strict = delete $opts{strict} // 0;
434 1 50       2 croak("Unknown options to text: " . join(', ', keys %opts)) if %opts;
435              
436 1         3 my $body = await $self->body;
437 1         43 return _decode_utf8($body, $strict);
438             }
439              
440             # Parse body as JSON (async, dies on error)
441 2     2 1 16 async sub json {
442 2         3 my $self = shift;
443 2         3 my $body = await $self->body;
444 2         68 return decode_json($body);
445             }
446              
447             # Parse URL-encoded form body (async, returns Hash::MultiValue, cached in scope)
448             # Options: strict => 1 (croak on invalid UTF-8), raw => 1 (skip UTF-8 decoding)
449 6     6 1 83 async sub form_params {
450 6         142 my ($self, %opts) = @_;
451 6   50     23 my $strict = delete $opts{strict} // 0;
452 6   50     16 my $raw = delete $opts{raw} // 0;
453              
454             # Extract multipart options before checking for unknown opts
455 6         7 my %multipart_opts;
456 6         11 for my $key (qw(max_field_size max_file_size spool_threshold max_files max_fields temp_dir)) {
457 36 50       40 $multipart_opts{$key} = delete $opts{$key} if exists $opts{$key};
458             }
459 6 50       11 croak("Unknown options to form_params: " . join(', ', keys %opts)) if %opts;
460              
461 6 50       14 my $cache_key = $raw ? 'pagi.request.form.raw' : ($strict ? 'pagi.request.form.strict' : 'pagi.request.form');
    50          
462              
463             # Return cached if available
464 6 100       18 return $self->{scope}{$cache_key} if $self->{scope}{$cache_key};
465              
466             # For multipart, delegate to uploads handling
467 5 100       12 if ($self->is_multipart) {
468             # Multipart always parses to default cache, then copy
469 1         4 my $form = await $self->_parse_multipart_form(%multipart_opts);
470 1         24 $self->{scope}{$cache_key} = $form;
471 1         4 return $form;
472             }
473              
474             # URL-encoded form
475 4         7 my $body = await $self->body;
476 4         86 my @pairs;
477              
478 4         14 for my $part (split /[&;]/, $body) {
479 6 50       8 next unless length $part;
480 6         12 my ($key, $val) = split /=/, $part, 2;
481 6   50     8 $key //= '';
482 6   50     9 $val //= '';
483              
484             # URL decode (handles + as space)
485 6         9 my $key_decoded = _url_decode($key);
486 6         7 my $val_decoded = _url_decode($val);
487              
488             # UTF-8 decode unless raw mode
489 6 50       12 my $key_final = $raw ? $key_decoded : _decode_utf8($key_decoded, $strict);
490 6 50       171 my $val_final = $raw ? $val_decoded : _decode_utf8($val_decoded, $strict);
491              
492 6         115 push @pairs, $key_final, $val_final;
493             }
494              
495 4         14 $self->{scope}{$cache_key} = Hash::MultiValue->new(@pairs);
496 4         150 return $self->{scope}{$cache_key};
497             }
498              
499             # DEPRECATED: Alias with warning
500 0     0 0 0 async sub form {
501 0         0 my $self = shift;
502 0         0 carp "form() is deprecated; use form_params() instead";
503 0         0 return await $self->form_params(@_);
504             }
505              
506             # Singular accessor for form params
507 0     0 1 0 async sub form_param {
508 0         0 my ($self, $name, %opts) = @_;
509 0         0 my $form = await $self->form_params(%opts);
510 0         0 return $form->get($name);
511             }
512              
513             # Raw form params (no UTF-8 decoding)
514 0     0 1 0 async sub raw_form_params {
515 0         0 my ($self, %opts) = @_;
516 0         0 return await $self->form_params(%opts, raw => 1);
517             }
518              
519             # DEPRECATED: Alias with warning
520 0     0 0 0 async sub raw_form {
521 0         0 my $self = shift;
522 0         0 carp "raw_form() is deprecated; use raw_form_params() instead";
523 0         0 return await $self->raw_form_params(@_);
524             }
525              
526             # Raw singular accessor
527 0     0 1 0 async sub raw_form_param {
528 0         0 my ($self, $name) = @_;
529 0         0 return await $self->form_param($name, raw => 1);
530             }
531              
532             # Parse multipart form (internal, cached in scope)
533 4     4   6 async sub _parse_multipart_form {
534 4         6 my ($self, %opts) = @_;
535              
536             # Already parsed?
537             return $self->{scope}{'pagi.request.form'}
538 4 0 33     7 if $self->{scope}{'pagi.request.form'} && $self->{scope}{'pagi.request.uploads'};
539              
540             # Extract boundary from content-type
541 4   50     6 my $ct = $self->header('content-type') // '';
542 4         14 my ($boundary) = $ct =~ /boundary=([^;\s]+)/;
543 4 50       17 $boundary =~ s/^["']|["']$//g if $boundary; # Strip quotes
544              
545 4 50       6 die "No boundary found in Content-Type" unless $boundary;
546              
547             my $handler = PAGI::Request::MultiPartHandler->new(
548             boundary => $boundary,
549             receive => $self->{receive},
550             max_field_size => $opts{max_field_size},
551             max_file_size => $opts{max_file_size},
552             spool_threshold => $opts{spool_threshold},
553             max_files => $opts{max_files},
554             max_fields => $opts{max_fields},
555             temp_dir => $opts{temp_dir},
556 4         39 );
557              
558 4         14 my ($form, $uploads) = await $handler->parse;
559              
560 4         472 $self->{scope}{'pagi.request.form'} = $form;
561 4         5 $self->{scope}{'pagi.request.uploads'} = $uploads;
562 4         9 $self->{scope}{'pagi.request.body.read'} = 1; # Body has been consumed
563              
564 4         71 return $form;
565             }
566              
567             # Get all uploads as Hash::MultiValue (cached in scope)
568 4     4 1 13 async sub uploads {
569 4         5 my ($self, %opts) = @_;
570              
571 4 100       17 return $self->{scope}{'pagi.request.uploads'} if $self->{scope}{'pagi.request.uploads'};
572              
573 3 50       142 if ($self->is_multipart) {
574 3         5 await $self->_parse_multipart_form(%opts);
575 3         107 return $self->{scope}{'pagi.request.uploads'};
576             }
577              
578             # Not multipart - return empty
579 0         0 $self->{scope}{'pagi.request.uploads'} = Hash::MultiValue->new();
580 0         0 return $self->{scope}{'pagi.request.uploads'};
581             }
582              
583             # Get single upload by field name
584 2     2 1 9 async sub upload {
585 2         3 my ($self, $name, %opts) = @_;
586 2         5 my $uploads = await $self->uploads(%opts);
587 2         54 return $uploads->get($name);
588             }
589              
590             # Get all uploads for a field name
591 1     1 1 8 async sub upload_all {
592 1         2 my ($self, $name, %opts) = @_;
593 1         3 my $uploads = await $self->uploads(%opts);
594 1         28 return $uploads->get_all($name);
595             }
596              
597             1;
598              
599             __END__