File Coverage

blib/lib/PAGI/Request.pm
Criterion Covered Total %
statement 297 349 85.1
branch 89 128 69.5
condition 57 91 62.6
subroutine 73 88 82.9
pod 64 69 92.7
total 580 725 80.0


line stmt bran cond sub pod time code
1             package PAGI::Request;
2             $PAGI::Request::VERSION = '0.002000';
3 23     23   2431054 use strict;
  23         37  
  23         724  
4 23     23   106 use warnings;
  23         42  
  23         990  
5 23     23   9146 use Hash::MultiValue;
  23         53249  
  23         1109  
6 23     23   7399 use PAGI::Headers ();
  23         48  
  23         931  
7 23     23   6935 use Encode qw(decode FB_CROAK FB_DEFAULT LEAVE_SRC);
  23         223195  
  23         2076  
8 23     23   10490 use Cookie::Baker qw(crush_cookie);
  23         80140  
  23         1828  
9 23     23   10552 use MIME::Base64 qw(decode_base64);
  23         15643  
  23         1476  
10 23     23   4657 use Future::AsyncAwait;
  23         171675  
  23         204  
11 23     23   8935 use JSON::MaybeXS qw(decode_json);
  23         201406  
  23         1987  
12 23     23   170 use Carp qw(croak carp);
  23         44  
  23         942  
13 23     23   10612 use PAGI::Request::MultiPartHandler;
  23         66  
  23         1573  
14 23     23   177 use PAGI::Request::Upload;
  23         26  
  23         439  
15 23     23   9132 use PAGI::Request::Negotiate;
  23         39  
  23         1277  
16 23     23   9323 use PAGI::Request::BodyStream;
  23         56  
  23         129464  
