File Coverage

blib/lib/PAGI/Request/Negotiate.pm
Criterion Covered Total %
statement 80 80 100.0
branch 40 42 95.2
condition 11 18 61.1
subroutine 9 9 100.0
pod 6 6 100.0
total 146 155 94.1


line stmt bran cond sub pod time code
1             package PAGI::Request::Negotiate;
2             $PAGI::Request::Negotiate::VERSION = '0.002001';
3 23     23   146762 use strict;
  23         35  
  23         824  
4 23     23   85 use warnings;
  23         33  
  23         30506  
5              
6              
7             =head1 NAME
8              
9             PAGI::Request::Negotiate - Content negotiation utilities for PAGI
10              
11             =head1 SYNOPSIS
12              
13             use PAGI::Request::Negotiate;
14              
15             # Parse Accept header
16             my @types = PAGI::Request::Negotiate->parse_accept(
17             'text/html, application/json;q=0.9, */*;q=0.1'
18             );
19             # Returns: (['text/html', 1], ['application/json', 0.9], ['*/*', 0.1])
20              
21             # Find best match
22             my $best = PAGI::Request::Negotiate->best_match(
23             ['application/json', 'text/html'],
24             'text/html, application/json;q=0.9'
25             );
26             # Returns: 'text/html'
27              
28             # Check if type is acceptable
29             my $accepts = PAGI::Request::Negotiate->accepts_type(
30             'text/html, application/json',
31             'json'
32             );
33              
34             # Get quality value for type
35             my $quality = PAGI::Request::Negotiate->quality_for_type(
36             'application/json;q=0.9',
37             'json'
38             );
39              
40             =head1 DESCRIPTION
41              
42             PAGI::Request::Negotiate provides utilities for HTTP content negotiation,
43             including parsing Accept headers and finding the best matching content type.
44              
45             This module supports quality values, wildcards (*/*, type/*), and common
46             MIME type shortcuts (json, html, xml, etc.).
47              
48             =head1 CLASS METHODS
49              
50             =cut
51              
52             # Common MIME type shortcuts
53             my %TYPE_SHORTCUTS = (
54             html => 'text/html',
55             text => 'text/plain',
56             txt => 'text/plain',
57             json => 'application/json',
58             xml => 'application/xml',
59             atom => 'application/atom+xml',
60             rss => 'application/rss+xml',
61             css => 'text/css',
62             js => 'application/javascript',
63             png => 'image/png',
64             jpg => 'image/jpeg',
65             jpeg => 'image/jpeg',
66             gif => 'image/gif',
67             svg => 'image/svg+xml',
68             pdf => 'application/pdf',
69             zip => 'application/zip',
70             form => 'application/x-www-form-urlencoded',
71             );
72              
73             =head2 parse_accept
74              
75             my @types = PAGI::Request::Negotiate->parse_accept($header);
76              
77             Parse an Accept header and return a list of arrayrefs containing
78             C<[media_type, quality]> sorted by preference (quality descending,
79             then specificity descending).
80              
81             If no Accept header is provided, returns a single entry for C<*/*> with
82             quality 1.
83              
84             Quality values are clamped to the range [0, 1].
85              
86             Specificity ordering: exact types > type/* > */*
87              
88             =cut
89              
90             sub parse_accept {
91 38     38 1 190696 my ($class, $header) = @_;
92             # No Accept header means accept everything
93 38 100 100     170 return (['*/*', 1]) unless defined $header && length $header;
94              
95 36         58 my @types;
96              
97 36         266 for my $part (split /\s*,\s*/, $header) {
98             # Parse: type/subtype;param=value;q=0.9;ext=value
99 75         240 my ($type, @params) = split /\s*;\s*/, $part;
100 75 50 33     238 next unless defined $type && length $type;
101              
102             # Normalize type
103 75         122 $type = lc($type);
104 75         201 $type =~ s/^\s+//;
105 75         132 $type =~ s/\s+$//;
106              
107             # Extract quality value (default 1)
108 75         97 my $quality = 1;
109 75         124 for my $param (@params) {
110 28 100       140 if ($param =~ /^q\s*=\s*(-?[0-9.]+)$/i) {
111 27         137 $quality = $1 + 0; # Convert to number
112 27 100       58 $quality = 1 if $quality > 1;
113 27 100       56 $quality = 0 if $quality < 0;
114 27         45 last;
115             }
116             }
117              
118 75         227 push @types, [$type, $quality];
119             }
120              
121             # Sort by quality (highest first), then by specificity
122             @types = sort {
123             # Primary: quality (descending)
124 36         192 my $cmp = $b->[1] <=> $a->[1];
  45         89  
125 45 100       116 return $cmp if $cmp;
126              
127             # Secondary: specificity (more specific first)
128             # */* < type/* < type/subtype
129 15         31 my $spec_a = _specificity($a->[0]);
130 15         29 my $spec_b = _specificity($b->[0]);
131 15         41 return $spec_b <=> $spec_a;
132             } @types;
133              
134 36         193 return @types;
135             }
136              
137             # Calculate specificity score for sorting
138             # Returns: 0 for */*, 1 for type/*, 2 for type/subtype
139             sub _specificity {
140 35     35   58 my ($type) = @_;
141 35 100       73 return 0 if $type eq '*/*';
142 32 100       75 return 1 if $type =~ m{^[^/]+/\*$};
143 29         44 return 2;
144             }
145              
146             =head2 best_match
147              
148             my $type = PAGI::Request::Negotiate->best_match(\@supported, $accept_header);
149              
150             Find the best matching content type from C<@supported> based on the
151             Accept header. Returns the best match or undef if none acceptable.
152              
153             C<@supported> can contain full MIME types or shortcuts (html, json, xml, etc.)
154              
155             The returned value is from the C<@supported> array (preserves shortcuts).
156              
157             =cut
158              
159             sub best_match {
160 11     11 1 11216 my ($class, $supported, $accept_header) = @_;
161 11 100 66     62 return unless $supported && @$supported;
162              
163             # Parse Accept header
164 10         28 my @accepted = $class->parse_accept($accept_header);
165              
166             # Normalize supported types (expand shortcuts)
167 10         52 my @normalized = map { $class->normalize_type($_) } @$supported;
  16         36  
168              
169             # Find best match
170 10         21 for my $accepted (@accepted) {
171 14         33 my ($type, $quality) = @$accepted;
172 14 50       27 next if $quality == 0; # Explicitly rejected
173              
174 14         34 for my $i (0 .. $#normalized) {
175 19 100       44 if ($class->type_matches($normalized[$i], $type)) {
176             # Return original (possibly shortcut) type
177 8         75 return $supported->[$i];
178             }
179             }
180             }
181              
182 2         11 return;
183             }
184              
185             =head2 type_matches
186              
187             my $bool = PAGI::Request::Negotiate->type_matches($type, $pattern);
188              
189             Check if a media type matches a pattern. Patterns can include wildcards
190             like C<*/*> or C.
191              
192             Both type and pattern are compared case-insensitively.
193              
194             =cut
195              
196             sub type_matches {
197 71     71 1 8284 my ($class, $type, $pattern) = @_;
198 71         127 $type = lc($type);
199 71         102 $pattern = lc($pattern);
200              
201             # Exact match
202 71 100       183 return 1 if $type eq $pattern;
203              
204             # Wildcard match
205 55 100       116 return 1 if $pattern eq '*/*';
206              
207             # Type wildcard (e.g., text/*)
208 49 100       137 if ($pattern =~ m{^([^/]+)/\*$}) {
209 6         19 my $major = $1;
210 6 100       120 return 1 if $type =~ m{^\Q$major\E/};
211             }
212              
213 45         132 return 0;
214             }
215              
216             =head2 normalize_type
217              
218             my $mime = PAGI::Request::Negotiate->normalize_type($type);
219              
220             Convert a type shortcut to its full MIME type. Known shortcuts include:
221              
222             html => text/html
223             json => application/json
224             xml => application/xml
225             atom => application/atom+xml
226             rss => application/rss+xml
227             text => text/plain
228             txt => text/plain
229             css => text/css
230             js => application/javascript
231             png => image/png
232             jpg => image/jpeg
233             jpeg => image/jpeg
234             gif => image/gif
235             svg => image/svg+xml
236             pdf => application/pdf
237             zip => application/zip
238             form => application/x-www-form-urlencoded
239              
240             If the type is already a MIME type (contains '/'), it's returned as-is.
241              
242             Unknown shortcuts are prefixed with 'application/'.
243              
244             =cut
245              
246             sub normalize_type {
247 54     54 1 6828 my ($class, $type) = @_;
248 54 100       208 return $type if $type =~ m{/};
249 32   66     175 return $TYPE_SHORTCUTS{lc($type)} // "application/$type";
250             }
251              
252             =head2 accepts_type
253              
254             my $bool = PAGI::Request::Negotiate->accepts_type($accept_header, $type);
255              
256             Check if a specific content type is acceptable based on the Accept header.
257              
258             The type can be a full MIME type or a shortcut.
259              
260             Returns false if the type has quality=0 (explicitly rejected).
261              
262             =cut
263              
264             sub accepts_type {
265 14     14 1 8272 my ($class, $accept_header, $type) = @_;
266 14         38 $type = $class->normalize_type($type);
267              
268 14         43 my @accepted = $class->parse_accept($accept_header);
269              
270 14         25 for my $accepted (@accepted) {
271 23         44 my ($pattern, $quality) = @$accepted;
272 23 100       49 next if $quality == 0;
273              
274             # Check both directions: type matches pattern OR pattern matches type
275             # This allows accepts('text/*') to return true if client accepts 'text/html'
276 22 100       49 return 1 if $class->type_matches($type, $pattern);
277 15 100       29 return 1 if $class->type_matches($pattern, $type);
278             }
279              
280 5         21 return 0;
281             }
282              
283             =head2 quality_for_type
284              
285             my $q = PAGI::Request::Negotiate->quality_for_type($accept_header, $type);
286              
287             Get the quality value for a specific type. Returns 0 if not acceptable.
288              
289             When multiple patterns match (e.g., both text/* and text/html), returns
290             the quality of the most specific match.
291              
292             The type can be a full MIME type or a shortcut.
293              
294             =cut
295              
296             sub quality_for_type {
297 4     4 1 6305 my ($class, $accept_header, $type) = @_;
298 4         15 $type = $class->normalize_type($type);
299              
300 4         16 my @accepted = $class->parse_accept($accept_header);
301              
302 4         7 my $best_quality = 0;
303 4         8 my $best_specificity = -1;
304              
305 4         8 for my $accepted (@accepted) {
306 9         22 my ($pattern, $quality) = @$accepted;
307              
308 9 100       31 if ($class->type_matches($type, $pattern)) {
309 5         15 my $spec = _specificity($pattern);
310 5 100 33     20 if ($spec > $best_specificity ||
      66        
311             ($spec == $best_specificity && $quality > $best_quality)) {
312 4         9 $best_quality = $quality;
313 4         10 $best_specificity = $spec;
314             }
315             }
316             }
317              
318 4         21 return $best_quality;
319             }
320              
321             =head1 EXAMPLES
322              
323             =head2 Content Negotiation in a PAGI App
324              
325             use PAGI::Request;
326             use PAGI::Request::Negotiate;
327              
328             async sub app ($scope, $receive, $send) {
329             # Scope headers are an arrayref of [name, value] pairs, not a hash.
330             # Read them through PAGI::Request, not $scope->{headers}{...}.
331             my $req = PAGI::Request->new($scope, $receive);
332             my $accept = $req->header('accept') // '*/*';
333              
334             my $format = PAGI::Request::Negotiate->best_match(
335             ['json', 'html', 'xml'],
336             $accept
337             );
338              
339             my ($body, $content_type);
340             if ($format eq 'json') {
341             $body = '{"message":"Hello"}';
342             $content_type = 'application/json';
343             } elsif ($format eq 'html') {
344             $body = '

Hello

';
345             $content_type = 'text/html';
346             } else {
347             $body = 'Hello';
348             $content_type = 'application/xml';
349             }
350              
351             await $send->({
352             type => 'http.response.start',
353             status => 200,
354             headers => [['content-type', $content_type]],
355             });
356              
357             await $send->({
358             type => 'http.response.body',
359             body => $body,
360             });
361             }
362              
363             =head1 SEE ALSO
364              
365             L, L
366              
367             RFC 7231 Section 5.3 - Content Negotiation
368              
369             =head1 AUTHOR
370              
371             PAGI Contributors
372              
373             =cut
374              
375             1;