File Coverage

blib/lib/Limper.pm
Criterion Covered Total %
statement 14 175 8.0
branch 0 96 0.0
condition 0 83 0.0
subroutine 5 35 14.2
pod 19 25 76.0
total 38 414 9.1


line stmt bran cond sub pod time code
1             package Limper;
2             $Limper::VERSION = '0.015';
3 2     2   54826 use 5.10.0;
  2         14  
4 2     2   8 use strict;
  2         3  
  2         33  
5 2     2   6 use warnings;
  2         4  
  2         49  
6              
7 2     2   813 use IO::Socket;
  2         33310  
  2         7  
8              
9 2     2   639 use Exporter qw/import/;
  2         4  
  2         4745  
10             our @EXPORT = qw/get post put del trace options patch any status headers request response config hook limp/;
11             our @EXPORT_OK = qw/info warning rfc1123date/;
12              
13             # data stored here
14             my $request = {};
15             my $response = {};
16             my $config = {};
17             my $hook = {};
18             my $conn;
19              
20             # route subs
21             my $route = {};
22 0     0 1   sub get { push @{$route->{GET}}, @_; @_ }
  0            
  0            
23 0     0 1   sub post { push @{$route->{POST}}, @_; @_ }
  0            
  0            
24 0     0 1   sub put { push @{$route->{PUT}}, @_; @_ }
  0            
  0            
25 0     0 1   sub del { push @{$route->{DELETE}}, @_; @_ }
  0            
  0            
26 0     0 1   sub trace { push @{$route->{TRACE}}, @_; @_ }
  0            
  0            
27 0     0 1   sub options { push @{$route->{OPTIONS}}, @_; @_ }
  0            
  0            
28 0     0 1   sub patch { push @{$route->{PATCH}}, @_; @_ }
  0            
  0            
29 0     0 1   sub any { push @{$route->{$_}}, @_ for keys %$route }
  0            
