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.002000';
3 23     23   144712 use strict;
  23         31  
  23         762  
4 23     23   94 use warnings;
  23         34  
  23         29515  
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 191973 my ($class, $header) = @_;
92             # No Accept header means accept everything
93 38 100 100     186 return (['*/*', 1]) unless defined $header && length $header;
94              
95 36         70 my @types;
96              
97 36         252 for my $part (split /\s*,\s*/, $header) {
98             # Parse: type/subtype;param=value;q=0.9;ext=value
99 75         210 my ($type, @params) = split /\s*;\s*/, $part;
100 75 50 33     204 next unless defined $type && length $type;
101              
102             # Normalize type
103 75         111 $type = lc($type);
104 75         155 $type =~ s/^\s+//;
105 75         109 $type =~ s/\s+$//;
106              
107             # Extract quality value (default 1)
108 75         96 my $quality = 1;
109 75         101 for my $param (@params) {
110 28 100       120 if ($param =~ /^q\s*=\s*(-?[0-9.]+)$/i) {
111 27         131 $quality = $1 + 0; # Convert to number
112 27 100       51 $quality = 1 if $quality > 1;
113 27 100       50 $quality = 0 if $quality < 0;
114 27         38 last;
115             }
116             }
117              
118 75         179 push @types, [$type, $quality];
119             }
120              
121             # Sort by quality (highest first), then by specificity
122             @types = sort {
123             # Primary: quality (descending)
124 36         137 my $cmp = $b->[1] <=> $a->[1];
  45         92  
125 45 100       207 return $cmp if $cmp;
126              
127             # Secondary: specificity (more specific first)
128             # */* < type/* < type/subtype
129 15         35 my $spec_a = _specificity($a->[0]);
130 15         31 my $spec_b = _specificity($b->[0]);
131 15         43 return $spec_b <=> $spec_a;
132             } @types;
133              
134 36         89 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   63 my ($type) = @_;
141 35 100       67 return 0 if $type eq '*/*';
142 32 100       71 return 1 if $type =~ m{^[^/]+/\*$};
143 29         69 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 11424 my ($class, $supported, $accept_header) = @_;
161 11 100 66     80 return unless $supported && @$supported;
162              
163             # Parse Accept header
164 10         23 my @accepted = $class->parse_accept($accept_header);
165              
166             # Normalize supported types (expand shortcuts)
167 10         19 my @normalized = map { $class->normalize_type($_) } @$supported;
  16         39  
168              
169             # Find best match
170 10         16 for my $accepted (@accepted) {
171 14         31 my ($type, $quality) = @$accepted;
172 14 50       25 next if $quality == 0; # Explicitly rejected
173              
174 14         55 for my $i (0 .. $#normalized) {
175 19 100       37 if ($class->type_matches($normalized[$i], $type)) {
176             # Return original (possibly shortcut) type
177 8         45 return $supported->[$i];
178             }
179             }
180             }
181              
182 2         10 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 6867 my ($class, $type, $pattern) = @_;
198 71         93 $type = lc($type);
199 71         89 $pattern = lc($pattern);
200              
201             # Exact match
202 71 100       150 return 1 if $type eq $pattern;
203              
204             # Wildcard match
205 55 100       97 return 1 if $pattern eq '*/*';
206              
207             # Type wildcard (e.g., text/*)
208 49 100       114 if ($pattern =~ m{^([^/]+)/\*$}) {
209 6         14 my $major = $1;
210 6 100       115 return 1 if $type =~ m{^\Q$major\E/};
211             }
212              
213 45         87 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 5628 my ($class, $type) = @_;
248 54 100       178 return $type if $type =~ m{/};
249 32   66     173 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 6714 my ($class, $accept_header, $type) = @_;
266 14         26 $type = $class->normalize_type($type);
267              
268 14         30 my @accepted = $class->parse_accept($accept_header);
269              
270 14         19 for my $accepted (@accepted) {
271 23         39 my ($pattern, $quality) = @$accepted;
272 23 100       46 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       37 return 1 if $class->type_matches($type, $pattern);
277 15 100       24 return 1 if $class->type_matches($pattern, $type);
278             }
279              
280 5         17 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 4999 my ($class, $accept_header, $type) = @_;
298 4         12 $type = $class->normalize_type($type);
299              
300 4         12 my @accepted = $class->parse_accept($accept_header);
301              
302 4         6 my $best_quality = 0;
303 4         5 my $best_specificity = -1;
304              
305 4         7 for my $accepted (@accepted) {
306 9         14 my ($pattern, $quality) = @$accepted;
307              
308 9 100       47 if ($class->type_matches($type, $pattern)) {
309 5         10 my $spec = _specificity($pattern);
310 5 100 33     14 if ($spec > $best_specificity ||
      66        
311             ($spec == $best_specificity && $quality > $best_quality)) {
312 4         8 $best_quality = $quality;
313 4         5 $best_specificity = $spec;
314             }
315             }
316             }
317              
318 4         13 return $best_quality;
319             }
320              
321             =head1 EXAMPLES
322              
323             =head2 Content Negotiation in a PAGI App
324              
325             use PAGI::Request::Negotiate;
326              
327             async sub app ($scope, $receive, $send) {
328             my $accept = $scope->{headers}{accept} // '*/*';
329              
330             my $format = PAGI::Request::Negotiate->best_match(
331             ['json', 'html', 'xml'],
332             $accept
333             );
334              
335             my ($body, $content_type);
336             if ($format eq 'json') {
337             $body = '{"message":"Hello"}';
338             $content_type = 'application/json';
339             } elsif ($format eq 'html') {
340             $body = '

Hello

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