File Coverage

blib/lib/Dancer2/Plugin/HTTP/ConditionalRequest.pm
Criterion Covered Total %
statement 15 91 16.4
branch 0 58 0.0
condition 0 27 0.0
subroutine 5 16 31.2
pod n/a
total 20 192 10.4


line stmt bran cond sub pod time code
1             package Dancer2::Plugin::HTTP::ConditionalRequest;
2              
3             =head1 NAME
4              
5             Dancer2::Plugin::HTTP::ConditionalRequest - RFC 7232 compliant
6              
7             =head1 VERSION
8              
9             Version 0.01
10              
11             =cut
12              
13             our $VERSION = '0.04';
14              
15 1     1   51048 use warnings;
  1         1  
  1         29  
16 1     1   3 use strict;
  1         1  
  1         16  
17              
18 1     1   3 use Carp;
  1         4  
  1         51  
19 1     1   506 use Dancer2::Plugin;
  1         53021  
  1         7  
20              
21 1     1   14120 use DateTime::Format::HTTP;
  1         317466  
  1         873  
22              
23              
24             =head1 SYNOPSIS
25              
26             Conditionally handling HTTP request based on eTag or Modification-Date,
27             according to RFC 7232
28              
29             HTTP Conditional Requests are used for telling servers that they only have to
30             perform the method if the preconditions are met. Such requests are either used
31             by caches to (re)validate the cached response with the origin server - or -
32             to prevent lost-updates with unsafe-methods in a stateless api (like REST).
33            
34             any '/my_resource/:id' => sub {
35             ...
36             # check stuff
37             # - compute eTag from MD5
38             # - use an external table
39             # - find a last modification date
40             ...
41            
42             http_conditional {
43             etag => '2d5730a4c92b1061',
44             last_modified => "Tue, 15 Nov 1994 12:45:26 GMT", # HTTP Date
45             required => false,
46             }
47            
48             # do the real stuff, like updating
49            
50             };
51              
52             =head1 RFC_7232 HTTP: Conditional Requests... explained
53              
54             As mentioned in the previous section, Conditional Requests are for two purposes
55             mainly:
56              
57             =head2 Caching
58              
59             For GET and HEAD methods, the caching-server passes the validators to the origin
60             server to see if it can still use the cached version or not - and if not, get a
61             fresh version.
62              
63             Keep in mind that the Dancer2 server is probably designed to be that originating
64             server and should provide either the 304 (Not Modified) status or a fresh
65             representation off the requested resource. At this stage (and in compliance with
66             the RFC) there is nothing to deal with the caching of the responses at all.
67              
68             This plugin does not do any caching, it's only purpose is to respond correctly
69             to conditional requests. Neither does this plugin set any caching-directives
70             that are part of RFC_7234 (Caching), and for which there is a seperate plugin.
71              
72             =head2 Lost-Updates
73              
74             For REST api's it is important to understand that it is a Stateless interface.
75             This means that there is no such thing as record locking on the server. Would
76             one desire to edit a resource, the only way to check that one is not accidently
77             overwritin someone elses changes, is comparing it with weak or strong
78             validators, like date/time of a last modification - or a unique version
79             identifier, known as a eTag.
80              
81             =head2 Strong and weak validators
82              
83             ETags are stronger validators than the Date Last-Modified. In the above
84             described example, it has two validators provided that can be used to check the
85             conditional request. If the client did set an eTag conditional in 'If-Matched'
86             or 'If-None-Matched', it will try to match that. If not, it will try to match
87             against the Date Last-Modified with either the 'If-Modified-Since' or
88             'If-Unmodified-Since'.
89              
90             =head2 Required or not
91              
92             The optional 'required' turns the API into a strict mode. Running under 'strict'
93             ensures that the client will provided either the eTag or Date-Modified validator
94             for un-safe requests. If not provided when required, it will return a response
95             with status 428 (Precondition Required) (RFC 6585).
96              
97             When set to false, it allows a client to sent of a request without the headers
98             for the conditional requests and as such have bypassed all the checks end up in
99             the last validation step and continue with the requested operation.
100              
101             =head2 Safe and unsafe methods
102              
103             Sending these validators with a GET request is used for caching and respond with
104             a status of 304 (Not Modified) when the client has a 'fresh' version. Remember
105             though to send of current caching-information too (according to the RFC 7232).
106              
107             When used with 'unsafe' methods that will cause updates, these validators can
108             prevent 'lost updates' and will respond with 412 (Precondition Failed) when
109             there might have happened an intermediate update.
110              
111             =head2 Generating eTags and Dates Last-Modified
112              
113             Unfortunately, for a any method one might have to retrieve and process the
114             resource data before being capable of generating a eTag. Or one might have to go
115             through a few pieces of underlying data structures to find that
116             last-modification date.
117              
118             For a GET method one can then skip the 'post-processing' like serialisation and
119             one does no longer have to send the data but only the status message 304
120             (Not Modified).
121              
122             =head2 More reading
123              
124             There is a lot of additional information in RFC-7232 about generating and
125             retrieving eTags or last-modification-dates. Please read-up in the RFC about
126             those topics.
127              
128             =cut
129              
130             =head1 Dancer2 Keywords
131              
132             =head2 http_conditional
133              
134             This keyword used will check with the passed in parameters to do a conditional
135             request. If these pre-conditions are not met execution will be halted with the
136             relevant status code. If the preconditions apply, execution will continue on the
137             following line.
138              
139             A optional hashref takes the options
140              
141             =over
142              
143             =item etag
144              
145             a string that 'uniquely' identifies the current version of the resource.
146              
147             =item last_modified
148              
149             a HTTP Date compliant string of the date/time this resource was last updated.
150              
151             or
152              
153             a DateTime object.
154              
155             A suitable string can be created from a UNIX timestamp using
156             L<HTTP::Date::time2str|HTTP::Date/time2str>, or from a L<DateTime|DateTime>
157             object using C<format_datetime> from L<DateTime::Format::HTTP|DateTime::Format::HTTP/format_datetime>.
158              
159             =item required
160              
161             if set to true, it enforces clients that request a unsafe method to provide one
162             or both validators.
163              
164             =back
165              
166             If used with either a GET or a HEAD method, the validators mentioned in the
167             options are set and returned in the appropriate HTTP Header Fields.
168              
169             =cut
170              
171             register http_conditional => sub {
172 0     0     my $self = shift;
173 0           my $coderef = pop;
174 0           my $args = shift;
175            
176            
177 0 0 0       unless ( $coderef && ref $coderef eq 'CODE' ) {
178             return sub {
179 0     0     warn "http_conditional: missing CODE-REF";
180 0           };
181             };
182            
183             # To understand the flow of the desicions the original text of the RFC has
184             # been included so one can see that it does what the RFC says, not what the
185             # evelopper thinks it should do.
186            
187             # Additional checks for argument validation have been added.
188            
189 0 0         goto STEP_1 if not $args->{required};
190            
191             # RFC-6585 - Status 428 (Precondition Required)
192             #
193             # For a GET, it would be totaly safe to return a fresh response,
194             # however, for unsafe methods it could be required that the client
195             # does provide the eTag or DateModified validators.
196             #
197             # setting the pluging config with something like: required => 1
198             # might be a nice way to handle it for the entire app, turning it
199             # into a strict modus.
200            
201 0 0         if ($self->http_method_is_nonsafe) {
202             # warn "http_conditional: http_method_is_nonsafe";
203             $self->halt( $self->_http_status_precondition_required_etag )
204             if ( $args->{etag}
205 0 0 0       and not $self->app->request->header('If-Match') );
206             $self->halt( $self->_http_status_precondition_required_last_modified)
207             if ( $args->{last_modified}
208 0 0 0       and not $self->app->request->header('If-Unmodified-Since') );
209             } else {
210             # warn "http_conditional: http_method_is_safe";
211             };
212            
213              
214             # RFC 7232 Hypertext Transfer Protocol (HTTP/1.1): Conditional Requests
215             #
216             # Section 6. Precedence
217             #
218             # When more than one conditional request header field is present in a
219             # request, the order in which the fields are evaluated becomes
220             # important. In practice, the fields defined in this document are
221             # consistently implemented in a single, logical order, since "lost
222             # update" preconditions have more strict requirements than cache
223             # validation, a validated cache is more efficient than a partial
224             # response, and entity tags are presumed to be more accurate than date
225             # validators.
226            
227             STEP_1:
228             # When recipient is the origin server and If-Match is present,
229             # evaluate the If-Match precondition:
230            
231             # if true, continue to step 3
232            
233             # if false, respond 412 (Precondition Failed) unless it can be
234             # determined that the state-changing request has already
235             # succeeded (see Section 3.1)
236            
237 0 0         if ( defined ($self->app->request->header('If-Match')) ) {
238            
239             # check arguments and http-headers
240             $self->halt( $self->_http_status_precondition_failed_no_etag )
241 0 0         if not exists $args->{etag};
242            
243            
244             # RFC 7232
245 0 0         if ( $self->app->request->header('If-Match') eq $args->{etag} ) {
246 0           goto STEP_3;
247             } else {
248 0           $self->app->response->status(412); # Precondition Failed
249 0           $self->halt( );
250             }
251             }
252            
253             STEP_2:
254             # When recipient is the origin server, If-Match is not present, and
255             # If-Unmodified-Since is present, evaluate the If-Unmodified-Since
256             # precondition:
257            
258             # if true, continue to step 3
259            
260             # if false, respond 412 (Precondition Failed) unless it can be
261             # determined that the state-changing request has already
262             # succeeded (see Section 3.4)
263            
264 0 0         if ( defined ($self->app->request->header('If-Unmodified-Since')) ) {
265            
266             # check arguments and http-headers
267             $self->halt( $self->_http_status_precondition_failed_no_date )
268 0 0         if not exists $args->{last_modified};
269            
270 0           my $rqst_date = DateTime::Format::HTTP->parse_datetime(
271             $self->app->request->header('If-Unmodified-Since')
272             );
273 0 0         $self->halt( $self->_http_status_bad_request_if_unmodified_since )
274             if not defined $rqst_date;
275            
276             my $last_date = $args->{last_modified}->isa('DateTime')
277             ? $args->{last_modified}
278 0 0         : DateTime::Format::HTTP->parse_datetime($args->{last_modified});
279 0 0         $self->halt( $self->_http_status_server_error_bad_last_modified )
280             if not defined $last_date;
281            
282            
283             # RFC 7232
284 0 0         if ( $rqst_date > $last_date ) {
285 0           goto STEP_3;
286             } else {
287 0           $self->app->response->status(412); # Precondition Failed
288 0           return;
289             }
290             }
291              
292             STEP_3:
293             # When If-None-Match is present, evaluate the If-None-Match
294             # precondition:
295            
296             # if true, continue to step 5
297            
298             # if false for GET/HEAD, respond 304 (Not Modified)
299            
300             # if false for other methods, respond 412 (Precondition Failed)
301            
302 0 0         if ( defined ($self->app->request->header('If-None-Match')) ) {
303            
304             # check arguments and http-headers
305             $self->halt( $self->_http_status_precondition_failed_no_etag )
306 0 0         if not exists $args->{etag};
307            
308            
309             # RFC 7232
310 0 0         if ( $self->app->request->header('If-None-Match') ne $args->{etag} ) {
311 0           goto STEP_5;
312             } else {
313 0 0 0       if (
314             $self->app->request->method eq 'GET'
315             or
316             $self->app->request->method eq 'HEAD'
317             ) {
318 0           $self->app->response->status(304); # Not Modified
319 0           $self->halt;
320             } else {
321 0           $self->app->response->status(412); # Precondition Failed
322 0           $self->halt( );
323             }
324             }
325             }
326              
327             STEP_4:
328             # When the method is GET or HEAD, If-None-Match is not present, and
329             # If-Modified-Since is present, evaluate the If-Modified-Since
330             # precondition:
331            
332             # if true, continue to step 5
333            
334             # if false, respond 304 (Not Modified)
335              
336 0 0 0       if (
      0        
      0        
337             ($self->app->request->method eq 'GET' or $self->app->request->method eq 'HEAD')
338             and
339             not defined($self->app->request->header('If-None-Match'))
340             and
341             defined($self->app->request->header('If-Modified-Since'))
342             ) {
343            
344             # check arguments and http-headers
345             $self->halt( $self->_http_status_precondition_failed_no_date )
346 0 0         if not exists $args->{last_modified};
347            
348 0           my $rqst_date = DateTime::Format::HTTP->parse_datetime(
349             $self->app->request->header('If-Modified-Since')
350             );
351 0 0         $self->halt( $self->_http_status_bad_request_if_modified_since )
352             if not defined $rqst_date;
353            
354             my $last_date = $args->{last_modified}->isa('DateTime')
355             ? $args->{last_modified}
356 0 0         : DateTime::Format::HTTP->parse_datetime($args->{last_modified});
357 0 0         $self->halt( $self->_http_status_server_error_bad_last_modified )
358             if not defined $last_date;
359            
360            
361             # RFC 7232
362 0 0         if ( $rqst_date < $last_date ) {
363 0           goto STEP_5;
364             } else {
365 0           $self->app->response->status(304); # Not Modified
366 0           $self->halt( );
367             }
368             }
369            
370             STEP_5:
371             # When the method is GET and both Range and If-Range are present,
372             # evaluate the If-Range precondition:
373            
374             # if the validator matches and the Range specification is
375             # applicable to the selected representation, respond 206
376             # (Partial Content) [RFC7233]
377            
378 0           undef; # TODO (BTW, up till perl 5.13, this would break because of labels
379            
380             STEP_6:
381             # Otherwise,
382            
383             # all conditions are met, so perform the requested action and
384             # respond according to its success or failure.
385            
386             # set HTTP Header-fields for GET / HEAD requests
387 0 0 0       if (
388             ($self->app->request->method eq 'GET' or $self->app->request->method eq 'HEAD')
389             ) {
390 0 0         if ( exists($args->{etag}) ) {
391             $self->app->response->header('ETag' => $args->{etag})
392 0           }
393 0 0         if ( exists($args->{last_modified}) ) {
394             my $last_date = $args->{last_modified}->isa('DateTime')
395             ? $args->{last_modified}
396 0 0         : DateTime::Format::HTTP->parse_datetime($args->{last_modified});
397 0 0         $self->halt( $self->_http_status_server_error_bad_last_modified )
398             if not defined $last_date;
399 0           $self->app->response->header('Last-Modified' =>
400             DateTime::Format::HTTP->format_datetime($last_date) )
401             }
402             }
403            
404             # RFC 7232
405 0           return;# $coderef->($self);
406            
407             };
408              
409             # RFC 7231 HTTP/1.1 Semantics and Content
410             # section 4.2.1 Common Method Properties - Safe Methods#
411             # http://tools.ietf.org/html/rfc7231#section-4.2.1
412             # there is a patch for Dancer2 it self
413             register http_method_is_safe => sub {
414             return (
415 0   0 0     $_[0]->app->request->method eq 'GET' ||
416             $_[0]->app->request->method eq 'HEAD' ||
417             $_[0]->app->request->method eq 'OPTIONS' ||
418             $_[0]->app->request->method eq 'TRACE'
419             );
420             };
421              
422             register http_method_is_nonsafe => sub {
423 0     0     return not $_[0]->http_method_is_safe();
424             };
425              
426             sub _http_status_bad_request_if_modified_since {
427 0     0     warn "http_conditional: bad formatted date 'If-Modified-Since'";
428 0           $_[0]->status(400); # Bad Request
429 0           return;
430             }
431              
432             sub _http_status_bad_request_if_unmodified_since {
433 0     0     warn "http_conditional: bad formatted date 'If-Unmodified-Since'";
434 0           $_[0]->status(400); # Bad Request
435 0           return;
436             }
437              
438             sub _http_status_precondition_failed_no_date {
439 0     0     warn "http_conditional: not provided 'last_modified'";
440 0           $_[0]->status(412); # Precondition Failed
441 0           return;
442             }
443              
444             sub _http_status_precondition_failed_no_etag {
445 0     0     warn "http_conditional: not provided 'eTag'";
446 0           $_[0]->status(412); # Precondition Failed
447 0           return;
448             }
449              
450             sub _http_status_precondition_required_etag {
451 0     0     warn "http_conditional: Precondition Required 'ETag'";
452 0           $_[0]->status(428); # Precondition Required
453 0           return;
454             }
455              
456             sub _http_status_precondition_required_last_modified {
457 0     0     warn "http_conditional: Precondition Required 'Date Last-Modified'";
458 0           $_[0]->status(428); # Precondition Required
459 0           return;
460             }
461              
462             sub _http_status_server_error_bad_last_modified {
463 0     0     $_[0]->status(500); # Precondition Failed
464 0           return "http_conditional: bad formatted date 'last_modified'";
465             }
466              
467             register_plugin;
468              
469             =head1 AUTHOR
470              
471             Theo van Hoesel, C<< <Th.J.v.Hoesel at THEMA-MEDIA.nl> >>
472              
473             =head1 BUGS
474              
475             Please report any bugs or feature requests to
476             C<bug-dancer2-plugin-http-conditionalrequest at rt.cpan.org>, or through the web
477             interface at
478             L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Dancer2-Plugin-HTTP-ConditionalRequest>.
479             I will be notified, and then you'll automatically be notified of progress on
480             your bug as I make changes.
481              
482              
483              
484             =head1 SUPPORT
485              
486             You can find documentation for this module with the perldoc command.
487              
488             perldoc Dancer2::Plugin::HTTP::ConditionalRequest
489              
490              
491             You can also look for information at:
492              
493             =over 4
494              
495             =item * RT: CPAN's request tracker (report bugs here)
496              
497             L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Dancer2-Plugin-HTTP-ConditionalRequest>
498              
499             =item * AnnoCPAN: Annotated CPAN documentation
500              
501             L<http://annocpan.org/dist/Dancer2-Plugin-HTTP-ConditionalRequest>
502              
503             =item * CPAN Ratings
504              
505             L<http://cpanratings.perl.org/d/Dancer2-Plugin-HTTP-ConditionalRequest>
506              
507             =item * Search CPAN
508              
509             L<http://search.cpan.org/dist/Dancer2-Plugin-HTTP-ConditionalRequest/>
510              
511             =back
512              
513              
514             =head1 ACKNOWLEDGEMENTS
515              
516              
517             =head1 LICENSE AND COPYRIGHT
518              
519             Copyright 2015-2016 Theo van Hoesel.
520              
521             This program is free software; you can redistribute it and/or modify it
522             under the terms of the the Artistic License (2.0). You may obtain a
523             copy of the full license at:
524              
525             L<http://www.perlfoundation.org/artistic_license_2_0>
526              
527             Any use, modification, and distribution of the Standard or Modified
528             Versions is governed by this Artistic License. By using, modifying or
529             distributing the Package, you accept this license. Do not use, modify,
530             or distribute the Package, if you do not accept this license.
531              
532             If your Modified Version has been derived from a Modified Version made
533             by someone other than you, you are nevertheless required to ensure that
534             your Modified Version complies with the requirements of this license.
535              
536             This license does not grant you the right to use any trademark, service
537             mark, tradename, or logo of the Copyright Holder.
538              
539             This license includes the non-exclusive, worldwide, free-of-charge
540             patent license to make, have made, use, offer to sell, sell, import and
541             otherwise transfer the Package with respect to any patent claims
542             licensable by the Copyright Holder that are necessarily infringed by the
543             Package. If you institute patent litigation (including a cross-claim or
544             counterclaim) against any party alleging that the Package constitutes
545             direct or contributory patent infringement, then this Artistic License
546             to you shall terminate on the date that such litigation is filed.
547              
548             Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER
549             AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES.
550             THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
551             PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY
552             YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR
553             CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR
554             CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE,
555             EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
556              
557             =cut
558              
559             1;