File Coverage

blib/lib/PAGI/Middleware/ConditionalGet.pm
Criterion Covered Total %
statement 82 99 82.8
branch 23 32 71.8
condition 9 26 34.6
subroutine 11 13 84.6
pod 1 1 100.0
total 126 171 73.6


line stmt bran cond sub pod time code
1             package PAGI::Middleware::ConditionalGet;
2              
3 1     1   517 use strict;
  1         2  
  1         32  
4 1     1   4 use warnings;
  1         2  
  1         38  
5 1     1   3 use parent 'PAGI::Middleware';
  1         1  
  1         5  
6 1     1   64 use Future::AsyncAwait;
  1         2  
  1         4  
7              
8             =head1 NAME
9              
10             PAGI::Middleware::ConditionalGet - Conditional GET/HEAD request handling
11              
12             =head1 SYNOPSIS
13              
14             use PAGI::Middleware::Builder;
15              
16             my $app = builder {
17             enable 'ETag'; # Generate ETags
18             enable 'ConditionalGet'; # Handle If-None-Match
19             $my_app;
20             };
21              
22             =head1 DESCRIPTION
23              
24             PAGI::Middleware::ConditionalGet returns 304 Not Modified for GET/HEAD
25             requests when the client's conditional headers match. Supports:
26              
27             - If-None-Match: Compare against ETag header
28             - If-Modified-Since: Compare against Last-Modified header
29              
30             =cut
31              
32             sub wrap {
33 4     4 1 31 my ($self, $app) = @_;
34              
35 4     4   88 return async sub {
36 4         5 my ($scope, $receive, $send) = @_;
37 4 50       25 if ($scope->{type} ne 'http') {
38 0         0 await $app->($scope, $receive, $send);
39 0         0 return;
40             }
41              
42             # Only handle GET and HEAD requests
43 4   50     12 my $method = uc($scope->{method} // '');
44 4 100 66     13 unless ($method eq 'GET' || $method eq 'HEAD') {
45 1         2 await $app->($scope, $receive, $send);
46 1         177 return;
47             }
48              
49             # Get conditional request headers
50 3         7 my $if_none_match = $self->_get_header($scope, 'if-none-match');
51 3         5 my $if_modified_since = $self->_get_header($scope, 'if-modified-since');
52              
53             # No conditional headers? Pass through
54 3 50 33     9 unless (defined $if_none_match || defined $if_modified_since) {
55 0         0 await $app->($scope, $receive, $send);
56 0         0 return;
57             }
58              
59             # Capture response headers
60 3         4 my $response_status;
61             my $response_headers;
62 3         3 my $sent_304 = 0;
63              
64 6         135 my $wrapped_send = async sub {
65 6         7 my ($event) = @_;
66 6 100       20 if ($event->{type} eq 'http.response.start') {
    50          
67 3         5 $response_status = $event->{status};
68 3         6 $response_headers = $event->{headers};
69              
70             # Only handle 2xx responses
71 3 50 33     19 if ($response_status >= 200 && $response_status < 300) {
72 3         9 my $etag = $self->_get_response_header($response_headers, 'etag');
73 3         8 my $last_modified = $self->_get_response_header($response_headers, 'last-modified');
74              
75 3         4 my $not_modified = 0;
76              
77             # Check If-None-Match
78 3 50 33     14 if (defined $if_none_match && defined $etag) {
    0 0        
79 3         9 $not_modified = $self->_etag_matches($if_none_match, $etag);
80             }
81             # Check If-Modified-Since (only if no If-None-Match)
82             elsif (defined $if_modified_since && defined $last_modified) {
83 0         0 $not_modified = $self->_not_modified_since($if_modified_since, $last_modified);
84             }
85              
86 3 100       7 if ($not_modified) {
87             # Send 304 response
88 2         8 my @headers_304 = $self->_filter_headers_for_304($response_headers);
89 2         11 await $send->({
90             type => 'http.response.start',
91             status => 304,
92             headers => \@headers_304,
93             });
94 2         92 await $send->({
95             type => 'http.response.body',
96             body => '',
97             more => 0,
98             });
99 2         63 $sent_304 = 1;
100 2         6 return;
101             }
102             }
103              
104 1         3 await $send->($event);
105             }
106             elsif ($event->{type} eq 'http.response.body') {
107 3 100       6 return if $sent_304; # Skip body if we sent 304
108 1         3 await $send->($event);
109             }
110             else {
111 0         0 await $send->($event);
112             }
113 3         11 };
114              
115 3         6 await $app->($scope, $receive, $wrapped_send);
116 4         19 };
117             }
118              
119             sub _get_header {
120 6     6   12 my ($self, $scope, $name) = @_;
121              
122 6         6 $name = lc($name);
123 6   50     7 for my $h (@{$scope->{headers} // []}) {
  6         14  
124 6 100       19 return $h->[1] if lc($h->[0]) eq $name;
125             }
126 3         4 return;
127             }
128              
129             sub _get_response_header {
130 6     6   11 my ($self, $headers, $name) = @_;
131              
132 6         10 $name = lc($name);
133 6   50     9 for my $h (@{$headers // []}) {
  6         17  
134 12 100       30 return $h->[1] if lc($h->[0]) eq $name;
135             }
136 3         6 return;
137             }
138              
139             sub _etag_matches {
140 3     3   6 my ($self, $if_none_match, $etag) = @_;
141              
142             # Parse If-None-Match which can be comma-separated
143             # ETags can be weak (W/"...") or strong ("...")
144              
145             # Handle * wildcard
146 3 100       7 return 1 if $if_none_match eq '*';
147              
148             # Normalize ETags for comparison (weak comparison)
149             my $normalize = sub {
150 4     4   5 my ($tag) = @_;
151 4         10 $tag =~ s/^\s+//;
152 4         7 $tag =~ s/\s+$//;
153 4         6 $tag =~ s/^W\///i; # Remove weak prefix for comparison
154 4         5 return $tag;
155 2         8 };
156              
157 2         5 my $normalized_etag = $normalize->($etag);
158              
159 2         7 for my $tag (split /\s*,\s*/, $if_none_match) {
160 2         4 my $normalized_tag = $normalize->($tag);
161 2 100       12 return 1 if $normalized_tag eq $normalized_etag;
162             }
163 1         5 return 0;
164             }
165              
166             sub _not_modified_since {
167 0     0   0 my ($self, $if_modified_since, $last_modified) = @_;
168              
169             # Parse HTTP dates and compare
170             # This is a simplified comparison - both should be HTTP-date format
171              
172             my $parse_date = sub {
173 0     0   0 my ($date_str) = @_;
174             # Try to parse common HTTP date formats
175             # RFC 1123: Sun, 06 Nov 1994 08:49:37 GMT
176             # RFC 850: Sunday, 06-Nov-94 08:49:37 GMT
177             # asctime: Sun Nov 6 08:49:37 1994
178              
179 0         0 require HTTP::Date;
180 0         0 return HTTP::Date::str2time($date_str);
181 0         0 };
182              
183 0         0 my $client_time = eval { $parse_date->($if_modified_since) };
  0         0  
184 0         0 my $server_time = eval { $parse_date->($last_modified) };
  0         0  
185              
186 0 0 0     0 return 0 unless defined $client_time && defined $server_time;
187 0         0 return $server_time <= $client_time;
188             }
189              
190             sub _filter_headers_for_304 {
191 2     2   4 my ($self, $headers) = @_;
192              
193             # RFC 7232: 304 response MUST include certain headers
194 2         5 my @allowed = qw(
195             cache-control content-location date etag expires
196             last-modified vary
197             );
198 2         3 my %allowed = map { $_ => 1 } @allowed;
  14         30  
199              
200 2         6 my @filtered;
201 2   50     4 for my $h (@{$headers // []}) {
  2         6  
202 4 100       37 push @filtered, $h if $allowed{lc($h->[0])};
203             }
204 2         12 return @filtered;
205             }
206              
207             1;
208              
209             __END__