File Coverage

blib/lib/Leyland/Negotiator.pm
Criterion Covered Total %
statement 9 132 6.8
branch 0 58 0.0
condition 0 27 0.0
subroutine 3 13 23.0
pod 3 3 100.0
total 15 233 6.4


line stmt bran cond sub pod time code
1             package Leyland::Negotiator;
2              
3             # ABSTRACT: Performs HTTP negotiations for Leyland requests
4              
5 2     2   13 use strict;
  2         14  
  2         84  
6 2     2   10 use warnings;
  2         5  
  2         69  
7              
8 2     2   9 use Carp;
  2         4  
  2         4424  
9              
10             =head1 NAME
11              
12             Leyland::Negotiator - Performs HTTP negotiations for Leyland requests
13              
14             =head1 SYNOPSIS
15              
16             # used internally
17              
18             =head1 DESCRIPTION
19              
20             This module performs HTTP negotiations for L requests. When a request
21             is handled by a Leyland application, it is first negotiated by this module
22             to make sure it can be handled, and to decide on how to handle it.
23              
24             The following negotiations are performed:
25              
26             =over
27              
28             =item 1. Character set negotiation - Leyland only supports UTF-8, so if
29             the request defines a different character set, a 400 Bad Request error
30             is thrown.
31              
32             =item 2. Path negotiation - The request path is compared against the application's
33             routes, and a list of routes is created. If none are found, a 404 Not Found
34             error is thrown.
35              
36             =item 3. Request method negotiation - The list of routes is filtered
37             by the request method (GET, POST, etc.), so only routes of this method
38             remain. If none remain, a 405 Method Not Allowed error is thrown.
39              
40             =item 4. Received content type negotiation - The list of routes is filtered
41             by the request content type (text/html for example), if it has any, so only
42             routes that accept this media type remain. If none remain, a 415 Unsupported
43             Media Type error is thrown.
44              
45             =item 5. Returned content type negotiation - The list of routes is filtered
46             by the request accepted media types (residing in the Accept HTTP header),
47             if defined, so only routes that return a media type accepted by the client
48             remain. If none remain, a 406 Not Acceptable error is thrown.
49              
50             =back
51              
52             There's one thing this method doesn't perform, and that's language negotiation.
53             Since proper HTTP language negotiation is rare (and difficult to implement),
54             you are expect to perform that yourself (only if you wish, of course).
55             For that, L is provided.
56              
57             This module also finds routes that match a path when an HTTP OPTIONS request
58             is received.
59              
60             =head1 CLASS METHODS
61              
62             =head2 negotiate( $c, $app_routes, $path )
63              
64             Performs a series of HTTP negotiations on the request and returns matching
65             routes. If none are found, an error is thrown. See L for
66             more information.
67              
68             =cut
69              
70             sub negotiate {
71 0     0 1   my ($class, $c, $app_routes, $path) = @_;
72              
73             # 1. CHARACTER SET NEGOTIATION
74             # --------------------------------------------------------------
75             # Leyland only supports UTF-8 character encodings, so let's check
76             # the client supports that. If not, let's return an error
77 0           $c->log->debug('Negotiating character set.');
78 0 0         Leyland::Negotiator->_negotiate_charset($c)
79             || $c->exception({ code => 400, error => "This server only supports the UTF-8 character set, unfortunately we are unable to fulfil your request." });
80              
81             # 2. PATH NEGOTIATION
82             # --------------------------------------------------------------
83             # let's find all possible prefix/route combinations
84             # from the request path, and then find all routes matching
85             # the request path
86 0           my $routes = [];
87 0   0       $path ||= $c->path;
88 0           $routes = $class->_negotiate_path($c, { app_routes => $app_routes, path => $path });
89 0 0         $c->exception({ code => 404 }) unless scalar @$routes;
90              
91 0           $c->log->debug('Found '.scalar(@$routes).' routes matching '.$path);
92              
93             # 3. REQUEST METHOD NEGOTIATION
94             # --------------------------------------------------------------
95             # weed out routes that do not match request method
96 0           $c->log->debug('Negotiating request method.');
97 0           $routes = $class->_negotiate_method($c->method, $routes);
98 0 0         $c->exception({ code => 405 }) unless scalar @$routes;
99              
100             # 4. RECEIVED CONTENT TYPE NEGOTIATION
101             # --------------------------------------------------------------
102             # weed out all routes that do not accept the media type that the
103             # client used for the request
104 0           $c->log->debug('Negotiating media type received.');
105 0           $routes = $class->_negotiate_receive_media($c, $routes);
106 0 0         $c->exception({ code => 415 }) unless scalar @$routes;
107              
108             # 5. RETURNED CONTENT TYPE NEGOTIATION
109             # --------------------------------------------------------------
110             # weed out all routes that do not return any media type
111             # the client accepts
112 0           $c->log->debug('Negotiating media type returned.');
113 0           $routes = $class->_negotiate_return_media($c, $routes);
114 0 0         $c->exception({ code => 406 }) unless scalar @$routes;
115              
116 0           return $routes;
117             }
118              
119             =head2 find_options( $c, $app_routes )
120              
121             Finds all routes that match a certain path when an HTTP OPTIONS request
122             is received.
123              
124             =cut
125              
126             sub find_options {
127 0     0 1   my ($class, $c, $app_routes) = @_;
128              
129 0           my $routes = $class->matching_routes($app_routes, $class->prefs_and_routes($c->path));
130              
131             # have we found any matching routes?
132 0 0         $c->exception({ code => 404 }) unless scalar @$routes;
133              
134             # okay, we have, let's see which HTTP methods are supported by
135             # these routes
136 0           my %meths = ( 'OPTIONS' => 1 );
137 0           foreach (@$routes) {
138 0           $meths{$class->method_name($_->{method})} = 1;
139             }
140              
141 0           return sort keys %meths;
142             }
143              
144             =head2 method_name( $meth )
145              
146             Receives the name of a Leyland-style HTTP method (like 'get', 'post',
147             'put' or 'del') and returns the correct HTTP name of it (like 'GET', 'POST',
148             'PUT' or 'DELETE').
149              
150             =cut
151              
152             sub method_name {
153 0     0 1   my ($class, $meth) = @_;
154              
155             # replace 'del' with 'delete'
156 0 0         $meth = 'delete' if $meth eq 'del';
157              
158             # return this in uppercase
159 0           return uc($meth);
160             }
161              
162             sub _negotiate_path {
163 0     0     my ($class, $c, $args) = @_;
164              
165 0   0       $args->{path} ||= $c->path;
166              
167             # let's find all possible prefix/route combinations
168             # from the request path and then find all routes matching the request path
169 0           my $routes = $class->_matching_routes($args->{app_routes}, $class->_prefs_and_routes($args->{path}), $args->{internal});
170              
171 0 0         if ($args->{method}) {
172 0           return $class->_negotiate_method($args->{method}, $routes);
173             } else {
174 0           return $routes;
175             }
176             }
177              
178             sub _prefs_and_routes {
179 0     0     my ($class, $path) = @_;
180              
181 0           my $pref_routes = [{ prefix => '', route => $path }];
182 0           my ($prefix) = ($path =~ m!^(/[^/]+)!);
183 0   0       my $route = $' || '/';
184 0           my $i = 0; # counter to prevent infinite loops, probably should be removed
185 0   0       while ($prefix && $i < 1000) {
186 0           push(@$pref_routes, { prefix => $prefix, route => $route });
187            
188 0           my ($suffix) = ($route =~ m!^(/[^/]+)!);
189 0 0         last unless $suffix;
190 0           $prefix .= $suffix;
191 0   0       $route = $' || '/';
192 0           $i++;
193             }
194              
195 0           return $pref_routes;
196             }
197              
198             sub _matching_routes {
199 0     0     my ($class, $app_routes, $pref_routes, $internal) = @_;
200              
201 0           my $routes = [];
202 0           foreach (@$pref_routes) {
203 0   0       my $pref_name = $_->{prefix} || '_root_';
204              
205 0 0         next unless $app_routes->EXISTS($pref_name);
206              
207 0           my $pref_routes = $app_routes->FETCH($pref_name);
208            
209 0 0         next unless $pref_routes;
210            
211             # find matching routes in this prefix
212 0           ROUTE: foreach my $r ($pref_routes->Keys) {
213             # does the requested route match the current route?
214 0 0         next unless my @captures = ($_->{route} =~ m/$r/);
215            
216 0 0 0       shift @captures if scalar @captures == 1 && $captures[0] eq '1';
217              
218 0           my $route_meths = $pref_routes->FETCH($r);
219              
220             # find all routes that support the request method (i.e. GET, POST, etc.)
221 0 0         METH: foreach my $m (sort { $a eq 'any' || $b eq 'any' } keys %$route_meths) {
  0            
222             # do not match internal routes
223 0 0         RULE: foreach my $rule (@{$route_meths->{$m}->{rules}->{is} || []}) {
  0            
224 0 0 0       next METH if $rule eq 'internal' && !$internal;
225             }
226              
227             # okay, add this route
228 0           push(@$routes, { method => $m, class => $route_meths->{$m}->{class}, prefix => $_->{prefix}, route => $r, code => $route_meths->{$m}->{code}, rules => $route_meths->{$m}->{rules}, captures => \@captures });
229             }
230             }
231             }
232              
233 0           return $routes;
234             }
235              
236             sub _negotiate_method {
237 0     0     my ($class, $method, $routes) = @_;
238              
239 0 0         return [grep { $class->method_name($_->{method}) eq $method || $_->{method} eq 'any' } @$routes];
  0            
240             }
241              
242             sub _negotiate_receive_media {
243 0     0     my ($class, $c, $all_routes) = @_;
244              
245 0 0         return $all_routes unless my $ct = $c->content_type;
246              
247             # will hold all routes with acceptable receive types
248 0           my $routes = [];
249              
250             # remove charset from content-type
251 0 0         if ($ct =~ m/^([^;]+)/) {
252 0           $ct = $1;
253             }
254              
255 0           $c->log->debug("I have received $ct");
256              
257 0           ROUTE: foreach (@$all_routes) {
258             # does this route accept all media types?
259 0 0         unless (exists $_->{rules}->{accepts}) {
260 0           push(@$routes, $_);
261 0           next ROUTE;
262             }
263              
264             # okay, it has, what are we accepting?
265 0           foreach my $accept (@{$_->{rules}->{accepts}}) {
  0            
266 0 0         if ($accept eq $ct) {
267 0           push(@$routes, $_);
268 0           next ROUTE;
269             }
270             }
271             }
272              
273 0           return $routes;
274             }
275              
276             sub _negotiate_return_media {
277 0     0     my ($class, $c, $all_routes) = @_;
278              
279 0           my @mimes;
280 0           foreach (@{$c->wanted_mimes}) {
  0            
281 0           push(@mimes, $_->{mime});
282             }
283 0           $c->log->debug('Remote address wants '.join(', ', @mimes));
284              
285             # will hold all routes with acceptable return types
286 0           my $routes = [];
287            
288 0           ROUTE: foreach (@$all_routes) {
289             # does this route return any media type?
290 0 0         if ($_->{rules}->{returns_all}) {
291 0           $_->{media} = '*/*';
292 0           push(@$routes, $_);
293 0           next ROUTE;
294             }
295              
296             # what media types does this route return?
297 0           my @have = exists $_->{rules}->{returns} ?
298 0 0         @{$_->{rules}->{returns}} :
299             ('text/html');
300              
301             # what routes do the client want?
302 0 0         if (@{$c->wanted_mimes}) {
  0            
303 0           foreach my $want (@{$c->wanted_mimes}) {
  0            
304             # does the client accept _everything_?
305             # if so, just return the first type we support.
306             # this will happen only in the end of the
307             # wanted_mimes list, so if the client explicitely
308             # accepts a type we support, it will have
309             # preference over this
310 0 0 0       if ($want->{mime} eq '*/*' && $want->{q} > 0) {
311 0           $_->{media} = $have[0];
312 0           push(@$routes, $_);
313 0           next ROUTE;
314             }
315            
316             # okay, the client doesn't support */*, let's see what we have
317 0           foreach my $have (@have) {
318 0 0         if ($want->{mime} eq $have) {
319             # we return a MIME type the client wants
320 0           $_->{media} = $want->{mime};
321 0           push(@$routes, $_);
322 0           next ROUTE;
323             }
324             }
325             }
326             } else {
327 0           $_->{media} = $have[0];
328 0           push(@$routes, $_);
329 0           next ROUTE;
330             }
331             }
332            
333 0           return $routes;
334             }
335              
336             sub _negotiate_charset {
337 0     0     my ($class, $c) = @_;
338              
339 0 0         if ($c->header('Accept-Charset')) {
340 0           my @chars = split(/,/, $c->header('Accept-Charset'));
341 0           foreach (@chars) {
342 0           my ($charset, $pref) = split(/;q=/, $_);
343 0 0         next unless defined $pref;
344 0 0 0       return if $charset =~ m/utf-?8/i && $pref == 0;
345             }
346             }
347              
348 0           return 1;
349             }
350              
351             =head1 AUTHOR
352              
353             Ido Perlmuter, C<< >>
354              
355             =head1 BUGS
356              
357             Please report any bugs or feature requests to C, or through
358             the web interface at L. I will be notified, and then you'll
359             automatically be notified of progress on your bug as I make changes.
360              
361             =head1 SUPPORT
362              
363             You can find documentation for this module with the perldoc command.
364              
365             perldoc Leyland::Negotiator
366              
367             You can also look for information at:
368              
369             =over 4
370              
371             =item * RT: CPAN's request tracker
372              
373             L
374              
375             =item * AnnoCPAN: Annotated CPAN documentation
376              
377             L
378              
379             =item * CPAN Ratings
380              
381             L
382              
383             =item * Search CPAN
384              
385             L
386              
387             =back
388              
389             =head1 LICENSE AND COPYRIGHT
390              
391             Copyright 2010-2014 Ido Perlmuter.
392              
393             This program is free software; you can redistribute it and/or modify it
394             under the terms of either: the GNU General Public License as published
395             by the Free Software Foundation; or the Artistic License.
396              
397             See http://dev.perl.org/licenses/ for more information.
398              
399             =cut
400              
401             1;