File Coverage

lib/PAGI/Test/Client.pm
Criterion Covered Total %
statement 325 343 94.7
branch 124 156 79.4
condition 44 77 57.1
subroutine 38 38 100.0
pod 18 18 100.0
total 549 632 86.8


line stmt bran cond sub pod time code
1             package PAGI::Test::Client;
2              
3 11     11   1508745 use strict;
  11         16  
  11         404  
4 11     11   55 use warnings;
  11         38  
  11         479  
5 11     11   43 use Future::AsyncAwait;
  11         13  
  11         65  
6 11     11   499 use Carp qw(croak);
  11         16  
  11         558  
7              
8 11     11   4182 use PAGI::Test::Response;
  11         27  
  11         67035  
9              
10              
11             sub new {
12 76     76 1 1488497 my ($class, %args) = @_;
13              
14 76 50       205 croak "app is required" unless $args{app};
15              
16             return bless {
17             app => $args{app},
18             headers => $args{headers} // {},
19             cookies => {},
20             lifespan => $args{lifespan} // 0,
21 76   100     1194 raise_app_exceptions => $args{raise_app_exceptions} // 0,
      100        
      100        
22             started => 0,
23             }, $class;
24             }
25              
26 50     50 1 5254 sub get { shift->_request('GET', @_) }
27 2     2 1 199 sub head { shift->_request('HEAD', @_) }
28 1     1 1 185 sub delete { shift->_request('DELETE', @_) }
29 12     12 1 884 sub post { shift->_request('POST', @_) }
30 2     2 1 217 sub put { shift->_request('PUT', @_) }
31 2     2 1 529 sub patch { shift->_request('PATCH', @_) }
32 2     2 1 544 sub options { shift->_request('OPTIONS', @_) }
33              
34             # Cookie management
35             sub cookies {
36 1     1 1 2 my ($self) = @_;
37 1         6 return $self->{cookies};
38             }
39              
40             sub cookie {
41 1     1 1 368 my ($self, $name) = @_;
42 1         5 return $self->{cookies}{$name};
43             }
44              
45             sub set_cookie {
46 1     1 1 5 my ($self, $name, $value) = @_;
47 1         3 $self->{cookies}{$name} = $value;
48 1         1 return $self;
49             }
50              
51             sub clear_cookies {
52 1     1 1 1 my ($self) = @_;
53 1         3 $self->{cookies} = {};
54 1         2 return $self;
55             }
56              
57             sub _request {
58 71     71   161 my ($self, $method, $path, %opts) = @_;
59              
60 71   50     249 $path //= '/';
61              
62             # Handle json option
63 71 100       272 if (exists $opts{json}) {
    100          
    50          
64 5         963 require JSON::MaybeXS;
65 5         16098 $opts{body} = JSON::MaybeXS::encode_json($opts{json});
66 5         20 _set_header(\$opts{headers}, 'Content-Type', 'application/json', 0);
67 5         16 _set_header(\$opts{headers}, 'Content-Length', length($opts{body}), 1);
68             }
69             # Handle form option (supports multi-value)
70             elsif (exists $opts{form}) {
71 6         16 my $pairs = _normalize_pairs($opts{form});
72 6         6 my @encoded;
73 6         9 for my $pair (@$pairs) {
74 14         18 my $key = _url_encode($pair->[0]);
75 14   50     24 my $val = _url_encode($pair->[1] // '');
76 14         24 push @encoded, "$key=$val";
77             }
78 6         20 $opts{body} = join('&', @encoded);
79 6         16 _set_header(\$opts{headers}, 'Content-Type', 'application/x-www-form-urlencoded', 0);
80 6         14 _set_header(\$opts{headers}, 'Content-Length', length($opts{body}), 1);
81             }
82             # Add Content-Length for raw body if not already set
83             elsif (defined $opts{body}) {
84 0         0 _set_header(\$opts{headers}, 'Content-Length', length($opts{body}), 0);
85             }
86              
87             # Build scope
88 71         230 my $scope = $self->_build_scope($method, $path, \%opts);
89              
90             # Build receive (returns request body)
91 69   100     193 my $body = $opts{body} // '';
92 69         73 my $receive_called = 0;
93 22     22   8553 my $receive = async sub {
94 22 50       40 if (!$receive_called) {
95 22         23 $receive_called = 1;
96 22         188 return { type => 'http.request', body => $body, more => 0 };
97             }
98 0         0 return { type => 'http.disconnect' };
99 69         228 };
100              
101             # Build send (captures response)
102 69         70 my @events;
103 115     115   3562 my $send = async sub {
104 115         154 my ($event) = @_;
105 115         266 my %captured = %$event;
106              
107 115 100 50     288 if (($captured{type} // '') eq 'http.response.body') {
108 57 100 66     229 if ($method eq 'HEAD') {
    100          
109 1         2 $captured{body} = '';
110 1         3 delete @captured{qw(fh file offset length)};
111             }
112             elsif (exists $captured{fh} || exists $captured{file}) {
113 5         11 $captured{body} = $self->_response_body_bytes(\%captured);
114 5         13 delete @captured{qw(fh file offset length)};
115             }
116             }
117              
118 115         503 push @events, \%captured;
119 69         217 };
120              
121             # Call app (with exception handling like real server)
122 69         70 my $exception;
123 69         73 eval {
124 69         186 $self->{app}->($scope, $receive, $send)->get;
125             };
126 69 100       3447 if ($@) {
127 12         12 $exception = $@;
128 12 100       18 if ($self->{raise_app_exceptions}) {
129 1         12 die $exception;
130             }
131             # Mimic server behavior: return 500 response
132 11         43 return PAGI::Test::Response->new(
133             status => 500,
134             headers => [['content-type', 'text/plain']],
135             body => 'Internal Server Error',
136             exception => $exception,
137             );
138             }
139              
140             # Check for incomplete response (common async mistake)
141 57         86 my $has_response_start = grep { $_->{type} eq 'http.response.start' } @events;
  114         219  
142 57 100       112 unless ($has_response_start) {
143 1         14 die "App returned without sending response. "
144             . "Did you forget to 'await' your \$send calls? "
145             . "See PAGI::Tutorial section on async patterns.\n";
146             }
147              
148             # Parse response from captured events
149 56         123 return $self->_build_response(\@events);
150             }
151              
152             sub _build_scope {
153 71     71   116 my ($self, $method, $path, $opts) = @_;
154              
155             # Parse query string from path
156 71         132 my $query_string = '';
157 71 100       261 if ($path =~ s/\?(.*)$//) {
158 2         6 $query_string = $1;
159             }
160              
161             # Add query params if provided (appended to path query string)
162 71 100       129 if ($opts->{query}) {
163 6         17 my $pairs = _normalize_pairs($opts->{query});
164 6         8 my @encoded;
165 6         8 for my $pair (@$pairs) {
166 11         44 my $key = _url_encode($pair->[0]);
167 11   50     22 my $val = _url_encode($pair->[1] // '');
168 11         20 push @encoded, "$key=$val";
169             }
170 6         13 my $new_params = join('&', @encoded);
171 6 100       17 $query_string = $query_string ? "$query_string&$new_params" : $new_params;
172             }
173              
174             # Build headers using helper
175 71         219 my $headers = $self->_build_headers($opts->{headers});
176              
177 69         581 my $scope = {
178             type => 'http',
179             pagi => { version => '0.2', spec_version => '0.2' },
180             http_version => '1.1',
181             method => $method,
182             scheme => 'http',
183             path => $path,
184             query_string => $query_string,
185             root_path => '',
186             headers => $headers,
187             client => ['127.0.0.1', 12345],
188             server => ['testserver', 80],
189             };
190              
191             # Add state if lifespan is enabled
192 69 100       168 $scope->{state} = $self->{state} if $self->{state};
193              
194 69         110 return $scope;
195             }
196              
197             sub _build_response {
198 56     56   87 my ($self, $events) = @_;
199              
200 56         70 my $status = 200;
201 56         44 my @headers;
202 56         64 my $body = '';
203 56         55 my $response_started = 0;
204 56         56 my $body_complete = 0;
205              
206 56         80 for my $event (@$events) {
207 114   50     171 my $type = $event->{type} // '';
208              
209 114 100       185 if ($type eq 'http.response.start') {
    50          
210 57 100       96 next if $response_started;
211 56         51 $response_started = 1;
212 56   50     105 $status = $event->{status} // 200;
213 56   50     53 @headers = @{$event->{headers} // []};
  56         155  
214             }
215             elsif ($type eq 'http.response.body') {
216 57 50       73 next unless $response_started;
217 57 100       80 next if $body_complete;
218              
219 56         109 $body .= $self->_response_body_bytes($event);
220              
221 56   100     151 my $more = $event->{more} // 0;
222 56 50       128 $body_complete = 1 unless $more;
223             }
224             }
225              
226             # Extract Set-Cookie headers and store cookies
227 56         66 for my $h (@headers) {
228 57 100       117 if (lc($h->[0]) eq 'set-cookie') {
229 1 50       8 if ($h->[1] =~ /^([^=]+)=([^;]*)/) {
230 1         4 $self->{cookies}{$1} = $2;
231             }
232             }
233             }
234              
235 56         234 return PAGI::Test::Response->new(
236             status => $status,
237             headers => \@headers,
238             body => $body,
239             );
240             }
241              
242             sub _response_body_bytes {
243 61     61   73 my ($self, $event) = @_;
244              
245 61 100 50     196 return $event->{body} // '' if exists $event->{body};
246              
247 5 100       8 if (exists $event->{file}) {
248             return _read_file_bytes(
249             $event->{file},
250             $event->{offset} // 0,
251             $event->{length},
252 2   100     10 );
253             }
254              
255 3 50       10 if (exists $event->{fh}) {
256             return _read_fh_bytes(
257             $event->{fh},
258             $event->{offset} // 0,
259             $event->{length},
260 3   100     14 );
261             }
262              
263 0         0 return '';
264             }
265              
266             sub _read_file_bytes {
267 2     2   4 my ($path, $offset, $length) = @_;
268              
269 2 50       70 open my $fh, '<:raw', $path
270             or croak "Cannot open file response '$path': $!";
271              
272 2 100 33     10 seek($fh, $offset, 0)
273             or croak "Cannot seek file response '$path': $!"
274             if $offset;
275              
276 2         5 my $content = _slurp_fh_bytes($fh, $length);
277 2         17 close $fh;
278              
279 2         10 return $content;
280             }
281              
282             sub _read_fh_bytes {
283 3     3   6 my ($fh, $offset, $length) = @_;
284              
285 3 100 33     8 seek($fh, $offset, 0)
286             or croak "Cannot seek filehandle response: $!"
287             if $offset;
288              
289 3         25 return _slurp_fh_bytes($fh, $length);
290             }
291              
292             sub _slurp_fh_bytes {
293 5     5   22 my ($fh, $length) = @_;
294              
295 5         7 my $content = '';
296 5         4 my $remaining = $length;
297              
298 5         18 while (1) {
299 10         9 my $to_read = 65536;
300 10 100       12 if (defined $remaining) {
301 4 100       9 last if $remaining <= 0;
302 2 50       4 $to_read = $remaining if $remaining < $to_read;
303             }
304              
305 8         43 my $bytes_read = read($fh, my $chunk, $to_read);
306 8 50       12 croak "Cannot read response body from filehandle: $!"
307             unless defined $bytes_read;
308 8 100       11 last if $bytes_read == 0;
309              
310 5         8 $content .= $chunk;
311 5 100       8 $remaining -= $bytes_read if defined $remaining;
312             }
313              
314 5         13 return $content;
315             }
316              
317             sub websocket {
318 14     14 1 1596 my ($self, $path, @rest) = @_;
319              
320 14         2854 require PAGI::Test::WebSocket;
321              
322             # Handle both: websocket($path, $callback) and websocket($path, %opts)
323             # and websocket($path, %opts, $callback)
324 14         48 my ($callback, %opts);
325 14 100 66     116 if (@rest == 1 && ref($rest[0]) eq 'CODE') {
    100 33        
    50          
326 8         15 $callback = $rest[0];
327             } elsif (@rest % 2 == 0) {
328 4         6 %opts = @rest;
329             } elsif (@rest % 2 == 1 && ref($rest[-1]) eq 'CODE') {
330 2         3 $callback = pop @rest;
331 2         5 %opts = @rest;
332             }
333              
334 14   50     26 $path //= '/';
335              
336             # Parse query string from path
337 14         20 my $query_string = '';
338 14 100       45 if ($path =~ s/\?(.*)$//) {
339 1         3 $query_string = $1;
340             }
341              
342             # Build headers
343 14         30 my @headers = (['host', 'testserver']);
344              
345             # Add client default headers (normalized)
346 14         47 my $default_pairs = _normalize_pairs($self->{headers});
347 14         23 for my $pair (@$default_pairs) {
348 0         0 push @headers, [lc($pair->[0]), $pair->[1]];
349             }
350              
351             # Add request-specific headers (normalized, replace by key)
352 14 100       86 if ($opts{headers}) {
353 2         4 my $request_pairs = _normalize_pairs($opts{headers});
354 2         3 my %replace_keys = map { lc($_->[0]) => 1 } @$request_pairs;
  3         9  
355              
356             # Filter out replaced headers from existing
357 2         3 @headers = grep { !$replace_keys{$_->[0]} } @headers;
  2         7  
358              
359             # Add request headers
360 2         2 for my $pair (@$request_pairs) {
361 3         9 push @headers, [lc($pair->[0]), $pair->[1]];
362             }
363             }
364              
365             # Add cookies
366 14 50       19 if (keys %{$self->{cookies}}) {
  14         36  
367 0         0 my $cookie = join('; ', map { "$_=$self->{cookies}{$_}" } sort keys %{$self->{cookies}});
  0         0  
  0         0  
368 0         0 push @headers, ['cookie', $cookie];
369             }
370              
371             my $scope = {
372             type => 'websocket',
373             pagi => { version => '0.2', spec_version => '0.2' },
374             http_version => '1.1',
375             scheme => 'ws',
376             path => $path,
377             query_string => $query_string,
378             root_path => '',
379             headers => \@headers,
380             client => ['127.0.0.1', 12345],
381             server => ['testserver', 80],
382 14   50     181 subprotocols => $opts{subprotocols} // [],
383             };
384              
385 14 100       35 $scope->{state} = $self->{state} if $self->{state};
386              
387 14         60 my $ws = PAGI::Test::WebSocket->new(app => $self->{app}, scope => $scope);
388 14         36 $ws->_start;
389              
390 14 100       28 if ($callback) {
391 10         10 eval { $callback->($ws) };
  10         22  
392 10         3845 my $err = $@;
393 10 100       25 $ws->close unless $ws->is_closed;
394 10 50       17 die $err if $err;
395 10         119 return;
396             }
397              
398 4         8 return $ws;
399             }
400              
401             sub sse {
402 12     12 1 1413 my ($self, $path, @rest) = @_;
403              
404 12         2562 require PAGI::Test::SSE;
405              
406             # Handle both: sse($path, $callback) and sse($path, %opts)
407             # and sse($path, %opts, $callback)
408 12         25 my ($callback, %opts);
409 12 100 66     97 if (@rest == 1 && ref($rest[0]) eq 'CODE') {
    100 33        
    50          
410 6         11 $callback = $rest[0];
411             } elsif (@rest % 2 == 0) {
412 1         1 %opts = @rest;
413             } elsif (@rest % 2 == 1 && ref($rest[-1]) eq 'CODE') {
414 5         10 $callback = pop @rest;
415 5         12 %opts = @rest;
416             }
417              
418 12   50     29 $path //= '/';
419              
420             # Parse query string from path
421 12         20 my $query_string = '';
422 12 50       52 if ($path =~ s/\?(.*)$//) {
423 0         0 $query_string = $1;
424             }
425              
426             # Build headers (SSE requires Accept: text/event-stream)
427 12         40 my @headers = (
428             ['host', 'testserver'],
429             ['accept', 'text/event-stream'],
430             );
431              
432             # Add client default headers (normalized)
433 12         45 my $default_pairs = _normalize_pairs($self->{headers});
434 12         26 for my $pair (@$default_pairs) {
435 0         0 push @headers, [lc($pair->[0]), $pair->[1]];
436             }
437              
438             # Add request-specific headers (normalized, replace by key)
439 12 100       29 if ($opts{headers}) {
440 2         4 my $request_pairs = _normalize_pairs($opts{headers});
441 2         4 my %replace_keys = map { lc($_->[0]) => 1 } @$request_pairs;
  3         11  
442              
443             # Filter out replaced headers from existing
444 2         32 @headers = grep { !$replace_keys{$_->[0]} } @headers;
  4         30  
445              
446             # Add request headers
447 2         4 for my $pair (@$request_pairs) {
448 3         9 push @headers, [lc($pair->[0]), $pair->[1]];
449             }
450             }
451              
452             # Add cookies
453 12 50       15 if (keys %{$self->{cookies}}) {
  12         37  
454 0         0 my $cookie = join('; ', map { "$_=$self->{cookies}{$_}" } sort keys %{$self->{cookies}});
  0         0  
  0         0  
455 0         0 push @headers, ['cookie', $cookie];
456             }
457              
458             # SSE supports all HTTP methods (GET is default, but POST/PUT work with
459             # modern libraries like fetch-event-source used by htmx4, datastar, etc.)
460 12   100     73 my $method = uc($opts{method} // 'GET');
461              
462 12         148 my $scope = {
463             type => 'sse',
464             pagi => { version => '0.2', spec_version => '0.2' },
465             http_version => '1.1',
466             method => $method,
467             scheme => 'http',
468             path => $path,
469             query_string => $query_string,
470             root_path => '',
471             headers => \@headers,
472             client => ['127.0.0.1', 12345],
473             server => ['testserver', 80],
474             };
475              
476 12 100       35 $scope->{state} = $self->{state} if $self->{state};
477              
478 12         59 my $sse = PAGI::Test::SSE->new(app => $self->{app}, scope => $scope);
479 12         34 $sse->_start;
480              
481 12 100       69 if ($callback) {
482 11         17 eval { $callback->($sse) };
  11         32  
483 11         3577 my $err = $@;
484 11 50       36 $sse->close unless $sse->is_closed;
485 11 50       28 die $err if $err;
486 11         178 return;
487             }
488              
489 1         3 return $sse;
490             }
491              
492             sub start {
493 7     7 1 26 my ($self) = @_;
494 7 50       25 return $self if $self->{started};
495 7 50       18 return $self unless $self->{lifespan};
496              
497 7         14 $self->{state} = {};
498              
499             my $scope = {
500             type => 'lifespan',
501             pagi => { version => '0.2', spec_version => '0.2' },
502             state => $self->{state},
503 7         48 };
504              
505 7         38 my $phase = 'startup';
506 7         10 my $pending_future;
507              
508 14     14   113 my $receive = async sub {
509 14 100       38 if ($phase eq 'startup') {
510 7         12 $phase = 'running';
511 7         47 return { type => 'lifespan.startup' };
512             }
513             # Wait for shutdown
514 7         16 $pending_future = Future->new;
515 7         42 return await $pending_future;
516 7         21 };
517              
518 7         11 my $startup_complete = 0;
519 14     14   518 my $send = async sub {
520 14         23 my ($event) = @_;
521 14 100       82 if ($event->{type} eq 'lifespan.startup.complete') {
    50          
522 7         22 $startup_complete = 1;
523             }
524             elsif ($event->{type} eq 'lifespan.shutdown.complete') {
525             # Done
526             }
527 7         22 };
528              
529 7         18 $self->{lifespan_pending} = \$pending_future;
530 7         18 $self->{lifespan_future} = $self->{app}->($scope, $receive, $send);
531              
532             # Pump until startup complete
533 7         901 my $deadline = time + 5;
534 7   33     26 while (!$startup_complete && time < $deadline) {
535             # Just yield - the async code runs synchronously in our setup
536             }
537              
538 7         10 $self->{started} = 1;
539 7         15 return $self;
540             }
541              
542             sub stop {
543 7     7 1 361 my ($self) = @_;
544 7 50       75 return $self unless $self->{started};
545 7 50       21 return $self unless $self->{lifespan};
546              
547             # Resolve the pending future with shutdown event
548 7 50 33     22 if ($self->{lifespan_pending} && ${$self->{lifespan_pending}}) {
  7         29  
549 7         10 ${$self->{lifespan_pending}}->done({ type => 'lifespan.shutdown' });
  7         40  
550             }
551              
552 7         1352 $self->{started} = 0;
553 7         16 return $self;
554             }
555              
556 2   50 2 1 798 sub state { shift->{state} // {} }
557              
558             sub run {
559 3     3 1 2390 my ($class, $app, $callback) = @_;
560              
561 3         13 my $client = $class->new(app => $app, lifespan => 1);
562 3         12 $client->start;
563              
564 3         3 eval { $callback->($client) };
  3         9  
565 3         2183 my $err = $@;
566              
567 3         17 $client->stop;
568 3 50       378 die $err if $err;
569             }
570              
571             sub _url_encode {
572 50     50   58 my ($str) = @_;
573 50         66 $str =~ s/([^A-Za-z0-9_\-.])/sprintf("%%%02X", ord($1))/eg;
  5         20  
574 50         51 return $str;
575             }
576              
577             # Normalize various input formats to arrayref of [key, value] pairs.
578             # Supports:
579             # - Hash with scalar values: { key => 'value' }
580             # Set a header on a headers structure (hashref or arrayref of pairs).
581             # If $replace is true, replaces existing value. Otherwise only sets if not present.
582             sub _set_header {
583 22     22   33 my ($headers_ref, $name, $value, $replace) = @_;
584 22   50     32 $replace //= 0;
585              
586 22 100       56 if (!defined $$headers_ref) {
587 9         44 $$headers_ref = { $name => $value };
588 9         12 return;
589             }
590              
591 13 100       42 if (ref($$headers_ref) eq 'HASH') {
    50          
592 9 50       14 if ($replace) {
593 9         28 $$headers_ref->{$name} = $value;
594             } else {
595 0   0     0 $$headers_ref->{$name} //= $value;
596             }
597             } elsif (ref($$headers_ref) eq 'ARRAY') {
598             # Check if header already exists (case-insensitive)
599 4         3 my $found_idx;
600 4         4 for my $i (0 .. $#{$$headers_ref}) {
  4         8  
601 8 50       15 if (lc($$headers_ref->[$i][0]) eq lc($name)) {
602 0         0 $found_idx = $i;
603 0         0 last;
604             }
605             }
606 4 50       6 if (defined $found_idx) {
607 0 0       0 $$headers_ref->[$found_idx][1] = $value if $replace;
608             } else {
609 4         5 push @{$$headers_ref}, [$name, $value];
  4         8  
610             }
611             }
612             }
613              
614             # - Hash with arrayref values: { key => ['v1', 'v2'] }
615             # - Arrayref of pairs: [['key', 'v1'], ['key', 'v2']]
616             # Returns arrayref of [key, value] pairs.
617             sub _normalize_pairs {
618 184     184   217 my ($input) = @_;
619 184 100       350 return [] unless defined $input;
620              
621             # Arrayref of pairs: [['key', 'val'], ['key', 'val2']]
622 131 100       239 if (ref($input) eq 'ARRAY') {
623             # Validate it looks like pairs
624 6         8 for my $pair (@$input) {
625 17 100 66     156 croak "Expected arrayref of [key, value] pairs"
626             unless ref($pair) eq 'ARRAY' && @$pair == 2;
627             }
628 5         7 return $input;
629             }
630              
631             # Hash (with scalar or arrayref values)
632 125 100       207 if (ref($input) eq 'HASH') {
633 124         108 my @pairs;
634 124         307 for my $key (sort keys %$input) {
635 43         49 my $val = $input->{$key};
636 43 100       52 if (ref($val) eq 'ARRAY') {
637             # Multiple values for this key
638 10         32 push @pairs, [$key, $_] for @$val;
639             } else {
640             # Single value
641 33   50     67 push @pairs, [$key, $val // ''];
642             }
643             }
644 124         213 return \@pairs;
645             }
646              
647 1         189 croak "Expected hashref or arrayref of pairs, got " . ref($input);
648             }
649              
650             # Build headers array, merging defaults with request-specific headers.
651             # Request headers replace client defaults by key (case-insensitive).
652             sub _build_headers {
653 71     71   153 my ($self, $request_headers) = @_;
654              
655 71         78 my @headers;
656              
657             # Default headers
658 71         200 push @headers, ['host', 'testserver'];
659              
660             # Normalize client default headers
661 71         183 my $default_pairs = _normalize_pairs($self->{headers});
662              
663             # Normalize request-specific headers
664 71         111 my $request_pairs = _normalize_pairs($request_headers);
665              
666             # Build set of keys to replace (lowercase)
667 69         115 my %replace_keys;
668 69         92 for my $pair (@$request_pairs) {
669 35         59 $replace_keys{lc($pair->[0])} = 1;
670             }
671              
672             # Add client defaults (skip if being replaced)
673 69         83 for my $pair (@$default_pairs) {
674             push @headers, [lc($pair->[0]), $pair->[1]]
675 6 100       14 unless $replace_keys{lc($pair->[0])};
676             }
677              
678             # Add request-specific headers
679 69         88 for my $pair (@$request_pairs) {
680 35         60 push @headers, [lc($pair->[0]), $pair->[1]];
681             }
682              
683             # Add cookies
684 69 100       61 if (keys %{$self->{cookies}}) {
  69         158  
685 2         3 my $cookie = join('; ', map { "$_=$self->{cookies}{$_}" } sort keys %{$self->{cookies}});
  2         7  
  2         4  
686 2         5 push @headers, ['cookie', $cookie];
687             }
688              
689 69         172 return \@headers;
690             }
691              
692             1;
693              
694             __END__