File Coverage

lib/PAGI/Middleware/ContentLength.pm
Criterion Covered Total %
statement 64 67 95.5
branch 19 22 86.3
condition 20 27 74.0
subroutine 7 7 100.0
pod 1 1 100.0
total 111 124 89.5


line stmt bran cond sub pod time code
1             package PAGI::Middleware::ContentLength;
2              
3 2     2   213908 use strict;
  2         3  
  2         85  
4 2     2   7 use warnings;
  2         7  
  2         99  
5 2     2   364 use parent 'PAGI::Middleware';
  2         328  
  2         12  
6 2     2   102 use Future::AsyncAwait;
  2         4  
  2         7  
7              
8             =head1 NAME
9              
10             PAGI::Middleware::ContentLength - Auto Content-Length header middleware
11              
12             =head1 SYNOPSIS
13              
14             use PAGI::Middleware::Builder;
15              
16             my $app = builder {
17             enable 'ContentLength';
18             $my_app;
19             };
20              
21             =head1 DESCRIPTION
22              
23             PAGI::Middleware::ContentLength automatically adds a Content-Length header
24             to responses that don't already have one. It buffers the response body
25             to calculate the length, then sends the complete response.
26              
27             This middleware is useful when the application doesn't know the body
28             length upfront, but you want to avoid chunked encoding.
29              
30             =head1 CONFIGURATION
31              
32             =over 4
33              
34             =item * auto_chunked (default: 0)
35              
36             If true, skip adding Content-Length and let chunked encoding be used instead.
37             This is useful for large responses where buffering would be expensive.
38              
39             =back
40              
41             =cut
42              
43             sub _init {
44 7     7   11 my ($self, $config) = @_;
45              
46 7   100     31 $self->{auto_chunked} = $config->{auto_chunked} // 0;
47             }
48              
49             sub wrap {
50 7     7 1 50 my ($self, $app) = @_;
51              
52 7     7   185 return async sub {
53 7         13 my ($scope, $receive, $send) = @_;
54             # Skip for non-HTTP requests
55 7 100       17 if ($scope->{type} ne 'http') {
56 1         3 await $app->($scope, $receive, $send);
57 1         68 return;
58             }
59              
60 6         4 my @buffered_events;
61 6         6 my $has_content_length = 0;
62 6         7 my $is_streaming = 0;
63 6         6 my $status;
64             my @headers;
65              
66             # Create intercepting send to buffer response
67 13         285 my $wrapped_send = async sub {
68 13         15 my ($event) = @_;
69 13         19 my $type = $event->{type};
70              
71 13 100       18 if ($type eq 'http.response.start') {
    50          
72 6         15 $status = $event->{status};
73 6   50     5 @headers = @{$event->{headers} // []};
  6         18  
74              
75             # Check if Content-Length already present
76 6         9 for my $h (@headers) {
77 6 100       13 if (lc($h->[0]) eq 'content-length') {
78 1         2 $has_content_length = 1;
79 1         2 last;
80             }
81             # If Transfer-Encoding is chunked, don't add Content-Length
82 5 50 33     14 if (lc($h->[0]) eq 'transfer-encoding' && lc($h->[1]) eq 'chunked') {
83 0         0 $is_streaming = 1;
84 0         0 last;
85             }
86             }
87              
88             # If already has Content-Length or is streaming, pass through
89 6 100 66     26 if ($has_content_length || $is_streaming || $self->{auto_chunked}) {
      100        
90 2         5 await $send->($event);
91 2         83 return;
92             }
93              
94             # Buffer the start event to add Content-Length later
95 4         41 push @buffered_events, $event;
96             }
97             elsif ($type eq 'http.response.body') {
98             # If we're passing through (has Content-Length or streaming)
99 7 100 100     27 if ($has_content_length || $is_streaming || $self->{auto_chunked}) {
      100        
100 3         4 await $send->($event);
101 3         71 return;
102             }
103              
104             # Check if this is a streaming response (more => 1)
105 4 100       7 if ($event->{more}) {
106 1         2 $is_streaming = 1;
107              
108             # Flush buffered events and switch to pass-through
109 1         2 for my $buffered (@buffered_events) {
110 1         2 await $send->($buffered);
111             }
112 1         28 @buffered_events = ();
113 1         2 await $send->($event);
114 1         24 return;
115             }
116              
117             # Buffer body events
118 3         8 push @buffered_events, $event;
119             }
120             else {
121             # Pass through other events (trailers, etc.)
122 0         0 await $send->($event);
123             }
124 6         20 };
125              
126             # Run the inner app
127 6         13 await $app->($scope, $receive, $wrapped_send);
128              
129             # If we have buffered events, calculate Content-Length and send
130 6 50 66     326 if (@buffered_events && !$has_content_length && !$is_streaming) {
      66        
131             # Calculate total body length
132 3         4 my $body_length = 0;
133 3         4 for my $event (@buffered_events) {
134 6 100       11 if ($event->{type} eq 'http.response.body') {
135 3   50     9 $body_length += length($event->{body} // '');
136             }
137             }
138              
139             # Send start with Content-Length
140 3         3 for my $event (@buffered_events) {
141 6 100       83 if ($event->{type} eq 'http.response.start') {
142 3         3 push @{$event->{headers}}, ['content-length', $body_length];
  3         8  
143             }
144 6         9 await $send->($event);
145             }
146             }
147 7         31 };
148             }
149              
150             1;
151              
152             __END__