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

Hello

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