30 0 0   0 1   sub routes { $_[0] ? $route->{uc $_[0]} : $route }
31              
32             # for send_response()
33             my $reasons = {
34             100 => 'Continue',
35             101 => 'Switching Protocols',
36             200 => 'OK',
37             201 => 'Created',
38             202 => 'Accepted',
39             203 => 'Non-Authoritative Information',
40             204 => 'No Content',
41             205 => 'Reset Content',
42             206 => 'Partial Content',
43             300 => 'Multiple Choices',
44             301 => 'Moved Permanently',
45             302 => 'Found',
46             303 => 'See Other',
47             304 => 'Not Modified',
48             305 => 'Use Proxy',
49             307 => 'Temporary Redirect',
50             400 => 'Bad Request',
51             401 => 'Unauthorized',
52             402 => 'Payment Required',
53             403 => 'Forbidden',
54             404 => 'Not Found',
55             405 => 'Method Not Allowed',
56             406 => 'Not Acceptable',
57             407 => 'Proxy Authentication Required',
58             408 => 'Request Time-out',
59             409 => 'Conflict',
60             410 => 'Gone',
61             411 => 'Length Required',
62             412 => 'Precondition Failed',
63             413 => 'Request Entity Too Large',
64             414 => 'Request-URI Too Large',
65             415 => 'Unsupported Media Type',
66             416 => 'Requested range not satisfiable',
67             417 => 'Expectation Failed',
68             500 => 'Internal Server Error',
69             501 => 'Not Implemented',
70             502 => 'Bad Gateway',
71             503 => 'Service Unavailable',
72             504 => 'Gateway Time-out',
73             505 => 'HTTP Version not supported',
74             };
75              
76             # for get_request()
77             my $method_rx = qr/(?: OPTIONS | GET | HEAD | POST | PUT | DELETE | TRACE | CONNECT )/x;
78             my $version_rx = qr{HTTP/\d+\.\d+};
79             my $uri_rx = qr/[^ ]+/;
80              
81             # Returns current time or passed timestamp as an HTTP 1.1 date
82             my @months = qw/Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec/;
83             my @days = qw/Sun Mon Tue Wed Thu Fri Sat/;
84             sub rfc1123date {
85 0 0   0 1   my ($sec, $min, $hour, $mday, $mon, $year, $wday) = @_ ? gmtime $_[0] : gmtime;
86 0           sprintf '%s, %02d %s %4d %02d:%02d:%02d GMT', $days[$wday], $mday, $months[$mon], $year + 1900, $hour, $min, $sec;
87             }
88              
89             # Formats date like "2014-08-17 00:12:41" in local time.
90             sub date {
91 0     0 0   my ($sec, $min, $hour, $mday, $mon, $year) = localtime;
92 0           sprintf '%04d-%02d-%02d %02d:%02d:%02d', $year + 1900, $mon + 1, $mday, $hour, $min, $sec;
93             }
94              
95             # Trivially log to STDOUT or STDERR
96 0     0 1   sub info { say date, ' ', @_ }
97 0     0 1   sub warning { warn date, ' ', @_ }
98              
99             sub timeout {
100 0     0 0   eval {
101 0     0     local $SIG{ALRM} = sub { die "alarm\n" };
  0            
102 0   0       alarm($config->{timeout} // 5);
103 0           $_ = $_[0]->();
104 0           alarm 0;
105             };
106 0 0 0       $@ ? ($conn->close and undef) : $_;
107             }
108              
109             sub bad_request {
110 0     0 0   warning "[$request->{remote_host}] bad request: $_[0]";
111 0           $response = { status => 400, body => 'Bad Request' };
112 0   0       send_response($request->{method} // '' eq 'HEAD', 'close');
113             }
114              
115             # Returns a processed request as a hash, or sends a 400 and closes if invalid.
116             sub get_request {
117 0   0 0 0   $request = { headers => {}, remote_host => $conn->peerhost // 'localhost' };
118 0           $response = { headers => {} };
119 0           my ($request_line, $headers_done, $chunked);
120 0           while (1) {
121 0 0   0     defined(my $line = timeout(sub { $conn->getline })) or last;
  0            
122 0 0         if (!defined $request_line) {
    0          
123 0 0         next if $line eq "\r\n";
124 0           ($request->{method}, $request->{uri}, $request->{version}) = $line =~ /^($method_rx) ($uri_rx) ($version_rx)\r\n/;
125 0 0         return bad_request $line unless defined $request->{method};
126             ($request->{scheme}, $request->{authority}, $request->{path}, $request->{query}, $request->{fragment}) =
127 0           $request->{uri} =~ m|(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?|; # from https://metacpan.org/pod/URI
128 0           $request_line = 1;
129             } elsif (!defined $headers_done) {
130 0 0         if ($line =~ /^\r\n/) {
131 0           $headers_done = 1;
132             } else {
133 0           my ($name, $value) = split /:[ \t]*/, $line, 2;
134 0 0         if ($name =~ /\r\n/) {
135 0           return bad_request $line;
136             }
137 0           $value =~ s/\r\n//;
138 0 0 0       $value = $1 if lc $name eq 'host' and $request->{version} eq 'HTTP/1.1' and $request->{uri} =~ s{^https?://(.+?)/}{/};
      0        
139 0 0         if (exists $request->{headers}{lc $name}) {
140 0 0         if (ref $request->{headers}{lc $name}) {
141 0           push @{$request->{headers}{lc $name}}, $value;
  0            
142             } else {
143 0           $request->{headers}{lc $name} = [$request->{headers}{lc $name}, $value];
144             }
145             } else {
146 0           $request->{headers}{lc $name} = $value;
147             }
148             }
149             }
150 0 0         if (defined $headers_done) {
151 0 0         return if defined $chunked;
152 0   0       info "[$request->{remote_host}] $request->{method} $request->{uri} $request->{version} [", $request->{headers}{'user-agent'} // '', ']';
153 0 0 0       return bad_request 'Host header missing' if $request->{version} eq 'HTTP/1.1' and (!exists $request->{headers}{host} or ref $request->{headers}{host});
      0        
154 0           for (keys %{$request->{headers}}) {
  0            
155 0 0 0       if ($_ eq 'expect' and lc $request->{headers}{$_} eq '100-continue' and $request->{version} eq 'HTTP/1.1') {
    0 0        
    0 0        
156 0           $conn->print("HTTP/1.1 100 Continue\r\n\r\n"); # this does not check if route is valid. just here to comply.
157             } elsif ($_ eq 'content-length') {
158 0     0     timeout(sub { $conn->read($request->{body}, $request->{headers}{$_}) });
  0            
159 0           last;
160             } elsif ($_ eq 'transfer-encoding' and lc $request->{headers}{$_} eq 'chunked') {
161 0           my $length = my $offset = $chunked = 0;
162 0           do {
163 0     0     $_ = timeout(sub { $conn->getline });
  0            
164 0           $length = hex((/^([A-Fa-f0-9]+)(?:;.*)?\r\n/)[0]);
165 0 0   0     timeout(sub { $conn->read($request->{body}, $length + 2, $offset) }) if $length;
  0            
166 0           $offset += $length;
167             } while $length;
168 0           $request->{body} =~ s/\r\n$//;
169 0           undef $headers_done; # to get optional footers, and another blank line
170             }
171             }
172 0 0         last if defined $headers_done;
173             }
174             }
175             }
176              
177             # Finds and calls the appropriate route sub, or sends a 404 response.
178             sub handle_request {
179 0     0 0   my $head = 1;
180 0 0 0       (defined $request->{method} and $request->{method} eq 'HEAD') ? ($request->{method} = 'GET') : ($head = 0);
181 0 0 0       if (defined $request->{method} and exists $route->{$request->{method}}) {
182 0           for (my $i = 0; $i < @{$route->{$request->{method}}}; $i += 2) {
  0            
183 0 0 0       if ($route->{$request->{method}}[$i] eq $request->{path} ||
      0        
184             ref $route->{$request->{method}}[$i] eq 'Regexp' and $request->{path} =~ $route->{$request->{method}}[$i]) {
185 0           $response->{body} = & { $route->{$request->{method}}[$i+1] };
  0            
186 0           return send_response($head);
187             }
188             }
189             }
190 0           $response->{body} = 'This is the void';
191 0           $response->{status} = 404;
192 0           send_response($head);
193             }
194              
195             # Sends a response to client. Default status is 200.
196             sub send_response {
197 0     0 0   my ($head, $connection) = @_;
198             $connection //= (($request->{version} // '') eq 'HTTP/1.1')
199             ? lc($request->{headers}{connection} // '')
200 0 0 0       : lc($request->{headers}{connection} // 'close') eq 'keep-alive' ? 'keep-alive' : 'close';
    0 0        
      0        
      0        
201 0   0       $response->{status} //= 200;
202 0           $response->{headers}{Date} = rfc1123date();
203 0 0 0       if (defined $response->{body} and !ref $response->{body}) {
204 0   0       $response->{headers}{'Content-Length'} //= length $response->{body};
205 0   0       $response->{headers}{'Content-Type'} //= 'text/plain';
206             }
207 0 0 0       delete $response->{body} if $head // 0;
208 0 0 0       $response->{headers}{Connection} = $connection if $connection eq 'close' or ($connection eq 'keep-alive' and $request->{version} ne 'HTTP/1.1');
      0        
209 0   0       $response->{headers}{Server} = 'limper/' . ($Limper::VERSION // 'pre-release');
210 0           $_->($request, $response) for @{$hook->{after}};
  0            
211 0 0         return $hook->{response_handler}[0]->() if exists $hook->{response_handler};
212             {
213 0           local $\ = "\r\n";
  0            
214 0   0       $conn->print(join ' ', $request->{version} // 'HTTP/1.1', $response->{status}, $response->{reason} // $reasons->{$response->{status}});
      0        
215 0 0         return unless $conn->connected;
216 0           my @headers = headers();
217 0           $conn->print( join(': ', splice(@headers, 0, 2)) ) while @headers;
218 0           $conn->print();
219             }
220 0 0 0       $conn->print($response->{body} // '') if defined $response->{body};
221 0 0         $conn->close if $connection eq 'close';
222             }
223              
224             sub status {
225 0 0   0 1   if (defined wantarray) {
226 0 0         wantarray ? ($response->{status}, $response->{reason}) : $response->{status};
227             } else {
228 0           $response->{status} = shift;
229 0 0         $response->{reason} = shift if @_;
230             }
231             }
232              
233             sub headers {
234 0 0   0 1   if (!defined wantarray) {
235 0           $response->{headers}{+pop} = pop while @_;
236             } else {
237 0           my @headers;
238 0           for my $key (keys %{ $response->{headers} }) {
  0            
239 0 0         if (ref $response->{headers}{$key}) {
240 0           push @headers, $key, $_ for @{$response->{headers}{$key}};
  0            
241             } else {
242 0           push @headers, $key, $response->{headers}{$key};
243             }
244             }
245 0           @headers;
246             }
247             }
248              
249 0     0 1   sub request { $request }
250              
251 0     0 1   sub response { $response }
252              
253 0     0 1   sub config { $config }
254              
255 0     0 1   sub hook { push @{$hook->{$_[0]}}, $_[1] }
  0            
256              
257             sub limp {
258 0 0   0 1   $config = shift @_ if ref $_[0] eq 'HASH';
259 0 0         return $hook->{request_handler}[0] if exists $hook->{request_handler};
260 0 0         my $sock = IO::Socket::INET->new(Listen => SOMAXCONN, ReuseAddr => 1, LocalAddr => 'localhost', LocalPort => 8080, Proto => 'tcp', @_)
261             or die "cannot bind to port: $!";
262              
263 0           info 'limper started';
264              
265 0   0       for (1 .. $config->{workers} // 5) {
266 0 0         defined(my $pid = fork) or die "fork failed: $!";
267 0           while (!$pid) {
268 0 0         if ($conn = $sock->accept()) {
269 0           do {
270 0           eval {
271 0           get_request;
272 0 0         handle_request if $conn->connected;
273             };
274 0 0         if ($@) {
275 0 0 0       $response = { status => 500, body => $config->{debug} // 0 ? $@ : 'Internal Server Error' };
276 0           send_response 0, 'close';
277 0           warning $@;
278             }
279             } while ($conn->connected);
280             }
281             }
282             }
283 0           1 while (wait != -1);
284              
285 0           my $shutdown = $sock->shutdown(2);
286 0           my $closed = $sock->close();
287 0 0         info 'shutdown ', $shutdown ? 'successful' : 'unsuccessful';
288 0 0         info 'closed ', $closed ? 'successful' : 'unsuccessful';
289             }
290              
291             1;
292              
293             __END__