package Limper; $Limper::VERSION = '0.015'; use 5.10.0; use strict; use warnings; use IO::Socket; use Exporter qw/import/; our @EXPORT = qw/get post put del trace options patch any status headers request response config hook limp/; our @EXPORT_OK = qw/info warning rfc1123date/; # data stored here my $request = {}; my $response = {}; my $config = {}; my $hook = {}; my $conn; # route subs my $route = {}; sub get { push @{$route->{GET}}, @_; @_ } sub post { push @{$route->{POST}}, @_; @_ } sub put { push @{$route->{PUT}}, @_; @_ } sub del { push @{$route->{DELETE}}, @_; @_ } sub trace { push @{$route->{TRACE}}, @_; @_ } sub options { push @{$route->{OPTIONS}}, @_; @_ } sub patch { push @{$route->{PATCH}}, @_; @_ } sub any { push @{$route->{$_}}, @_ for keys %$route } sub routes { $_[0] ? $route->{uc $_[0]} : $route } # for send_response() my $reasons = { 100 => 'Continue', 101 => 'Switching Protocols', 200 => 'OK', 201 => 'Created', 202 => 'Accepted', 203 => 'Non-Authoritative Information', 204 => 'No Content', 205 => 'Reset Content', 206 => 'Partial Content', 300 => 'Multiple Choices', 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified', 305 => 'Use Proxy', 307 => 'Temporary Redirect', 400 => 'Bad Request', 401 => 'Unauthorized', 402 => 'Payment Required', 403 => 'Forbidden', 404 => 'Not Found', 405 => 'Method Not Allowed', 406 => 'Not Acceptable', 407 => 'Proxy Authentication Required', 408 => 'Request Time-out', 409 => 'Conflict', 410 => 'Gone', 411 => 'Length Required', 412 => 'Precondition Failed', 413 => 'Request Entity Too Large', 414 => 'Request-URI Too Large', 415 => 'Unsupported Media Type', 416 => 'Requested range not satisfiable', 417 => 'Expectation Failed', 500 => 'Internal Server Error', 501 => 'Not Implemented', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Time-out', 505 => 'HTTP Version not supported', }; # for get_request() my $method_rx = qr/(?: OPTIONS | GET | HEAD | POST | PUT | DELETE | TRACE | CONNECT )/x; my $version_rx = qr{HTTP/\d+\.\d+}; my $uri_rx = qr/[^ ]+/; # Returns current time or passed timestamp as an HTTP 1.1 date my @months = qw/Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec/; my @days = qw/Sun Mon Tue Wed Thu Fri Sat/; sub rfc1123date { my ($sec, $min, $hour, $mday, $mon, $year, $wday) = @_ ? gmtime $_[0] : gmtime; sprintf '%s, %02d %s %4d %02d:%02d:%02d GMT', $days[$wday], $mday, $months[$mon], $year + 1900, $hour, $min, $sec; } # Formats date like "2014-08-17 00:12:41" in local time. sub date { my ($sec, $min, $hour, $mday, $mon, $year) = localtime; sprintf '%04d-%02d-%02d %02d:%02d:%02d', $year + 1900, $mon + 1, $mday, $hour, $min, $sec; } # Trivially log to STDOUT or STDERR sub info { say date, ' ', @_ } sub warning { warn date, ' ', @_ } sub timeout { eval { local $SIG{ALRM} = sub { die "alarm\n" }; alarm($config->{timeout} // 5); $_ = $_[0]->(); alarm 0; }; $@ ? ($conn->close and undef) : $_; } sub bad_request { warning "[$request->{remote_host}] bad request: $_[0]"; $response = { status => 400, body => 'Bad Request' }; send_response($request->{method} // '' eq 'HEAD', 'close'); } # Returns a processed request as a hash, or sends a 400 and closes if invalid. sub get_request { $request = { headers => {}, remote_host => $conn->peerhost // 'localhost' }; $response = { headers => {} }; my ($request_line, $headers_done, $chunked); while (1) { defined(my $line = timeout(sub { $conn->getline })) or last; if (!defined $request_line) { next if $line eq "\r\n"; ($request->{method}, $request->{uri}, $request->{version}) = $line =~ /^($method_rx) ($uri_rx) ($version_rx)\r\n/; return bad_request $line unless defined $request->{method}; ($request->{scheme}, $request->{authority}, $request->{path}, $request->{query}, $request->{fragment}) = $request->{uri} =~ m|(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?|; # from http://metacpan.org/pod/URI $request_line = 1; } elsif (!defined $headers_done) { if ($line =~ /^\r\n/) { $headers_done = 1; } else { my ($name, $value) = split /:[ \t]*/, $line, 2; if ($name =~ /\r\n/) { return bad_request $line; } $value =~ s/\r\n//; $value = $1 if lc $name eq 'host' and $request->{version} eq 'HTTP/1.1' and $request->{uri} =~ s{^http?://(.+?)/}{/}; if (exists $request->{headers}{lc $name}) { if (ref $request->{headers}{lc $name}) { push @{$request->{headers}{lc $name}}, $value; } else { $request->{headers}{lc $name} = [$request->{headers}{lc $name}, $value]; } } else { $request->{headers}{lc $name} = $value; } } } if (defined $headers_done) { return if defined $chunked; info "[$request->{remote_host}] $request->{method} $request->{uri} $request->{version} [", $request->{headers}{'user-agent'} // '', ']'; return bad_request 'Host header missing' if $request->{version} eq 'HTTP/1.1' and (!exists $request->{headers}{host} or ref $request->{headers}{host}); for (keys %{$request->{headers}}) { if ($_ eq 'expect' and lc $request->{headers}{$_} eq '100-continue' and $request->{version} eq 'HTTP/1.1') { $conn->print("HTTP/1.1 100 Continue\r\n\r\n"); # this does not check if route is valid. just here to comply. } elsif ($_ eq 'content-length') { timeout(sub { $conn->read($request->{body}, $request->{headers}{$_}) }); last; } elsif ($_ eq 'transfer-encoding' and lc $request->{headers}{$_} eq 'chunked') { my $length = my $offset = $chunked = 0; do { $_ = timeout(sub { $conn->getline }); $length = hex((/^([A-Fa-f0-9]+)(?:;.*)?\r\n/)[0]); timeout(sub { $conn->read($request->{body}, $length + 2, $offset) }) if $length; $offset += $length; } while $length; $request->{body} =~ s/\r\n$//; undef $headers_done; # to get optional footers, and another blank line } } last if defined $headers_done; } } } # Finds and calls the appropriate route sub, or sends a 404 response. sub handle_request { my $head = 1; (defined $request->{method} and $request->{method} eq 'HEAD') ? ($request->{method} = 'GET') : ($head = 0); if (defined $request->{method} and exists $route->{$request->{method}}) { for (my $i = 0; $i < @{$route->{$request->{method}}}; $i += 2) { if ($route->{$request->{method}}[$i] eq $request->{path} || ref $route->{$request->{method}}[$i] eq 'Regexp' and $request->{path} =~ $route->{$request->{method}}[$i]) { $response->{body} = & { $route->{$request->{method}}[$i+1] }; return send_response($head); } } } $response->{body} = 'This is the void'; $response->{status} = 404; send_response($head); } # Sends a response to client. Default status is 200. sub send_response { my ($head, $connection) = @_; $connection //= (($request->{version} // '') eq 'HTTP/1.1') ? lc($request->{headers}{connection} // '') : lc($request->{headers}{connection} // 'close') eq 'keep-alive' ? 'keep-alive' : 'close'; $response->{status} //= 200; $response->{headers}{Date} = rfc1123date(); if (defined $response->{body} and !ref $response->{body}) { $response->{headers}{'Content-Length'} //= length $response->{body}; $response->{headers}{'Content-Type'} //= 'text/plain'; } delete $response->{body} if $head // 0; $response->{headers}{Connection} = $connection if $connection eq 'close' or ($connection eq 'keep-alive' and $request->{version} ne 'HTTP/1.1'); $response->{headers}{Server} = 'limper/' . ($Limper::VERSION // 'pre-release'); $_->($request, $response) for @{$hook->{after}}; return $hook->{response_handler}[0]->() if exists $hook->{response_handler}; { local $\ = "\r\n"; $conn->print(join ' ', $request->{version} // 'HTTP/1.1', $response->{status}, $response->{reason} // $reasons->{$response->{status}}); return unless $conn->connected; my @headers = headers(); $conn->print( join(': ', splice(@headers, 0, 2)) ) while @headers; $conn->print(); } $conn->print($response->{body} // '') if defined $response->{body}; $conn->close if $connection eq 'close'; } sub status { if (defined wantarray) { wantarray ? ($response->{status}, $response->{reason}) : $response->{status}; } else { $response->{status} = shift; $response->{reason} = shift if @_; } } sub headers { if (!defined wantarray) { $response->{headers}{+pop} = pop while @_; } else { my @headers; for my $key (keys %{ $response->{headers} }) { if (ref $response->{headers}{$key}) { push @headers, $key, $_ for @{$response->{headers}{$key}}; } else { push @headers, $key, $response->{headers}{$key}; } } @headers; } } sub request { $request } sub response { $response } sub config { $config } sub hook { push @{$hook->{$_[0]}}, $_[1] } sub limp { $config = shift @_ if ref $_[0] eq 'HASH'; return $hook->{request_handler}[0] if exists $hook->{request_handler}; my $sock = IO::Socket::INET->new(Listen => SOMAXCONN, ReuseAddr => 1, LocalAddr => 'localhost', LocalPort => 8080, Proto => 'tcp', @_) or die "cannot bind to port: $!"; info 'limper started'; for (1 .. $config->{workers} // 5) { defined(my $pid = fork) or die "fork failed: $!"; while (!$pid) { if ($conn = $sock->accept()) { do { eval { get_request; handle_request if $conn->connected; }; if ($@) { $response = { status => 500, body => $config->{debug} // 0 ? $@ : 'Internal Server Error' }; send_response 0, 'close'; warning $@; } } while ($conn->connected); } } } 1 while (wait != -1); my $shutdown = $sock->shutdown(2); my $closed = $sock->close(); info 'shutdown ', $shutdown ? 'successful' : 'unsuccessful'; info 'closed ', $closed ? 'successful' : 'unsuccessful'; } 1; __END__ =for Pod::Coverage bad_request date get_request handle_request send_response timeout =head1 NAME Limper - extremely lightweight but not very powerful web application framework =head1 VERSION version 0.015 =head1 SYNOPSIS use Limper; my $generic = sub { 'yay' }; get '/' => $generic; post '/' => $generic; post qr{^/foo/} => sub { status 202, 'whatevs'; headers Foo => 'bar', Fizz => 'buzz'; 'you posted something: ' . request->{body}; }; get '/baz' => sub { 'your non-decoded query, if any: ' . request->{query}; # URIs of '/baz?fizz=buzz&foo=bar' now work }; limp; =head1 DESCRIPTION B<Limper> was originally designed to primarily be a simple HTTP/1.1 test server in perl, but I realized it can be much more than that while still remaining simple. B<Limper> has a simple syntax like L<Dancer> yet no dependencies at all, unlike the dozens that L<Dancer> pulls in. B<Limper> is modular - support for serving files, easily returning JSON, or using PSGI can be included if and only if needed (and these features already exist on CPAN). B<Limper> is fast - about 2-3 times faster than Dancer. B<Limper> also fatpacks beautifully (at least on 5.10.1): fatpack pack example.pl > example-packed.pl Do not taunt B<Limper>. =head1 EXPORTS The following are all exported by default: get post put del trace options patch any status headers request response config hook limp Also exportable: info warning rfc1123date Not exportable, because it is a footgun: routes =head1 FUNCTIONS =head2 get =head2 post =head2 put =head2 del =head2 trace =head2 options =head2 patch Defines a route handler for METHOD to the given path: get '/' => sub { 'Hello world!' }; These can be chained together like so: get post del '/' => sub { 'Hello world!' }; Note that a route to match B<HEAD> requests is automatically created as well for B<get>. =head2 any Defines a route handler for B<all> METHODs to the given path: any '/' => sub { 'Hello world!' }; =head2 routes Returns all routes for all verbs, or if passed an argument the routes for that verb. WARNING: this is the actual routing data, not a copy. Meaning you can completely break everything by modifying this unless you know what you're doing. =head2 status Get or set the response status, and optionally reason. status 404; status 401, 'Nope'; my $status = status; my ($status, $reason) = status; =head2 headers Get or set the response headers. headers Foo => 'bar', Fizz => 'buzz'; headers Foo => ['this', 'that']; # change Foo to two values headers Oops => 'inserted', Oops => 'ignored'; # don't do this my @headers = headers; Note: Changed in 0.012. The headers are now stored in a hashref, where the value is either a string or array of strings. Calling B<headers> in list mode returns a flattened list. If you want the hashref of headers, use B< response->{headers} >>. Setting header pairs no longer overwrites all previously defined headers. =head2 request Returns a B<HASH> of the request. Request keys are: B<method>, B<uri>, and B<version>. B<uri> is now broken down and there are additional keys: B<scheme>, B<authority>, B<path>, B<query>, and B<fragment>. It may also contain B<headers> which is a B<HASH> and B<body>. There is no decoding of the body content nor URL parameters. =head2 response Returns response B<HASH>. Keys are B<status>, B<reason>, B<headers> (a B<HASH> of key/value pairs), and B<body>. =head2 config Returns config B<HASH>. See B<limp> below for known config settings. =head2 hook Adds a hook at some position. Three hooks are currently defined: B<after>, B<request_handler>, and B<response_handler>. =head3 after Runs after all other processing, just before response is sent. hook after => sub { my ($request, $response) = @_; # modify response as needed }; =head3 request_handler Runs when B<limp> is called, after only setting passed config settings, and returns the result instead of starting up the built-in web server. A simplified example for PSGI (including the B<response_handler> below) is: hook request_handler => sub { get_psgi @_; handle_request; }; =head3 response_handler Runs right after the B<after> hook, and returns the result instead of using the built-in web server for sending the response. For PSGI, this is: hook response_handler => sub { [ response->{status}, [headers], ref response->{body} ? response->{body} : [response->{body}] ]; }; =head2 limp Starts the server. You can pass it the same options as L<IO::Socket::INET> takes. The default options are: Listen => SOMAXCONN, ReuseAddr => 1, LocalAddr => 'localhost', LocalPort => 8080, Proto => 'tcp' In addition, the first argument can be a B<HASH> to pass config settings: limp({debug => 1, timeout => 60, workers => 10}, LocalAddr => '0.0.0.0', LocalPort => 3001); Default debug is B<0>, default timeout is B<5> (seconds), and default workers is B<10>. A timeout of B<0> means never timeout. This keyword should be called at the very end of the script, once all routes are defined. At this point, Limper takes over control. =head1 ADDITIONAL FUNCTIONS =head2 info =head2 warning Log given list to B<STDOUT> or B<STDERR>. Prepends the current local time in format "YYYY-MM-DD HH:MM:SS". =head2 rfc1123date Returns the current time or passed timestamp as an HTTP 1.1 date (RFC 1123). =head1 EVEN MORE For additional (discouraged) functions to aid in transitioning to Limper, see L<Limper::Sugar>. For sending files and easily sending JSON, see L<Limper::SendFile> and L<Limper::SendJSON>. For differences between Limper and Dancer, see L<Limper::Differences>. For extending Limper, see L<Limper::Extending>. =head1 NOTICE This framework is still under development. Things B<may> change without warning. Version 0.012 has such changes, but I hope I have what is in this version stable. =head1 BREAKING CHANGES IN 0.012 B<options> is now B<config>, and there is a new function B<options> for the HTTP method. B<note> has been changed to B<info>. B<headers> now will update just the fields given, and not replace all the headers. The headers are now stored as a B<HASH> instead of an B<ARRAY>. Hence, C<< response->{headers} >> cannot be directly passed to PSGI. Instead C<< [headers] >> meets this need. C<< request->{header} >> is now what C<< request->{hheader} >> was - no more ARRAY form. =head1 CONTRIBUTING =head2 Patches and Bug Fixes Preferably, clone the repo (uses L<Dist::Zilla>) and create one or more patch files with: git format patch <latest commit> Email me the patch, or otherwise let me know how to find it. Or if it's a simple patch and you don't want to mess with L<Dist::Zilla>, patch the latest release and send me a patch file. =head2 Module Namespaces See L<Limper::Extending/NAMESPACES>. =head1 COPYRIGHT AND LICENSE Copyright (C) 2014 by Ashley Willis E<lt>ashley+perl@gitable.orgE<gt> B<rabcyr> on irc and L<twitter|http://twitter.com/rabcyr>. This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself, either Perl version 5.12.4 or, at your option, any later version of Perl 5 you may have available. =head1 SEE ALSO L<Limper::Differences> L<Limper::Extending> L<Limper::Engine::PSGI> L<Limper::SendFile> L<Limper::SendJSON> L<Limper::Sugar> L<App::FatPacker> L<IO::Socket::INET> L<Web::Simple>