17              
18             sub new {
19 110     110 1 2288212 my ($class, $scope, $receive) = @_;
20 110         461 return bless {
21             scope => $scope,
22             receive => $receive,
23             }, $class;
24             }
25              
26             # Basic properties from scope
27 55     55 1 900 sub method { shift->{scope}{method} }
28 2     2 1 10 sub path { shift->{scope}{path} }
29 4   66 4 1 13 sub raw_path { my $s = shift; $s->{scope}{raw_path} // $s->{scope}{path} }
  4         21  
30 12   100 12 1 53 sub query_string { shift->{scope}{query_string} // '' }
31 3   100 3 1 20 sub scheme { shift->{scope}{scheme} // 'http' }
32 4   100 4 1 31 sub http_version { shift->{scope}{http_version} // '1.1' }
33 1     1 1 5 sub client { shift->{scope}{client} }
34 2     2 1 15 sub raw { shift->{scope} }
35              
36             # Internal: URL decode a string (handles + as space)
37             sub _url_decode {
38 40     40   48 my ($str) = @_;
39 40 50       53 return '' unless defined $str;
40 40         39 $str =~ s/\+/ /g;
41 40         55 $str =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/ge;
  13         31  
42 40         49 return $str;
43             }
44              
45             # Internal: Decode UTF-8 with replacement or croak in strict mode
46             sub _decode_utf8 {
47 41     41   51 my ($str, $strict) = @_;
48 41 50       70 return '' unless defined $str;
49 41 50       51 my $flag = $strict ? FB_CROAK : FB_DEFAULT;
50 41         39 $flag |= LEAVE_SRC;
51 41         147 return decode('UTF-8', $str, $flag);
52             }
53              
54             # Host from headers
55             sub host {
56 1     1 1 2 my $self = shift;
57 1         4 return $self->header('host');
58             }
59              
60             # Content-Type shortcut
61             sub content_type {
62 18     18 1 28 my $self = shift;
63 18   50     71 my $ct = $self->header('content-type') // '';
64             # Strip parameters like charset
65 18         78 $ct =~ s/;.*//;
66 18         39 return $ct;
67             }
68              
69             # Content-Length shortcut
70             sub content_length {
71 4     4 1 12 my $self = shift;
72 4         10 return $self->header('content-length');
73             }
74              
75             # Private cache: the source of truth for header lookups (built once per request).
76             sub _header_snapshot {
77 72     72   71 my $self = shift;
78             return $self->{scope}{'pagi.request.headers'}
79 72   50     431 //= PAGI::Headers->new($self->{scope}{headers} // []);
      66        
80             }
81              
82             # Public: a PAGI::Headers snapshot of the inbound headers. Returns an independent
83             # CLONE so mutating it cannot poison later header()/content_type()/cookie lookups.
84 3     3 1 17 sub headers { return $_[0]->_header_snapshot->clone }
85              
86             # Single header lookup (case-insensitive, last value)
87 56     56 1 159 sub header { return $_[0]->_header_snapshot->get($_[1]) }
88              
89             # All values for a header
90 13     13 1 576 sub header_all { return $_[0]->_header_snapshot->get_all($_[1]) }
91              
92             # Query params as Hash::MultiValue (cached in scope)
93             # Options: strict => 1 (croak on invalid UTF-8), raw => 1 (skip UTF-8 decoding)
94             sub query_params {
95 15     15 1 30 my ($self, %opts) = @_;
96 15   50     54 my $strict = delete $opts{strict} // 0;
97 15   50     42 my $raw = delete $opts{raw} // 0;
98 15 50       28 croak("Unknown options to query_params: " . join(', ', keys %opts)) if %opts;
99              
100 15 50       38 my $cache_key = $raw ? 'pagi.request.query.raw' : ($strict ? 'pagi.request.query.strict' : 'pagi.request.query');
    50          
101 15 100       52 return $self->{scope}{$cache_key} if $self->{scope}{$cache_key};
102              
103 9         24 my $qs = $self->query_string;
104 9         11 my @pairs;
105              
106 9         40 for my $part (split /[&;]/, $qs) {
107 14 50       32 next unless length $part;
108 14         30 my ($key, $val) = split /=/, $part, 2;
109 14   50     21 $key //= '';
110 14   50     23 $val //= '';
111              
112             # URL decode (handles + as space)
113 14         102 my $key_decoded = _url_decode($key);
114 14         113 my $val_decoded = _url_decode($val);
115              
116             # UTF-8 decode unless raw mode
117 14 50       34 my $key_final = $raw ? $key_decoded : _decode_utf8($key_decoded, $strict);
118 14 50       513 my $val_final = $raw ? $val_decoded : _decode_utf8($val_decoded, $strict);
119              
120 14         307 push @pairs, $key_final, $val_final;
121             }
122              
123 9         53 $self->{scope}{$cache_key} = Hash::MultiValue->new(@pairs);
124 9         469 return $self->{scope}{$cache_key};
125             }
126              
127             # Raw query params (no UTF-8 decoding)
128             sub raw_query_params {
129 0     0 0 0 my $self = shift;
130 0         0 return $self->query_params(raw => 1);
131             }
132              
133             # Shortcut for single query param
134             sub query_param {
135 12     12 1 1614 my ($self, $name, %opts) = @_;
136 12         38 return $self->query_params(%opts)->get($name);
137             }
138              
139             # DEPRECATED: Alias with warning
140             sub query {
141 0     0 0 0 my $self = shift;
142 0         0 carp "query() is deprecated; use query_param() instead";
143 0         0 return $self->query_param(@_);
144             }
145              
146             # Raw single query param
147             sub raw_query_param {
148 0     0 1 0 my ($self, $name) = @_;
149 0         0 return $self->query_param($name, raw => 1);
150             }
151              
152             # DEPRECATED: Alias with warning
153             sub raw_query {
154 0     0 0 0 my $self = shift;
155 0         0 carp "raw_query() is deprecated; use raw_query_param() instead";
156 0         0 return $self->raw_query_param(@_);
157             }
158              
159             # All cookies as hashref (cached in scope)
160             sub cookies {
161 7     7 1 15 my $self = shift;
162 7 100       30 return $self->{scope}{'pagi.request.cookies'} if exists $self->{scope}{'pagi.request.cookies'};
163              
164 4   100     11 my $cookie_header = $self->header('cookie') // '';
165 4         16 $self->{scope}{'pagi.request.cookies'} = crush_cookie($cookie_header);
166 4         217 return $self->{scope}{'pagi.request.cookies'};
167             }
168              
169             # Single cookie value
170             sub cookie {
171 5     5 1 15 my ($self, $name) = @_;
172 5         11 return $self->cookies->{$name};
173             }
174              
175             # Method predicates
176 9   50 9 1 50 sub is_get { uc(shift->method // '') eq 'GET' }
177 9   50 9 1 1262 sub is_post { uc(shift->method // '') eq 'POST' }
178 7   50 7 1 1160 sub is_put { uc(shift->method // '') eq 'PUT' }
179 7   50 7 1 1177 sub is_patch { uc(shift->method // '') eq 'PATCH' }
180 7   50 7 1 1163 sub is_delete { uc(shift->method // '') eq 'DELETE' }
181 7   50 7 1 1203 sub is_head { uc(shift->method // '') eq 'HEAD' }
182 7   50 7 1 1279 sub is_options { uc(shift->method // '') eq 'OPTIONS' }
183              
184             # =============================================================================
185             # Connection State Methods (PAGI spec 0.3)
186             #
187             # These methods provide non-destructive disconnect detection via the
188             # pagi.connection scope key, which is a PAGI::Server::ConnectionState object.
189             # =============================================================================
190              
191             # Get the connection state object
192             sub connection {
193 5     5 1 6 my $self = shift;
194 5         11 return $self->{scope}{'pagi.connection'};
195             }
196              
197             # Check if client is still connected (synchronous, non-destructive)
198             sub is_connected {
199 0     0 1 0 my $self = shift;
200 0         0 my $conn = $self->connection;
201 0 0       0 return 0 unless $conn;
202 0         0 return $conn->is_connected;
203             }
204              
205             # Check if client has disconnected (synchronous, non-destructive)
206             # This is the inverse of is_connected - preferred for new code
207             sub is_disconnected {
208 0     0 1 0 my $self = shift;
209 0         0 return !$self->is_connected;
210             }
211              
212             # Get the disconnect reason string, or undef if still connected
213             sub disconnect_reason {
214 0     0 1 0 my $self = shift;
215 0         0 my $conn = $self->connection;
216 0 0       0 return undef unless $conn;
217 0         0 return $conn->disconnect_reason;
218             }
219              
220             # Register a callback to be invoked on an abnormal disconnect (not on a clean
221             # finish). The counterpart to on_complete; exactly one of the two fires.
222             sub on_disconnect {
223 3     3 1 18 my ($self, $cb) = @_;
224 3         7 my $conn = $self->connection;
225 3 100       13 $conn->on_disconnect($cb) if $conn;
226 3         16 return $self;
227             }
228              
229             # Register a callback to be invoked only when the request completes successfully.
230             # The counterpart to on_disconnect; exactly one of the two fires.
231             sub on_complete {
232 2     2 1 11 my ($self, $cb) = @_;
233 2         5 my $conn = $self->connection;
234 2 100       28 $conn->on_complete($cb) if $conn;
235 2         13 return $self;
236             }
237              
238             # Get a Future that resolves when the client disconnects
239             sub disconnect_future {
240 0     0 1 0 my $self = shift;
241 0         0 my $conn = $self->connection;
242 0 0       0 return undef unless $conn;
243 0         0 return $conn->disconnect_future;
244             }
245              
246             # Outbound flow-control introspection (delegates to the pagi.transport handle)
247             sub buffered_amount {
248 2     2 1 9 my $self = shift;
249 2         5 my $t = $self->{scope}{'pagi.transport'};
250 2 100       10 return 0 unless $t;
251 1         4 return $t->buffered_amount;
252             }
253              
254             sub high_water_mark {
255 2     2 1 614 my $self = shift;
256 2         3 my $t = $self->{scope}{'pagi.transport'};
257 2 100       7 return undef unless $t;
258 1         4 return $t->high_water_mark;
259             }
260              
261             sub low_water_mark {
262 2     2 1 323 my $self = shift;
263 2         5 my $t = $self->{scope}{'pagi.transport'};
264 2 100       6 return undef unless $t;
265 1         4 return $t->low_water_mark;
266             }
267              
268             sub on_high_water {
269 2     2 1 31 my ($self, $cb) = @_;
270 2         5 my $t = $self->{scope}{'pagi.transport'};
271 2 100 66     23 $t->on_high_water($cb) if $t && $t->can('on_high_water');
272 2         13 return $self;
273             }
274              
275             sub on_drain {
276 2     2 1 461 my ($self, $cb) = @_;
277 2         6 my $t = $self->{scope}{'pagi.transport'};
278 2 100 66     32 $t->on_drain($cb) if $t && $t->can('on_drain');
279 2         10 return $self;
280             }
281              
282             sub is_writable {
283 3     3 1 4 my $self = shift;
284 3         5 my $t = $self->{scope}{'pagi.transport'};
285 3 100       7 return 1 unless $t;
286 2         6 my $high = $t->high_water_mark;
287 2 50       7 return 1 unless defined $high;
288 2 100       6 return $t->buffered_amount < $high ? 1 : 0;
289             }
290              
291             # Content-type predicates
292             sub is_json {
293 0     0 1 0 my $self = shift;
294 0         0 my $ct = $self->content_type;
295 0         0 return $ct eq 'application/json';
296             }
297              
298             sub is_form {
299 0     0 1 0 my $self = shift;
300 0         0 my $ct = $self->content_type;
301 0   0     0 return $ct eq 'application/x-www-form-urlencoded'
302             || $ct =~ m{^multipart/form-data};
303             }
304              
305             sub is_multipart {
306 14     14 1 17 my $self = shift;
307 14         35 my $ct = $self->content_type;
308 14         186 return $ct =~ m{^multipart/form-data};
309             }
310              
311             # Accept header check using Negotiate module
312             # Combines multiple Accept headers per RFC 7230 Section 3.2.2
313             sub accepts {
314 7     7 1 22 my ($self, $mime_type) = @_;
315 7         20 my @accept_values = $self->header_all('accept');
316 7         14 my $accept = join(', ', @accept_values);
317 7         19 return PAGI::Request::Negotiate->accepts_type($accept, $mime_type);
318             }
319              
320             # Find best matching content type from supported list
321             # Combines multiple Accept headers per RFC 7230 Section 3.2.2
322             sub preferred_type {
323 4     4 1 15 my ($self, @types) = @_;
324 4         13 my @accept_values = $self->header_all('accept');
325 4         8 my $accept = join(', ', @accept_values);
326 4         13 return PAGI::Request::Negotiate->best_match(\@types, $accept);
327             }
328              
329             # Extract Bearer token from Authorization header
330             sub bearer_token {
331 3     3 1 13 my $self = shift;
332 3   100     10 my $auth = $self->header('authorization') // '';
333 3 100       15 if ($auth =~ /^Bearer\s+(.+)$/i) {
334 1         10 return $1;
335             }
336 2         7 return undef;
337             }
338              
339             # Extract Basic auth credentials
340             sub basic_auth {
341 3     3 1 12 my $self = shift;
342 3   100     9 my $auth = $self->header('authorization') // '';
343 3 100       18 if ($auth =~ /^Basic\s+(.+)$/i) {
344 2         11 my $decoded = decode_base64($1);
345 2         8 my ($user, $pass) = split /:/, $decoded, 2;
346 2         9 return ($user, $pass);
347             }
348 1         2 return (undef, undef);
349             }
350              
351             # Path parameters - captured from URL path by router
352             # Stored in scope->{path_params} for router-agnostic access
353             sub path_params {
354 33     33 1 383 my ($self, %opts) = @_;
355 33         42 my $strict = delete $opts{strict};
356 33 100       136 croak("Unknown options to path_params: " . join(', ', keys %opts)) if %opts;
357              
358 32         48 my $params = $self->{scope}{path_params};
359 32 100 100     82 if (!defined $params && $strict) {
360 1         98 croak "path_params not set in scope (no router configured?). "
361             . "Pass strict => 0 to allow this.";
362             }
363 31   100     96 return $params // {};
364             }
365              
366 19     19   26 sub _default_path_param_strict_opt { return 1 }
367              
368             sub path_param {
369 25     25 1 3315 my ($self, $name, %opts) = @_;
370 25 100       89 my $strict = exists $opts{strict} ? delete $opts{strict} : $self->_default_path_param_strict_opt;
371 25 50       55 croak("Unknown options to path_param: " . join(', ', keys %opts)) if %opts;
372              
373 25         39 my $params = $self->path_params;
374              
375 25 100 100     84 if ($strict && !exists $params->{$name}) {
376 8         16 my @available = keys %$params;
377 8 100       966 croak "path_param '$name' not found. "
378             . (@available ? "Available: " . join(', ', sort @available) : "No path params set (no router?)");
379             }
380              
381 17         64 return $params->{$name};
382             }
383              
384 9     9 1 38 sub scope { shift->{scope} }
385              
386             # Vend a detached response bound to this request's scope (the raw-app analog
387             # of $ctx->response). It is a value, not a connection; call ->respond($send)
388             # to send it.
389             sub response {
390 2     2 1 8 my $self = shift;
391 2         9 require PAGI::Response;
392 2         14 return PAGI::Response->new($self->{scope});
393             }
394              
395              
396             # Application state (injected by PAGI::Lifespan, read-only)
397             sub state {
398 7     7 1 15 my $self = shift;
399 7   100     50 return $self->{scope}{state} // {};
400             }
401              
402             # Body streaming - mutually exclusive with buffered body methods
403             sub body_stream {
404 3     3 1 41 my ($self, %opts) = @_;
405              
406 3 100       89 croak "Body already consumed; streaming not available" if $self->{scope}{'pagi.request.body.read'};
407 2 50       5 croak "Body streaming already started" if $self->{scope}{'pagi.request.body.stream.created'};
408              
409 2         3 $self->{scope}{'pagi.request.body.stream.created'} = 1;
410              
411 2         4 my $max_bytes = $opts{max_bytes};
412 2 50       4 my $limit_name = defined $max_bytes ? 'max_bytes' : undef;
413 2 50       5 if (!defined $max_bytes) {
414 2         6 my $cl = $self->content_length;
415 2 50       5 if (defined $cl) {
416 0         0 $max_bytes = $cl;
417 0         0 $limit_name = 'content-length';
418             }
419             }
420              
421             return PAGI::Request::BodyStream->new(
422             receive => $self->{receive},
423             max_bytes => $max_bytes,
424             limit_name => $limit_name,
425             decode => $opts{decode},
426             strict => $opts{strict},
427 2         13 );
428             }
429              
430             # Streaming multipart - mutually exclusive with buffered body methods
431             sub multipart_stream {
432 6     6 1 75 my ($self, %opts) = @_;
433             croak "Body already consumed; multipart_stream() not available"
434             if $self->{scope}{'pagi.request.body.read'}
435 6 100 100     207 || $self->{scope}{'pagi.request.body.stream.created'};
436 4 100       11 croak "multipart_stream() requires a multipart/form-data request" unless $self->is_multipart;
437              
438 3   50     6 my $ct = $self->header('content-type') // '';
439 3         15 my ($boundary) = $ct =~ /boundary=([^;\s]+)/;
440 3 50       16 $boundary =~ s/^["']|["']$//g if defined $boundary; # Strip quotes
441 3 50 33     11 croak "No boundary found in Content-Type" unless defined $boundary && length $boundary;
442              
443 3         7 $self->{scope}{'pagi.request.body.stream.created'} = 1; # latch: lock out buffered readers
444              
445 3         489 require PAGI::Request::MultipartStream;
446             return PAGI::Request::MultipartStream->new(
447             receive => $self->{receive},
448             boundary => $boundary,
449 3 50       10 map { defined $opts{$_} ? ($_ => $opts{$_}) : () }
  15         58  
450             qw(max_files max_fields max_field_size max_file_size max_request_body),
451             );
452             }
453              
454             # Read raw body bytes (async, cached in scope)
455 13     13 1 102 async sub body {
456 13         16 my $self = shift;
457              
458             croak "Body streaming already started; buffered helpers unavailable"
459 13 100       131 if $self->{scope}{'pagi.request.body.stream.created'};
460              
461             # Return cached body if already read
462 12 100       24 return $self->{scope}{'pagi.request.body'} if $self->{scope}{'pagi.request.body.read'};
463              
464 11         12 my $receive = $self->{receive};
465 11 50       18 die "No receive callback provided" unless $receive;
466              
467 11         10 my $body = '';
468 11         11 while (1) {
469 13         23 my $message = await $receive->();
470 13 50 33     660 last unless $message && $message->{type};
471 13 100       27 last if $message->{type} eq 'http.disconnect';
472              
473 12   50     40 $body .= $message->{body} // '';
474 12 100       34 last unless $message->{more};
475             }
476              
477 11         15 $self->{scope}{'pagi.request.body'} = $body;
478 11         18 $self->{scope}{'pagi.request.body.read'} = 1;
479 11         33 return $body;
480             }
481              
482             # Read body as decoded UTF-8 text (async)
483             # Options: strict => 1 (croak on invalid UTF-8)
484 1     1 1 9 async sub text {
485 1         2 my ($self, %opts) = @_;
486 1   50     33 my $strict = delete $opts{strict} // 0;
487 1 50       3 croak("Unknown options to text: " . join(', ', keys %opts)) if %opts;
488              
489 1         2 my $body = await $self->body;
490 1         24 return _decode_utf8($body, $strict);
491             }
492              
493             # Parse body as JSON (async, dies on error)
494 2     2 1 16 async sub json {
495 2         3 my $self = shift;
496 2         4 my $body = await $self->body;
497 2         65 return decode_json($body);
498             }
499              
500             # Parse URL-encoded form body (async, returns Hash::MultiValue, cached in scope)
501             # Options: strict => 1 (croak on invalid UTF-8), raw => 1 (skip UTF-8 decoding)
502 8     8 1 183 async sub form_params {
503 8         15 my ($self, %opts) = @_;
504 8   50     34 my $strict = delete $opts{strict} // 0;
505 8   50     22 my $raw = delete $opts{raw} // 0;
506              
507             # Extract multipart options before checking for unknown opts
508 8         8 my %multipart_opts;
509 8         13 for my $key (qw(max_field_size max_file_size spool_threshold max_files max_fields temp_dir)) {
510 48 50       60 $multipart_opts{$key} = delete $opts{$key} if exists $opts{$key};
511             }
512 8 50       13 croak("Unknown options to form_params: " . join(', ', keys %opts)) if %opts;
513              
514 8 50       23 my $cache_key = $raw ? 'pagi.request.form.raw' : ($strict ? 'pagi.request.form.strict' : 'pagi.request.form');
    50          
515              
516             # Return cached if available
517 8 100       21 return $self->{scope}{$cache_key} if $self->{scope}{$cache_key};
518              
519             # For multipart, delegate to uploads handling
520 7 100       15 if ($self->is_multipart) {
521             # Multipart always parses to default cache, then copy
522 3         9 my $form = await $self->_parse_multipart_form(%multipart_opts);
523 2         48 $self->{scope}{$cache_key} = $form;
524 2         7 return $form;
525             }
526              
527             # URL-encoded form
528 4         12 my $body = await $self->body;
529 4         90 my @pairs;
530              
531 4         15 for my $part (split /[&;]/, $body) {
532 6 50       9 next unless length $part;
533 6         13 my ($key, $val) = split /=/, $part, 2;
534 6   50     10 $key //= '';
535 6   50     7 $val //= '';
536              
537             # URL decode (handles + as space)
538 6         9 my $key_decoded = _url_decode($key);
539 6         7 my $val_decoded = _url_decode($val);
540              
541             # UTF-8 decode unless raw mode
542 6 50       13 my $key_final = $raw ? $key_decoded : _decode_utf8($key_decoded, $strict);
543 6 50       169 my $val_final = $raw ? $val_decoded : _decode_utf8($val_decoded, $strict);
544              
545 6         116 push @pairs, $key_final, $val_final;
546             }
547              
548 4         15 $self->{scope}{$cache_key} = Hash::MultiValue->new(@pairs);
549 4         155 return $self->{scope}{$cache_key};
550             }
551              
552             # DEPRECATED: Alias with warning
553 0     0 0 0 async sub form {
554 0         0 my $self = shift;
555 0         0 carp "form() is deprecated; use form_params() instead";
556 0         0 return await $self->form_params(@_);
557             }
558              
559             # Singular accessor for form params
560 0     0 1 0 async sub form_param {
561 0         0 my ($self, $name, %opts) = @_;
562 0         0 my $form = await $self->form_params(%opts);
563 0         0 return $form->get($name);
564             }
565              
566             # Raw form params (no UTF-8 decoding)
567 0     0 1 0 async sub raw_form_params {
568 0         0 my ($self, %opts) = @_;
569 0         0 return await $self->form_params(%opts, raw => 1);
570             }
571              
572             # DEPRECATED: Alias with warning
573 0     0 0 0 async sub raw_form {
574 0         0 my $self = shift;
575 0         0 carp "raw_form() is deprecated; use raw_form_params() instead";
576 0         0 return await $self->raw_form_params(@_);
577             }
578              
579             # Raw singular accessor
580 0     0 1 0 async sub raw_form_param {
581 0         0 my ($self, $name) = @_;
582 0         0 return await $self->form_param($name, raw => 1);
583             }
584              
585             # Parse multipart form (internal, cached in scope)
586 6     6   7 async sub _parse_multipart_form {
587 6         9 my ($self, %opts) = @_;
588              
589             croak "Body streaming already started; buffered helpers unavailable"
590 6 100       118 if $self->{scope}{'pagi.request.body.stream.created'};
591              
592             # Already parsed?
593             return $self->{scope}{'pagi.request.form'}
594 5 0 33     41 if $self->{scope}{'pagi.request.form'} && $self->{scope}{'pagi.request.uploads'};
595              
596             # Extract boundary from content-type
597 5   50     8 my $ct = $self->header('content-type') // '';
598 5         24 my ($boundary) = $ct =~ /boundary=([^;\s]+)/;
599 5 50       23 $boundary =~ s/^["']|["']$//g if $boundary; # Strip quotes
600              
601 5 50       10 die "No boundary found in Content-Type" unless $boundary;
602              
603             my $handler = PAGI::Request::MultiPartHandler->new(
604             boundary => $boundary,
605             receive => $self->{receive},
606             max_field_size => $opts{max_field_size},
607             max_file_size => $opts{max_file_size},
608             spool_threshold => $opts{spool_threshold},
609             max_files => $opts{max_files},
610             max_fields => $opts{max_fields},
611             temp_dir => $opts{temp_dir},
612 5         52 );
613              
614 5         21 my ($form, $uploads) = await $handler->parse;
615              
616 5         570 $self->{scope}{'pagi.request.form'} = $form;
617 5         15 $self->{scope}{'pagi.request.uploads'} = $uploads;
618 5         8 $self->{scope}{'pagi.request.body.read'} = 1; # Body has been consumed
619              
620 5         31 return $form;
621             }
622              
623             # Get all uploads as Hash::MultiValue (cached in scope)
624 4     4 1 11 async sub uploads {
625 4         6 my ($self, %opts) = @_;
626              
627 4 100       17 return $self->{scope}{'pagi.request.uploads'} if $self->{scope}{'pagi.request.uploads'};
628              
629 3 50       8 if ($self->is_multipart) {
630 3         7 await $self->_parse_multipart_form(%opts);
631 3         94 return $self->{scope}{'pagi.request.uploads'};
632             }
633              
634             # Not multipart - return empty
635 0         0 $self->{scope}{'pagi.request.uploads'} = Hash::MultiValue->new();
636 0         0 return $self->{scope}{'pagi.request.uploads'};
637             }
638              
639             # Get single upload by field name
640 2     2 1 8 async sub upload {
641 2         5 my ($self, $name, %opts) = @_;
642 2         4 my $uploads = await $self->uploads(%opts);
643 2         70 return $uploads->get($name);
644             }
645              
646             # Get all uploads for a field name
647 1     1 1 7 async sub upload_all {
648 1         2 my ($self, $name, %opts) = @_;
649 1         3 my $uploads = await $self->uploads(%opts);
650 1         53 return $uploads->get_all($name);
651             }
652              
653             1;
654              
655             __END__