File Coverage

blib/lib/PAGI/Middleware/ContentNegotiation.pm
Criterion Covered Total %
statement 73 75 97.3
branch 15 20 75.0
condition 13 17 76.4
subroutine 11 11 100.0
pod 1 1 100.0
total 113 124 91.1


line stmt bran cond sub pod time code
1             package PAGI::Middleware::ContentNegotiation;
2              
3 1     1   490 use strict;
  1         2  
  1         55  
4 1     1   5 use warnings;
  1         1  
  1         42  
5 1     1   4 use parent 'PAGI::Middleware';
  1         1  
  1         4  
6 1     1   45 use Future::AsyncAwait;
  1         2  
  1         5  
7              
8             =head1 NAME
9              
10             PAGI::Middleware::ContentNegotiation - HTTP content negotiation middleware
11              
12             =head1 SYNOPSIS
13              
14             use PAGI::Middleware::Builder;
15              
16             my $app = builder {
17             enable 'ContentNegotiation',
18             supported_types => ['application/json', 'text/html', 'text/plain'],
19             default_type => 'application/json';
20             $my_app;
21             };
22              
23             # In your app:
24             async sub app {
25             my ($scope, $receive, $send) = @_;
26              
27             my $preferred = $scope->{'pagi.preferred_content_type'};
28             if ($preferred eq 'application/json') {
29             # Return JSON
30             } else {
31             # Return HTML
32             }
33             }
34              
35             =head1 DESCRIPTION
36              
37             PAGI::Middleware::ContentNegotiation parses the Accept header and determines
38             the best content type to return. It adds the preferred type to the scope
39             for the application to use.
40              
41             =head1 CONFIGURATION
42              
43             =over 4
44              
45             =item * supported_types (required)
46              
47             Array of MIME types the application supports.
48              
49             =item * default_type (optional)
50              
51             Default type when no Accept header or no match. Defaults to first supported type.
52              
53             =item * strict (default: 0)
54              
55             If true, return 406 Not Acceptable when no supported type matches.
56              
57             =back
58              
59             =cut
60              
61             sub _init {
62 6     6   12 my ($self, $config) = @_;
63              
64             $self->{supported_types} = $config->{supported_types}
65 6   50     17 // die "ContentNegotiation requires 'supported_types' option";
66             $self->{default_type} = $config->{default_type}
67 6   66     25 // $self->{supported_types}[0];
68 6   100     47 $self->{strict} = $config->{strict} // 0;
69             }
70              
71             sub wrap {
72 6     6 1 45 my ($self, $app) = @_;
73              
74 6     6   150 return async sub {
75 6         12 my ($scope, $receive, $send) = @_;
76 6 50       15 if ($scope->{type} ne 'http') {
77 0         0 await $app->($scope, $receive, $send);
78 0         0 return;
79             }
80              
81             # Parse Accept header
82 6   100     17 my $accept = $self->_get_header($scope, 'accept') // '*/*';
83 6         13 my $preferred = $self->_negotiate($accept);
84              
85 6 100 100     14 if (!$preferred && $self->{strict}) {
86 1         4 await $self->_send_not_acceptable($send);
87 1         46 return;
88             }
89              
90 5   66     12 $preferred //= $self->{default_type};
91              
92             # Add preferred type to scope
93 5         10 my @accepted = $self->_parse_accept($accept);
94 5         24 my $new_scope = {
95             %$scope,
96             'pagi.preferred_content_type' => $preferred,
97             'pagi.accepted_types' => \@accepted,
98             };
99              
100 5         15 await $app->($new_scope, $receive, $send);
101 6         24 };
102             }
103              
104             sub _negotiate {
105 6     6   10 my ($self, $accept) = @_;
106              
107 6         15 my @accepted = $self->_parse_accept($accept);
108 6 50       13 return unless @accepted;
109              
110 6         7 for my $item (@accepted) {
111 6         9 my $type = $item->{type};
112              
113             # Check for exact match
114 6         8 for my $supported (@{$self->{supported_types}}) {
  6         11  
115 12 100       28 return $supported if lc($type) eq lc($supported);
116             }
117              
118             # Check for wildcard matches
119 5 100       15 if ($type eq '*/*') {
120 2         8 return $self->{supported_types}[0];
121             }
122              
123 3 100       14 if ($type =~ m{^([^/]+)/\*$}) {
124 1         3 my $major = lc($1);
125 1         1 for my $supported (@{$self->{supported_types}}) {
  1         3  
126 2 100       28 return $supported if $supported =~ m{^$major/}i;
127             }
128             }
129             }
130              
131 2         6 return;
132             }
133              
134             sub _parse_accept {
135 11     11   16 my ($self, $accept) = @_;
136              
137 11         11 my @items;
138              
139 11         43 for my $part (split /\s*,\s*/, $accept) {
140 13         30 my ($type, @params) = split /\s*;\s*/, $part;
141 13 50       23 next unless $type;
142              
143 13         14 my $q = 1.0;
144 13         16 for my $param (@params) {
145 2 50       9 if ($param =~ /^q\s*=\s*([0-9.]+)$/i) {
146 2         9 $q = $1 + 0;
147 2         3 last;
148             }
149             }
150              
151 13         35 push @items, { type => $type, q => $q };
152             }
153              
154             # Sort by quality value, descending
155 11         24 @items = sort { $b->{q} <=> $a->{q} } @items;
  2         7  
156              
157 11         21 return @items;
158             }
159              
160             sub _get_header {
161 6     6   11 my ($self, $scope, $name) = @_;
162              
163 6         9 $name = lc($name);
164 6   50     9 for my $h (@{$scope->{headers} // []}) {
  6         17  
165 5 50       21 return $h->[1] if lc($h->[0]) eq $name;
166             }
167 1         5 return;
168             }
169              
170 1     1   2 async sub _send_not_acceptable {
171 1         2 my ($self, $send) = @_;
172              
173 1         1 my $supported = join(', ', @{$self->{supported_types}});
  1         5  
174 1         2 my $body = "Not Acceptable. Supported types: $supported";
175              
176 1         7 await $send->({
177             type => 'http.response.start',
178             status => 406,
179             headers => [
180             ['Content-Type', 'text/plain'],
181             ['Content-Length', length($body)],
182             ],
183             });
184 1         42 await $send->({
185             type => 'http.response.body',
186             body => $body,
187             more => 0,
188             });
189             }
190              
191             1;
192              
193             __END__