first draft of http methods, with a test case
John Napiorkowski [Wed, 16 Nov 2011 02:17:36 +0000 (21:17 -0500)]
added implicit HEAD and general refactor

added docs and minor code tweaks, more tests

test case for one helper

rewrite to deal with chaining correctly

removed silly debug code

support for options

options test cases

finished mst code review changes

lib/Web/Dispatch.pm
lib/Web/Dispatch/HTTPMethods.pm [new file with mode: 0644]
lib/Web/Dispatch/Predicates.pm
t/predicate_objects.t [moved from t/proxy-predicates.t with 100% similarity]
t/wd-http-methods.t [new file with mode: 0644]

index 9ed3912..5da5854 100644 (file)
@@ -108,7 +108,7 @@ sub _to_try {
   # sub (<spec>) {}      becomes a dispatcher
   # sub {}               is a PSGI app and can be returned as is
   # '<spec>' => sub {}   becomes a dispatcher
-  # $obj isa WD:Predicates::Proxy => sub { ... } -  become a dispatcher
+  # $obj isa WD:Predicates::Matcher => sub { ... } -  become a dispatcher
   # $obj w/to_app method is a Plack::App-like thing - call it to get a PSGI app
   #
 
diff --git a/lib/Web/Dispatch/HTTPMethods.pm b/lib/Web/Dispatch/HTTPMethods.pm
new file mode 100644 (file)
index 0000000..d22e3a3
--- /dev/null
@@ -0,0 +1,189 @@
+package Web::Dispatch::HTTPMethods;
+
+use strictures 1;
+use Web::Dispatch::Predicates qw(match_method);
+use Scalar::Util qw(blessed);
+use base qw(Exporter);
+
+our @EXPORT = qw(GET HEAD POST PUT DELETE OPTIONS);
+
+sub HEAD(&;@) { method_helper(HEAD => @_) }
+sub GET(&;@) { method_helper(GET => @_) }
+sub POST(&;@) { method_helper(POST => @_) }
+sub PUT(&;@) { method_helper(PUT => @_) }
+sub DELETE(&;@) { method_helper(DELETE => @_) }
+sub OPTIONS(&;@) { method_helper(OPTIONS => @_) }
+
+{
+  package Web::Dispatch::HTTPMethods::Endpoint;
+
+  sub new { bless { map { $_=>0 } @EXPORT }, shift }
+  sub hdrs { 'Content-Type' => 'text/plain' }
+
+  sub create_implicit_HEAD {
+    my $self = shift;
+    if($self->{GET} && not $self->{HEAD}) {
+      $self->{HEAD} = sub { [ @{$self->{GET}->(@_)}[0,1], []] };
+    }
+  }
+
+  sub create_implicit_OPTIONS {
+    my $self = shift;
+    $self->{OPTIONS} = sub {
+      [200, [$self->hdrs, Allow=>$self->allowed] , [] ];
+    };
+  }
+
+  sub allowed { join ',', grep { $_[0]->{$_} } @EXPORT }
+
+  sub to_app {
+    my $self = shift;
+    my $implicit_HEAD = $self->create_implicit_HEAD;
+    my $implicit_OPTIONS = $self->create_implicit_OPTIONS;
+
+    return sub {
+      my $env = shift;
+      if($env->{REQUEST_METHOD} eq 'HEAD') {
+        $implicit_HEAD->($env);
+      } elsif($env->{REQUEST_METHOD} eq 'OPTIONS') {
+        $implicit_OPTIONS->($env);
+      } else {
+        [405, [$self->hdrs, Allow=>$self->allowed] , ['Method Not Allowed'] ];
+      }
+    };
+  }
+}
+
+sub isa_endpoint {
+  blessed($_[0]) &&
+    $_[0]->isa('Web::Dispatch::HTTPMethods::Endpoint')
+}
+
+sub endpoint_from { return $_[-1] }
+sub new_endpoint { Web::Dispatch::HTTPMethods::Endpoint->new(@_) }
+
+sub method_helper {
+  my $predicate = match_method(my $method = shift);
+  my ($code, @following ) = @_;
+  endpoint_from( my @dispatchers = 
+    scalar(@following) ? ($predicate, @_) : ($predicate, @_, new_endpoint)
+   )->{$method} = $code;
+
+  die "Non HTTP Method dispatcher detected in HTTP Method scope"
+   unless(isa_endpoint($dispatchers[-1]));
+
+  return @dispatchers; 
+}
+
+
+1;
+
+=head1 NAME
+
+Web::Dispatch::HTTPMethods - Helpers to make RESTFul Dispatchers Easier
+
+=head1 SYNOPSIS
+
+    package MyApp:WithHTTPMethods;
+
+    use Web::Simple;
+    use Web::Dispatch::HTTPMethods;
+
+    sub as_text {
+      [200, ['Content-Type' => 'text/plain'],
+        [$_[0]->{REQUEST_METHOD}, $_[0]->{REQUEST_URI}] ]
+    }
+
+    sub dispatch_request {
+      sub (/get) {
+        GET { as_text(pop) }
+      },
+      sub (/get-head) {
+        GET { as_text(pop) }
+        HEAD { [204,[],[]] },
+      },
+      sub (/get-post-put) {
+        GET { as_text(pop) }  ## NOTE: no commas separating http methods
+        POST { as_text(pop) }
+        PUT { as_text(pop) }
+      },
+    }
+
+=head1 DESCRIPTION
+
+Exports the most commonly used HTTP methods as subroutine helps into your
+L<Web::Simple> based application.  Additionally adds an automatic HTTP code 405
+C<Method Not Allow> if none of the HTTP methods match for a given dispatch and
+also adds a dispatch rule for C<HEAD> if no C<HEAD> exists but a C<GET> does
+(in which case the C<HEAD> returns the C<GET> dispatch with an empty body.)
+
+We also add at the end of the chain support for the OPTIONS method (if you do
+not add one yourself.  This defaults to http 200 ok + Allows http headers.
+
+Also we try to set correct HTTP headers such as C<Allows> as makes sense based
+on your dispatch chain.
+
+The following dispatch chains are basically the same:
+
+    sub dispatch_request {
+      sub (/get-http-methods) {
+        GET { [200, ['Content-Type' => 'text/plain'], ['Hello World']] }
+      },
+      sub(/get-classic) {
+        sub (GET) { [200, ['Content-Type' => 'text/plain'], ['Hello World']] },
+        sub (HEAD)  { [200, ['Content-Type' => 'text/plain'], []] },
+        sub (OPTIONS)  {
+          [200, ['Content-Type' => 'text/plain', Allows=>'GET,HEAD,OPTIONS'], []];
+        },
+        sub () {
+          [405, ['Content-Type' => 'text/plain', Allows=>'GET,HEAD,OPTIONS'], 
+           ['Method Not Allowed']]
+        },
+      }
+    }
+
+The idea here is less boilerplate to distract the reader from the main point of
+the code and also to encapsulate some best practices.
+
+B<NOTE> You currently cannot mix http method style and prototype sub style in
+the same scope, as in the following example:
+
+    sub dispatch_request {
+      sub (/get-head) {
+        GET { ... }
+        sub (HEAD) { ... }
+      },
+    }
+
+If you try this our code will notice and issue a C<die>.  If you have a good use
+case please bring it to the authors.  
+
+=head2 EXPORTS
+
+This automatically exports the following subroutines:
+
+    GET
+    PUT
+    POST
+    HEAD
+    DELETE
+    OPTIONS
+
+=head1 AUTHOR
+
+See L<Web::Simple> for AUTHOR
+
+=head1 CONTRIBUTORS
+
+See L<Web::Simple> for CONTRIBUTORS
+
+=head1 COPYRIGHT
+
+See L<Web::Simple> for COPYRIGHT
+
+=head1 LICENSE
+
+See L<Web::Simple> for LICENSE
+
+=cut
+
index a23244d..4ff3a93 100644 (file)
@@ -8,11 +8,11 @@ our @EXPORT = qw(
   match_extension match_query match_body match_uploads
 );
 
-sub _generate_proxy { bless shift, 'Web::Dispatch::Matcher' }
+sub _matcher { bless shift, 'Web::Dispatch::Matcher' }
 
 sub match_and {
   my @match = @_;
-  _generate_proxy(sub {
+  _matcher(sub {
     my ($env) = @_;
     my $my_env = { 'Web::Dispatch.original_env' => $env, %$env };
     my $new_env;
@@ -33,7 +33,7 @@ sub match_and {
 
 sub match_or {
   my @match = @_;
-  _generate_proxy(sub {
+  _matcher(sub {
     foreach my $try (@match) {
       if (my @ret = $try->(@_)) {
         return @ret;
@@ -45,7 +45,7 @@ sub match_or {
 
 sub match_not {
   my ($match) = @_;
-  _generate_proxy(sub {
+  _matcher(sub {
     if (my @discard = $match->($_[0])) {
       ();
     } else {
@@ -56,7 +56,7 @@ sub match_not {
 
 sub match_method {
   my ($method) = @_;
-  _generate_proxy(sub {
+  _matcher(sub {
     my ($env) = @_;
     $env->{REQUEST_METHOD} eq $method ? {} : ()
   })
@@ -64,7 +64,7 @@ sub match_method {
 
 sub match_path {
   my ($re) = @_;
-  _generate_proxy(sub {
+  _matcher(sub {
     my ($env) = @_;
     if (my @cap = ($env->{PATH_INFO} =~ /$re/)) {
       $cap[0] = {}; return @cap;
@@ -75,7 +75,7 @@ sub match_path {
 
 sub match_path_strip {
   my ($re) = @_;
-  _generate_proxy(sub {
+  _matcher(sub {
     my ($env) = @_;
     if (my @cap = ($env->{PATH_INFO} =~ /$re/)) {
       $cap[0] = {
@@ -94,7 +94,7 @@ sub match_extension {
   my $re = $wild
              ? qr/\.(\w+)$/
              : qr/\.(\Q${extension}\E)$/;
-  _generate_proxy(sub {
+  _matcher(sub {
     if ($_[0]->{PATH_INFO} =~ $re) {
       ($wild ? ({}, $1) : {});
     } else {
@@ -104,15 +104,15 @@ sub match_extension {
 }
 
 sub match_query {
-  _generate_proxy(_param_matcher(query => $_[0]));
+  _matcher(_param_matcher(query => $_[0]));
 }
 
 sub match_body {
-  _generate_proxy(_param_matcher(body => $_[0]));
+  _matcher(_param_matcher(body => $_[0]));
 }
 
 sub match_uploads {
-  _generate_proxy(_param_matcher(uploads => $_[0]));
+  _matcher(_param_matcher(uploads => $_[0]));
 }
 
 sub _param_matcher {
similarity index 100%
rename from t/proxy-predicates.t
rename to t/predicate_objects.t
diff --git a/t/wd-http-methods.t b/t/wd-http-methods.t
new file mode 100644 (file)
index 0000000..ddb7340
--- /dev/null
@@ -0,0 +1,120 @@
+use strictures 1;
+use Test::More;
+
+{
+  package t::Web::Simple::HTTPMethods;
+
+  use Web::Simple;
+  use Web::Dispatch::HTTPMethods;
+
+  sub as_text {
+    [200, ['Content-Type' => 'text/plain'],
+      [$_[0]->{REQUEST_METHOD}, $_[0]->{REQUEST_URI}] ]
+  }
+
+  sub dispatch_request {
+    sub (/get) {
+      GET { as_text(pop) }
+    },
+    sub (/get-head-options) {
+      GET { as_text(pop) }
+      HEAD { [204,[],[]] }
+      OPTIONS { [204,[],[]] },
+    },
+    sub (/get-post-put) {
+      GET { as_text(pop) }
+      POST { as_text(pop) }
+      PUT { as_text(pop) }
+    },
+  }
+}
+
+ok my $app = t::Web::Simple::HTTPMethods->new,
+  'made app';
+
+for my $uri ('http://localhost/get-post-put') {
+
+  ## Check allowed methods and responses
+  for(ok my $res = $app->run_test_request(GET => $uri)) {
+    is $res->content, 'GET/get-post-put';
+  }
+
+  for(ok my $res = $app->run_test_request(POST => $uri)) {
+    is $res->content, 'POST/get-post-put';
+  }
+
+  for(ok my $res = $app->run_test_request(PUT => $uri)) {
+    is $res->content, 'PUT/get-post-put';
+  }
+
+  ## Since GET is allowed, check for implict HEAD
+  for(ok my $head = $app->run_test_request(HEAD => $uri)) {
+    is $head->code, 200;
+    is $head->content, '';
+  }
+
+  ## Check the implicit support for OPTIONS
+  for(ok my $options = $app->run_test_request(OPTIONS => $uri)) {
+    is $options->code, 200;
+    is $options->content, '';
+    is $options->header('Allow'), 'GET,HEAD,POST,PUT,OPTIONS';
+  }
+
+  ## Check implicitly added not allowed
+  for(ok my $not_allowed = $app->run_test_request(DELETE => $uri)) {
+    is $not_allowed->code, 405;
+    is $not_allowed->content, 'Method Not Allowed';
+    is $not_allowed->header('Allow'), 'GET,HEAD,POST,PUT,OPTIONS';
+  }
+
+}
+
+for my $uri ('http://localhost/get-head-options') {
+
+  ## Check allowed methods and responses
+  for(ok my $res = $app->run_test_request(GET => $uri)) {
+    is $res->content, 'GET/get-head-options';
+  }
+
+  for(ok my $head = $app->run_test_request(HEAD => $uri)) {
+    is $head->code, 204;
+    is $head->content, '';
+  }
+
+  for(ok my $options = $app->run_test_request(OPTIONS => $uri)) {
+    is $options->code, 204;
+    is $options->content, '';
+  }
+
+  ## Check implicitly added not allowed
+  for(ok my $not_allowed = $app->run_test_request(PUT => $uri)) {
+    is $not_allowed->code, 405;
+    is $not_allowed->content, 'Method Not Allowed';
+    is $not_allowed->header('Allow'), 'GET,HEAD,OPTIONS';
+  }
+
+}
+
+for my $uri ('http://localhost/get') {
+
+  ## Check allowed methods and responses
+  for(ok my $res = $app->run_test_request(GET => $uri)) {
+    is $res->content, 'GET/get';
+  }
+
+  ## Check implicitly added not allowed
+  for(ok my $not_allowed = $app->run_test_request(PUT => $uri)) {
+    is $not_allowed->code, 405;
+    is $not_allowed->content, 'Method Not Allowed';
+    is $not_allowed->header('Allow'), 'GET,HEAD,OPTIONS';
+  }
+
+  ## Since GET is allowed, check for implict HEAD
+  for(ok my $head = $app->run_test_request(HEAD => $uri)) {
+    is $head->code, 200;
+    is $head->content, '';
+  }
+
+}
+
+done_testing;