File Coverage

blib/lib/Dancer2/Plugin/HTTP/ConditionalRequest.pm
Criterion Covered Total %
statement 15 89 16.8
branch 0 60 0.0
condition 0 24 0.0
subroutine 5 15 33.3
pod n/a
total 20 188 10.6


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.05';
14              
15 1     1   45460 use warnings;
  1         1  
  1         26  
16 1     1   3 use strict;
  1         1  
  1         15  
17              
18 1     1   3 use Carp;
  1         3  
  1         46  
19 1     1   474 use Dancer2::Plugin;
  1         51042  
  1         7  
20              
21 1     1   13525 use DateTime::Format::HTTP;
  1         298267  
  1         877  
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 or serializing
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 0         my $coderef = pop if ref($_[-1]) eq "CODE";
174 0           my $args = shift;
175            
176             # unless ( $coderef && ref $coderef eq 'CODE' ) {
177             # return sub {
178             # warn "http_conditional: missing CODE-REF";
179             # };
180             # };
181            
182             # To understand the flow of the desicions the original text of the RFC has
183             # been included so one can see that it does what the RFC says, not what the
184             # evelopper thinks it should do.
185            
186             # Additional checks for argument validation have been added.
187            
188 0 0         goto STEP_1 if not $args->{required};
189            
190             # RFC-6585 - Status 428 (Precondition Required)
191             #
192             # For a GET, it would be totaly safe to return a fresh response,
193             # however, for unsafe methods it could be required that the client
194             # does provide the eTag or DateModified validators.
195             #
196             # setting the pluging config with something like: required => 1
197             # might be a nice way to handle it for the entire app, turning it
198             # into a strict modus.
199            
200 0 0         if ($self->http_method_is_nonsafe) {
201             # warn "http_conditional: http_method_is_nonsafe";
202             $self->halt( $self->_http_status_precondition_required_etag )
203             if ( $args->{etag}
204 0 0 0       and not $self->app->request->header('If-Match') );
205             $self->halt( $self->_http_status_precondition_required_last_modified)
206             if ( $args->{last_modified}
207 0 0 0       and not $self->app->request->header('If-Unmodified-Since') );
208             } else {
209             # warn "http_conditional: http_method_is_safe";
210             };
211            
212            
213             # RFC 7232 Hypertext Transfer Protocol (HTTP/1.1): Conditional Requests
214             #
215             # Section 6. Precedence
216             #
217             # When more than one conditional request header field is present in a
218             # request, the order in which the fields are evaluated becomes
219             # important. In practice, the fields defined in this document are
220             # consistently implemented in a single, logical order, since "lost
221             # update" preconditions have more strict requirements than cache
222             # validation, a validated cache is more efficient than a partial
223             # response, and entity tags are presumed to be more accurate than date
224             # validators.
225            
226             STEP_1:
227             # When recipient is the origin server and If-Match is present,
228             # evaluate the If-Match precondition:
229            
230             # if true, continue to step 3
231            
232             # if false, respond 412 (Precondition Failed) unless it can be
233             # determined that the state-changing request has already
234             # succeeded (see Section 3.1)
235            
236 0 0         if ( defined ($self->app->request->header('If-Match')) ) {
237            
238             # check arguments and http-headers
239             $self->halt( $self->_http_status_precondition_failed_no_etag )
240 0 0         if not exists $args->{etag};
241            
242            
243             # RFC 7232
244 0 0         if ( $self->app->request->header('If-Match') eq $args->{etag} ) {
245 0           goto STEP_3;
246             } else {
247 0           $self->app->response->status(412); # Precondition Failed
248 0           $self->halt( );
249             }
250             }
251            
252             STEP_2:
253             # When recipient is the origin server, If-Match is not present, and
254             # If-Unmodified-Since is present, evaluate the If-Unmodified-Since
255             # precondition:
256            
257             # if true, continue to step 3
258            
259             # if false, respond 412 (Precondition Failed) unless it can be
260             # determined that the state-changing request has already
261             # succeeded (see Section 3.4)
262            
263 0 0         if ( defined ($self->app->request->header('If-Unmodified-Since')) ) {
264            
265             # check arguments and http-headers
266             $self->halt( $self->_http_status_precondition_failed_no_date )
267 0 0         if not exists $args->{last_modified};
268            
269 0           my $rqst_date = DateTime::Format::HTTP->parse_datetime(
270             $self->app->request->header('If-Unmodified-Since')
271             );
272 0 0         $self->halt( $self->_http_status_bad_request_if_unmodified_since )
273             if not defined $rqst_date;
274            
275             my $last_date = $args->{last_modified}->isa('DateTime')
276             ? $args->{last_modified}
277 0 0         : DateTime::Format::HTTP->parse_datetime($args->{last_modified});
278 0 0         $self->halt( $self->_http_status_server_error_bad_last_modified )
279             if not defined $last_date;
280            
281            
282             # RFC 7232
283 0 0         if ( $rqst_date > $last_date ) {
284 0           goto STEP_3;
285             } else {
286 0           $self->app->response->status(412); # Precondition Failed
287 0           return;
288             }
289             }
290              
291             STEP_3:
292             # When If-None-Match is present, evaluate the If-None-Match
293             # precondition:
294            
295             # if true, continue to step 5
296            
297             # if false for GET/HEAD, respond 304 (Not Modified)
298            
299             # if false for other methods, respond 412 (Precondition Failed)
300            
301 0 0         if ( defined ($self->app->request->header('If-None-Match')) ) {
302            
303             # check arguments and http-headers
304             $self->halt( $self->_http_status_precondition_failed_no_etag )
305 0 0         if not exists $args->{etag};
306            
307            
308             # RFC 7232
309 0 0         if ( $self->app->request->header('If-None-Match') ne $args->{etag} ) {
310 0           goto STEP_5;
311             } else {
312 0 0 0       if (
313             $self->app->request->method eq 'GET'
314             or
315             $self->app->request->method eq 'HEAD'
316             ) {
317 0           $self->app->response->status(304); # Not Modified
318 0           $self->halt;
319             } else {
320 0           $self->app->response->status(412); # Precondition Failed
321 0           $self->halt( );
322             }
323             }
324             }
325              
326             STEP_4:
327             # When the method is GET or HEAD, If-None-Match is not present, and
328             # If-Modified-Since is present, evaluate the If-Modified-Since
329             # precondition:
330            
331             # if true, continue to step 5
332            
333             # if false, respond 304 (Not Modified)
334              
335 0 0 0       if (
      0        
      0        
336             ($self->app->request->method eq 'GET' or $self->app->request->method eq 'HEAD')
337             and
338             not defined($self->app->request->header('If-None-Match'))
339             and
340             defined($self->app->request->header('If-Modified-Since'))
341             ) {
342            
343             # check arguments and http-headers
344             $self->halt( $self->_http_status_precondition_failed_no_date )
345 0 0         if not exists $args->{last_modified};
346            
347 0           my $rqst_date = DateTime::Format::HTTP->parse_datetime(
348             $self->app->request->header('If-Modified-Since')
349             );
350 0 0         $self->halt( $self->_http_status_bad_request_if_modified_since )
351             if not defined $rqst_date;
352            
353             my $last_date = $args->{last_modified}->isa('DateTime')
354             ? $args->{last_modified}
355 0 0         : DateTime::Format::HTTP->parse_datetime($args->{last_modified});
356 0 0         $self->halt( $self->_http_status_server_error_bad_last_modified )
357             if not defined $last_date;
358            
359            
360             # RFC 7232
361 0 0         if ( $rqst_date < $last_date ) {
362 0           goto STEP_5;
363             } else {
364 0           $self->app->response->status(304); # Not Modified
365 0           $self->halt( );
366             }
367             }
368            
369             STEP_5:
370             # When the method is GET and both Range and If-Range are present,
371             # evaluate the If-Range precondition:
372            
373             # if the validator matches and the Range specification is
374             # applicable to the selected representation, respond 206
375             # (Partial Content) [RFC7233]
376            
377 0           undef; # TODO (BTW, up till perl 5.13, this would break because of labels
378            
379             STEP_6:
380             # Otherwise,
381            
382             # all conditions are met, so perform the requested action and
383             # respond according to its success or failure.
384            
385             # set HTTP Header-fields for GET / HEAD requests
386 0 0 0       if (
387             ($self->app->request->method eq 'GET' or $self->app->request->method eq 'HEAD')
388             ) {
389 0 0         if ( exists($args->{etag}) ) {
390             $self->app->response->header('ETag' => $args->{etag})
391 0           }
392 0 0         if ( exists($args->{last_modified}) ) {
393             my $last_date = $args->{last_modified}->isa('DateTime')
394             ? $args->{last_modified}
395 0 0         : DateTime::Format::HTTP->parse_datetime($args->{last_modified});
396 0 0         $self->halt( $self->_http_status_server_error_bad_last_modified )
397             if not defined $last_date;
398 0           $self->app->response->header('Last-Modified' =>
399             DateTime::Format::HTTP->format_datetime($last_date) )
400             }
401             }
402            
403             # RFC 7232
404            
405 0 0         return $coderef->($self) if $coderef;
406             return
407            
408 0           };
409              
410             # RFC 7231 HTTP/1.1 Semantics and Content
411             # section 4.2.1 Common Method Properties - Safe Methods#
412             # http://tools.ietf.org/html/rfc7231#section-4.2.1
413             # there is a patch for Dancer2 it self
414             register http_method_is_safe => sub {
415             return (
416 0   0 0     $_[0]->app->request->method eq 'GET' ||
417             $_[0]->app->request->method eq 'HEAD' ||
418             $_[0]->app->request->method eq 'OPTIONS' ||
419             $_[0]->app->request->method eq 'TRACE'
420             );
421             };
422              
423             register http_method_is_nonsafe => sub {
424 0     0     return not $_[0]->http_method_is_safe();
425             };
426              
427             sub _http_status_bad_request_if_modified_since {
428 0     0     warn "http_conditional: bad formatted date 'If-Modified-Since'";
429 0           $_[0]->status(400); # Bad Request
430 0           return;
431             }
432              
433             sub _http_status_bad_request_if_unmodified_since {
434 0     0     warn "http_conditional: bad formatted date 'If-Unmodified-Since'";
435 0           $_[0]->status(400); # Bad Request
436 0           return;
437             }
438              
439             sub _http_status_precondition_failed_no_date {
440 0     0     warn "http_conditional: not provided 'last_modified'";
441 0           $_[0]->status(412); # Precondition Failed
442 0           return;
443             }
444              
445             sub _http_status_precondition_failed_no_etag {
446 0     0     warn "http_conditional: not provided 'eTag'";
447 0           $_[0]->status(412); # Precondition Failed
448 0           return;
449             }
450              
451             sub _http_status_precondition_required_etag {
452 0     0     warn "http_conditional: Precondition Required 'ETag'";
453 0           $_[0]->status(428); # Precondition Required
454 0           return;
455             }
456              
457             sub _http_status_precondition_required_last_modified {
458 0     0     warn "http_conditional: Precondition Required 'Date Last-Modified'";
459 0           $_[0]->status(428); # Precondition Required
460 0           return;
461             }
462              
463             sub _http_status_server_error_bad_last_modified {
464 0     0     $_[0]->status(500); # Precondition Failed
465 0           return "http_conditional: bad formatted date 'last_modified'";
466             }
467              
468             register_plugin;
469              
470             =head1 AUTHOR
471              
472             Theo van Hoesel, C<< <Th.J.v.Hoesel at THEMA-MEDIA.nl> >>
473              
474             =head1 BUGS
475              
476             Please report any bugs or feature requests to
477             C<bug-dancer2-plugin-http-conditionalrequest at rt.cpan.org>, or through the web
478             interface at
479             L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Dancer2-Plugin-HTTP-ConditionalRequest>.
480             I will be notified, and then you'll automatically be notified of progress on
481             your bug as I make changes.
482              
483              
484              
485             =head1 SUPPORT
486              
487             You can find documentation for this module with the perldoc command.
488              
489             perldoc Dancer2::Plugin::HTTP::ConditionalRequest
490              
491              
492             You can also look for information at:
493              
494             =over 4
495              
496             =item * RT: CPAN's request tracker (report bugs here)
497              
498             L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Dancer2-Plugin-HTTP-ConditionalRequest>
499              
500             =item * AnnoCPAN: Annotated CPAN documentation
501              
502             L<http://annocpan.org/dist/Dancer2-Plugin-HTTP-ConditionalRequest>
503              
504             =item * CPAN Ratings
505              
506             L<http://cpanratings.perl.org/d/Dancer2-Plugin-HTTP-ConditionalRequest>
507              
508             =item * Search CPAN
509              
510             L<http://search.cpan.org/dist/Dancer2-Plugin-HTTP-ConditionalRequest/>
511              
512             =back
513              
514              
515             =head1 ACKNOWLEDGEMENTS
516              
517              
518             =head1 LICENSE AND COPYRIGHT
519              
520             Copyright 2015-2016 Theo van Hoesel.
521              
522             This program is free software; you can redistribute it and/or modify it
523             under the terms of the the Artistic License (2.0). You may obtain a
524             copy of the full license at:
525              
526             L<http://www.perlfoundation.org/artistic_license_2_0>
527              
528             Any use, modification, and distribution of the Standard or Modified
529             Versions is governed by this Artistic License. By using, modifying or
530             distributing the Package, you accept this license. Do not use, modify,
531             or distribute the Package, if you do not accept this license.
532              
533             If your Modified Version has been derived from a Modified Version made
534             by someone other than you, you are nevertheless required to ensure that
535             your Modified Version complies with the requirements of this license.
536              
537             This license does not grant you the right to use any trademark, service
538             mark, tradename, or logo of the Copyright Holder.
539              
540             This license includes the non-exclusive, worldwide, free-of-charge
541             patent license to make, have made, use, offer to sell, sell, import and
542             otherwise transfer the Package with respect to any patent claims
543             licensable by the Copyright Holder that are necessarily infringed by the
544             Package. If you institute patent litigation (including a cross-claim or
545             counterclaim) against any party alleging that the Package constitutes
546             direct or contributory patent infringement, then this Artistic License
547             to you shall terminate on the date that such litigation is filed.
548              
549             Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER
550             AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES.
551             THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
552             PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY
553             YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR
554             CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR
555             CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE,
556             EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
557              
558             =cut
559              
560             1;