File Coverage

lib/HTTP/Handy.pm
Criterion Covered Total %
statement 148 295 50.1
branch 47 146 32.1
condition 24 72 33.3
subroutine 27 31 87.1
pod 10 10 100.0
total 256 554 46.2


"; '; ";
line stmt bran cond sub pod time code
1             package HTTP::Handy;
2             ######################################################################
3             #
4             # HTTP::Handy - A tiny HTTP/1.0 server for Perl 5.5.3+
5             #
6             # https://metacpan.org/dist/HTTP-Handy
7             #
8             # Copyright (c) 2026 INABA Hitoshi
9             ######################################################################
10             #
11             # Compatible : Perl 5.005_03 and later
12             # Platform : Windows and UNIX/Linux
13             #
14             ######################################################################
15              
16 6     6   251063 use 5.00503; # Universal Consensus 1998 for primetools
  6         27  
17             # Perl 5.005_03 compatibility for historical toolchains
18             # use 5.008001; # Lancaster Consensus 2013 for toolchains
19              
20 6     6   39 use strict;
  6         16  
  6         485  
21 6 50 33 6   204 BEGIN { if ($] < 5.006 && !defined(&warnings::import)) { $INC{'warnings.pm'} = 'stub'; eval 'package warnings; sub import {}' } }
  0         0  
  0         0  
22 6     6   35 use warnings; local $^W = 1;
  6         7  
  6         500  
23 6 50   6   146 BEGIN { pop @INC if $INC[-1] eq '.' }
24              
25 6     6   16762 use IO::Socket;
  6         105213  
  6         31  
26 6     6   2896 use Carp qw(croak);
  6         14  
  6         359  
27 6     6   29 use vars qw($VERSION $ACCESS_LOG_FH $CURRENT_LOG_FILE);
  6         9  
  6         30252  
28             $VERSION = '1.03';
29             $VERSION = $VERSION;
30             # $VERSION self-assignment suppresses "used only once" warning under strict.
31              
32             ###############################################################################
33             # Status text map
34             ###############################################################################
35             my %STATUS_TEXT = (
36             200 => 'OK',
37             201 => 'Created',
38             204 => 'No Content',
39             301 => 'Moved Permanently',
40             302 => 'Found',
41             304 => 'Not Modified',
42             400 => 'Bad Request',
43             403 => 'Forbidden',
44             404 => 'Not Found',
45             405 => 'Method Not Allowed',
46             413 => 'Request Entity Too Large',
47             500 => 'Internal Server Error',
48             );
49              
50             ###############################################################################
51             # MIME type map
52             ###############################################################################
53             my %MIME = (
54             'html' => 'text/html; charset=utf-8',
55             'htm' => 'text/html; charset=utf-8',
56             'txt' => 'text/plain; charset=utf-8',
57             'text' => 'text/plain; charset=utf-8',
58             'css' => 'text/css',
59             'js' => 'application/javascript',
60             'json' => 'application/json',
61             'xml' => 'application/xml',
62             'png' => 'image/png',
63             'jpg' => 'image/jpeg',
64             'jpeg' => 'image/jpeg',
65             'gif' => 'image/gif',
66             'ico' => 'image/x-icon',
67             'svg' => 'image/svg+xml',
68             'pdf' => 'application/pdf',
69             'zip' => 'application/zip',
70             'gz' => 'application/gzip',
71             'ltsv' => 'text/plain; charset=utf-8',
72             'csv' => 'text/csv; charset=utf-8',
73             'tsv' => 'text/tab-separated-values; charset=utf-8',
74             );
75              
76             # Default max POST body size: 10MB
77             my $DEFAULT_MAX_POST_SIZE = 10 * 1024 * 1024;
78              
79             # Access log variables (package variables for testability; declared via use vars above)
80             $ACCESS_LOG_FH = undef;
81             $CURRENT_LOG_FILE = '';
82              
83             ###############################################################################
84             # run - Start the server (blocking)
85             ###############################################################################
86             sub run {
87 0     0 1 0 my ($class, %args) = @_;
88              
89 0 0       0 my $app = $args{app} or croak "HTTP::Handy->run: 'app' is required";
90 0 0       0 my $host = defined $args{host} ? $args{host} : '0.0.0.0';
91 0 0       0 my $port = defined $args{port} ? $args{port} : 8080;
92 0 0       0 my $log = defined $args{log} ? $args{log} : 1;
93 0 0       0 my $max_post_size = defined $args{max_post_size} ? $args{max_post_size} : $DEFAULT_MAX_POST_SIZE;
94              
95 0 0       0 ref($app) eq 'CODE' or croak "HTTP::Handy->run: 'app' must be a code reference";
96 0 0       0 $port =~ /^\d+$/ or croak "HTTP::Handy->run: 'port' must be a number";
97 0 0       0 $max_post_size =~ /^\d+$/ or croak "HTTP::Handy->run: 'max_post_size' must be a number";
98              
99 0         0 my $server = IO::Socket::INET->new(
100             LocalAddr => $host,
101             LocalPort => $port,
102             Proto => 'tcp',
103             Listen => 10,
104             ReuseAddr => 1,
105             );
106 0 0       0 unless ($server) {
107 0         0 croak "HTTP::Handy: Cannot bind to $host:$port - $@";
108             }
109              
110             # Create Apache-like directories
111 0         0 _init_directories();
112              
113 0 0       0 _log_message("HTTP::Handy $HTTP::Handy::VERSION started on http://$host:$port/") if $log;
114 0 0       0 _log_message("Press Ctrl+C to stop.") if $log;
115              
116 0         0 while (1) {
117 0         0 my $client = $server->accept;
118 0 0       0 unless ($client) {
119 0 0       0 _log_message("Accept failed: $!") if $log;
120 0         0 next;
121             }
122              
123             # Disable CRLF translation on Windows
124 0         0 binmode $client;
125              
126 0         0 eval {
127 0         0 _handle_connection($client, $app, $log, $max_post_size, $port);
128             };
129 0 0       0 if ($@) {
130 0 0       0 _log_message("Error handling connection: $@") if $log;
131             }
132              
133 0         0 close $client;
134             }
135             }
136              
137             ###############################################################################
138             # _handle_connection - Parse request and dispatch to app
139             ###############################################################################
140             sub _handle_connection {
141 0     0   0 my ($client, $app, $log, $max_post_size, $server_port) = @_;
142              
143             # Read request line
144 0         0 my $request_line = _read_line($client);
145 0 0 0     0 return unless defined $request_line && $request_line ne '';
146              
147 0         0 $request_line =~ s/\r?\n$//;
148              
149 0         0 my ($method, $request_uri, $http_version) = split /\s+/, $request_line, 3;
150              
151             # Only allow GET and POST
152 0 0 0     0 unless (defined $method && ($method eq 'GET' || $method eq 'POST')) {
      0        
153 0         0 _send_error($client, 405, 'Method Not Allowed');
154 0         0 return;
155             }
156              
157             # Parse URI into path and query
158 0         0 my ($path, $query_string) = ('/', '');
159 0 0       0 if (defined $request_uri) {
160 0 0       0 if ($request_uri =~ /^([^?]*)\?(.*)$/) {
161 0         0 $path = $1;
162 0         0 $query_string = $2;
163             }
164             else {
165 0         0 $path = $request_uri;
166             }
167             }
168 0 0 0     0 $path = '/' unless defined $path && $path ne '';
169              
170             # Read headers
171 0         0 my %headers;
172 0         0 while (1) {
173 0         0 my $line = _read_line($client);
174 0 0       0 last unless defined $line;
175 0         0 $line =~ s/\r?\n$//;
176 0 0       0 last if $line eq '';
177              
178 0 0       0 if ($line =~ /^([^:]+):\s*(.*)$/) {
179 0         0 my ($name, $value) = ($1, $2);
180             # Normalize: lowercase, then convert to HTTP_* style
181 0         0 $name = lc $name;
182 0         0 $headers{$name} = $value;
183             }
184             }
185              
186             # Build $env
187 0   0     0 my $server_name = $headers{'host'} || 'localhost';
188 0         0 $server_name =~ s/:\d+$//; # strip port from Host header
189              
190             # SERVER_PORT: prefer the port from Host header if present,
191             # otherwise use the actual bound port passed from run().
192 0 0 0     0 my $env_port = ($headers{'host'} && $headers{'host'} =~ /:(\d+)$/)
193             ? int($1)
194             : $server_port;
195              
196 0   0     0 my $content_length = $headers{'content-length'} || 0;
197 0         0 $content_length = int($content_length);
198              
199 0 0       0 if ($content_length > $max_post_size) {
200 0         0 _send_error($client, 413, 'Request Entity Too Large');
201 0         0 return;
202             }
203              
204             # Read POST body
205 0         0 my $post_body = '';
206 0 0 0     0 if ($method eq 'POST' && $content_length > 0) {
207 0         0 read($client, $post_body, $content_length);
208             }
209              
210             # Build psgi.input as an in-memory filehandle
211             # For 5.5.3 compatibility, use a temp file approach via a simple object
212 0         0 my $input = HTTP::Handy::Input->new($post_body);
213              
214             my %env = (
215             'REQUEST_METHOD' => $method,
216             'PATH_INFO' => $path,
217             'QUERY_STRING' => $query_string,
218             'SERVER_NAME' => $server_name,
219             'SERVER_PORT' => $env_port,
220 0   0     0 'CONTENT_TYPE' => $headers{'content-type'} || '',
221             'CONTENT_LENGTH' => $content_length,
222             'psgi.input' => $input,
223             'psgi.errors' => \*STDERR,
224             'psgi.url_scheme' => 'http',
225             );
226              
227             # Add HTTP_* headers
228 0         0 for my $name (keys %headers) {
229 0         0 my $key = 'HTTP_' . uc($name);
230 0         0 $key =~ s/-/_/g;
231 0         0 $env{$key} = $headers{$name};
232             }
233              
234             # Dispatch to app
235 0         0 my $response;
236 0         0 eval {
237 0         0 $response = $app->(\%env);
238             };
239 0 0       0 if ($@) {
240 0         0 my $err = $@;
241 0         0 _log_message("App error: $err");
242 0         0 _send_error($client, 500, 'Internal Server Error');
243 0         0 return;
244             }
245              
246             # Validate response
247 0 0 0     0 unless (ref($response) eq 'ARRAY' && scalar(@$response) == 3) {
248 0         0 _send_error($client, 500, 'Internal Server Error');
249 0         0 return;
250             }
251              
252 0         0 my ($status, $resp_headers, $body) = @$response;
253              
254             # Send response
255 0   0     0 my $status_text = $STATUS_TEXT{$status} || 'Unknown';
256 0         0 my $response_str = "HTTP/1.0 $status $status_text\r\n";
257 0         0 $response_str .= "Connection: close\r\n";
258              
259             # Process response headers (flat array: key, value, key, value, ...)
260 0         0 my @header_list;
261 0 0       0 if (ref($resp_headers) eq 'ARRAY') {
262 0         0 my @h = @$resp_headers;
263 0         0 while (@h) {
264 0   0     0 my $k = shift(@h) || '';
265 0   0     0 my $v = shift(@h) || '';
266 0         0 push @header_list, "$k: $v";
267             }
268             }
269 0 0       0 $response_str .= join("\r\n", @header_list) . "\r\n" if @header_list;
270 0         0 $response_str .= "\r\n";
271              
272             # Build body
273 0         0 my $body_str = '';
274 0 0       0 if (ref($body) eq 'ARRAY') {
275 0 0       0 $body_str = join('', @$body) if @$body;
276             }
277              
278 0         0 my $body_length = length($body_str);
279 0         0 $response_str .= $body_str;
280              
281 0         0 print $client $response_str;
282              
283             # Access log in LTSV format.
284             # Sanitize field values: LTSV forbids tab and newline characters in values.
285 0 0       0 if ($log) {
286 0         0 my $ts = _iso_time();
287 0   0     0 my $ua = $headers{'user-agent'} || '';
288 0   0     0 my $referer = $headers{'referer'} || '';
289 0         0 $ua =~ s/[\t\n\r]/ /g;
290 0         0 $referer =~ s/[\t\n\r]/ /g;
291 0         0 my $line = join("\t",
292             "time:$ts",
293             "method:$method",
294             "path:$path",
295             "status:$status",
296             "size:$body_length",
297             "ua:$ua",
298             "referer:$referer",
299             ) . "\n";
300 0         0 print STDERR $line;
301              
302 0         0 _open_access_log();
303 0 0       0 print $ACCESS_LOG_FH $line if $ACCESS_LOG_FH;
304             }
305             }
306              
307             ###############################################################################
308             # _read_line - Read one line from socket (CR+LF or LF terminated)
309             ###############################################################################
310             sub _read_line {
311 0     0   0 my ($fh) = @_;
312 0         0 my $line = '';
313 0         0 my $char;
314 0         0 while (read($fh, $char, 1)) {
315 0         0 $line .= $char;
316 0 0       0 last if $char eq "\n";
317             # Safety limit: no header line should exceed 8KB
318 0 0       0 return undef if length($line) > 8192;
319             }
320 0 0       0 return $line eq '' ? undef : $line;
321             }
322              
323             ###############################################################################
324             # _send_error - Send a simple HTTP error response
325             ###############################################################################
326             sub _send_error {
327 0     0   0 my ($client, $code, $message) = @_;
328 0   0     0 my $text = $STATUS_TEXT{$code} || $message;
329 0         0 my $body = "$code $text"
330             . "

$code $text

$message

"
331             . "
HTTP::Handy/$HTTP::Handy::VERSION";
332 0         0 print $client "HTTP/1.0 $code $text\r\n";
333 0         0 print $client "Content-Type: text/html\r\n";
334 0         0 print $client "Content-Length: " . length($body) . "\r\n";
335 0         0 print $client "Connection: close\r\n";
336 0         0 print $client "\r\n";
337 0         0 print $client $body;
338             }
339              
340             ###############################################################################
341             # _log_message - Print timestamped log to STDERR
342             ###############################################################################
343             sub _log_message {
344 2     2   580 my ($msg) = @_;
345              
346 2         16 my $ts = _iso_time();
347 2         14 print STDERR "[$ts] $msg\n";
348              
349 2         15 my $fh;
350 2 50       8 if ($] >= 5.006) {
351             # Avoid "Too many arguments for open at" error when running with Perl 5.005_03
352 2 50       190 eval q{ open($fh, '>>', 'logs/error/error.log') } or return;
353             }
354             else {
355 0         0 $fh = \do { local *_ };
  0         0  
356 0 0       0 open($fh, ">> logs/error/error.log") or return;
357             }
358 2         12 binmode $fh;
359              
360 2         20 print $fh "[$ts] $msg\n";
361 2         98 close $fh;
362             }
363              
364             ###############################################################################
365             # serve_static - Serve files from a document root
366             ###############################################################################
367             sub serve_static {
368 20     20 1 212321 my ($class, $env, $docroot, %opts) = @_;
369              
370 20   100     108 $docroot ||= '.';
371             # Remove trailing slash
372 20         98 $docroot =~ s{[/\\]$}{};
373              
374 20   50     74 my $path = $env->{PATH_INFO} || '/';
375              
376             # Prevent path traversal via ".."
377 20 100       94 if ($path =~ /\.\./) {
378 3         21 return [403, ['Content-Type', 'text/plain'], ['Forbidden']];
379             }
380              
381             # Normalize separators on Windows
382 17         35 $path =~ s{\\}{/}g;
383              
384             # Strip leading slashes to prevent absolute path injection
385 17         163 $path =~ s{^/+}{/};
386              
387 17         35 my $file = $docroot . $path;
388              
389             # Directory: try index.html
390 17 100       361 if (-d $file) {
391 2         13 $file =~ s{/?$}{/index.html};
392             }
393              
394 17 100       139 unless (-f $file) {
395 2         16 return [404, ['Content-Type', 'text/plain'], ['Not Found']];
396             }
397              
398             # Determine MIME type from extension
399 15         53 my $ext = '';
400 15 50       91 if ($file =~ /\.([^.]+)$/) {
401 15         64 $ext = lc $1;
402             }
403 15   100     115 my $mime = $MIME{$ext} || 'application/octet-stream';
404              
405             # Read file
406 15         21 my $fh;
407 15 50       42 if ($] >= 5.006) {
408             # Avoid "Too many arguments for open at" error when running with Perl 5.005_03
409 15 50       1440 unless (eval q{ open($fh, '<', $file) }) {
410 0         0 return [403, ['Content-Type', 'text/plain'], ['Forbidden']];
411             }
412             }
413             else {
414 0         0 $fh = \do { local *_ };
  0         0  
415 0 0       0 unless (open($fh, "<$file")) {
416 0         0 return [403, ['Content-Type', 'text/plain'], ['Forbidden']];
417             }
418             }
419 15         113 binmode $fh;
420 15         68 local $/;
421 15         405 my $content = <$fh>;
422 15         172 close $fh;
423              
424             # Cache-Control header
425 15         26 my @cache_headers;
426 15 100       47 if (exists $opts{cache_max_age}) {
427 3         13 my $age = int($opts{cache_max_age});
428 3 100       12 if ($age > 0) {
429 2         12 push @cache_headers, 'Cache-Control', "public, max-age=$age";
430             }
431             else {
432 1         8 push @cache_headers, 'Cache-Control', 'no-cache';
433             }
434             }
435             else {
436             # Default: no-cache (safe for development use)
437 12         28 push @cache_headers, 'Cache-Control', 'no-cache';
438             }
439              
440 15         210 return [200,
441             ['Content-Type', $mime,
442             'Content-Length', length($content),
443             @cache_headers],
444             [$content]];
445             }
446              
447             ###############################################################################
448             # url_decode - Decode percent-encoded string
449             ###############################################################################
450             sub url_decode {
451 29     29 1 124967 my ($class, $str) = @_;
452 29 100       37 return '' unless defined $str;
453 28         26 $str =~ s/\+/ /g;
454 28         37 $str =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
  10         19  
455 28         66 return $str;
456             }
457              
458             ###############################################################################
459             # parse_query - Parse query string into hash
460             ###############################################################################
461             sub parse_query {
462 8     8 1 77 my ($class, $query) = @_;
463 8 100 100     23 return () unless defined $query && $query ne '';
464 6         6 my %params;
465 6         9 for my $pair (split /&/, $query) {
466 10         15 my ($key, $val) = split /=/, $pair, 2;
467 10 50       14 next unless defined $key;
468 10         12 $key = $class->url_decode($key);
469 10 100       15 $val = defined $val ? $class->url_decode($val) : '';
470 10 100       12 if (exists $params{$key}) {
471 2 100       9 if (ref $params{$key} eq 'ARRAY') {
472 1         2 push @{$params{$key}}, $val;
  1         3  
473             }
474             else {
475 1         3 $params{$key} = [$params{$key}, $val];
476             }
477             }
478             else {
479 8         13 $params{$key} = $val;
480             }
481             }
482 6         17 return %params;
483             }
484              
485             ###############################################################################
486             # mime_type - Return MIME type for a file extension
487             ###############################################################################
488             sub mime_type {
489 11     11 1 20 my ($class, $ext) = @_;
490 11         16 $ext = lc $ext;
491 11         17 $ext =~ s/^\.//;
492 11   100     59 return $MIME{$ext} || 'application/octet-stream';
493             }
494              
495             ###############################################################################
496             # is_htmx - Return true if the request was made by htmx
497             ###############################################################################
498             sub is_htmx {
499 5     5 1 10 my ($class, $env) = @_;
500 5 100 100     15 return (defined $env->{HTTP_HX_REQUEST} && $env->{HTTP_HX_REQUEST} eq 'true') ? 1 : 0;
501             }
502              
503             ###############################################################################
504             # response_redirect - Build a redirect response
505             ###############################################################################
506             sub response_redirect {
507 2     2 1 40 my ($class, $location, $code) = @_;
508 2   100     5 $code ||= 302;
509 2         7 return [$code,
510             ['Location', $location,
511             'Content-Type', 'text/plain'],
512             ["Redirect to $location"]];
513             }
514              
515             ###############################################################################
516             # response_json - Build a JSON response (no JSON encoding, caller provides)
517             ###############################################################################
518             sub response_json {
519 1     1 1 26 my ($class, $json_str, $code) = @_;
520 1   50     7 $code ||= 200;
521 1         3 return [$code,
522             ['Content-Type', 'application/json',
523             'Content-Length', length($json_str)],
524             [$json_str]];
525             }
526              
527             ###############################################################################
528             # response_html - Build an HTML response
529             ###############################################################################
530             sub response_html {
531 3     3 1 52 my ($class, $html, $code) = @_;
532 3   100     9 $code ||= 200;
533 3         10 return [$code,
534             ['Content-Type', 'text/html; charset=utf-8',
535             'Content-Length', length($html)],
536             [$html]];
537             }
538              
539             ###############################################################################
540             # response_text - Build a plain text response
541             ###############################################################################
542             sub response_text {
543 1     1 1 7 my ($class, $text, $code) = @_;
544 1   50     6 $code ||= 200;
545 1         3 return [$code,
546             ['Content-Type', 'text/plain; charset=utf-8',
547             'Content-Length', length($text)],
548             [$text]];
549             }
550              
551             ###############################################################################
552             # _init_directories - Create Apache-like directories
553             ###############################################################################
554             sub _init_directories {
555 3     3   6431 for my $dir (qw(
556             logs
557             logs/access
558             logs/error
559             run
560             htdocs
561             conf
562             )) {
563 18 100       903 mkdir($dir, 0777) unless -d $dir;
564             }
565             }
566              
567             ###############################################################################
568             # _open_access_log - Opens the access log file (YYYYMMDDHHm0.log.ltsv format)
569             ###############################################################################
570             sub _open_access_log {
571 2     2   164 my ($year, $month, $day, $hour, $min) = (localtime)[5, 4, 3, 2, 1];
572 2         25 my $current_log_filename = sprintf("logs/access/%04d%02d%02d%02d%02d.log.ltsv",
573             1900 + $year,
574             $month + 1,
575             $day,
576             $hour,
577             int($min / 10) * 10,
578             );
579              
580 2 100 66     32 return if defined $ACCESS_LOG_FH && $current_log_filename eq $CURRENT_LOG_FILE;
581              
582 1 50       15 if ($ACCESS_LOG_FH) {
583 0         0 close $ACCESS_LOG_FH;
584             }
585              
586 1         4 my $fh;
587 1 50       10 if ($] >= 5.006) {
588             # Avoid "Too many arguments for open at" error when running with Perl 5.005_03
589 1 50       224 eval q{ open($fh, '>>', $current_log_filename) } or do {
590 0         0 warn "Cannot open access log: $current_log_filename: $!";
591 0         0 return;
592             };
593             }
594             else {
595 0         0 $fh = \do { local *_ };
  0         0  
596 0 0       0 open($fh, ">> $current_log_filename") or do {
597 0         0 warn "Cannot open access log: $current_log_filename: $!";
598 0         0 return;
599             };
600             }
601 1         9 binmode $fh;
602              
603 1         19 select((select($fh), $| = 1)[0]); # autoflush
604              
605 1         21 $ACCESS_LOG_FH = $fh;
606 1         12 $CURRENT_LOG_FILE = $current_log_filename;
607             }
608              
609             ###############################################################################
610             # _iso_time - Returns localtime as ISO style (YYYY-MM-DDTHH:mm:SS)
611             ###############################################################################
612             sub _iso_time {
613 7     7   189866 my ($year, $month, $day, $hour, $min, $sec) = (localtime)[5, 4, 3, 2, 1, 0];
614 7         58 return sprintf("%04d-%02d-%02dT%02d:%02d:%02d",
615             1900 + $year,
616             $month + 1,
617             $day,
618             $hour,
619             $min,
620             $sec,
621             );
622             }
623              
624             # ----------------------------------------------------------------
625             # HTTP::Handy::Input - Minimal in-memory filehandle for psgi.input
626             # Compatible with Perl 5.5.3 (no open with scalar ref)
627             # ----------------------------------------------------------------
628             package HTTP::Handy::Input;
629              
630             sub new {
631 2     2   40 my ($class, $data) = @_;
632 2 50       4 $data = '' unless defined $data;
633 2         7 return bless { data => $data, pos => 0 }, $class;
634             }
635              
636             sub read {
637             # Note: $_[1] is the caller's buffer variable -- modified in place.
638             # We do NOT include it in the my() list because:
639             # (a) we must write back via $_[1], not a copy, and
640             # (b) "my ($self, undef, $length)" requires Perl 5.10+.
641 2     2   8 my $self = $_[0];
642 2         7 my $length = $_[2];
643 2   50     6 my $offset = $_[3] || 0;
644 2         11 my $remaining = length($self->{data}) - $self->{pos};
645 2 100       5 $length = $remaining if $length > $remaining;
646 2 100       4 return 0 if $length <= 0;
647 1         2 my $chunk = substr($self->{data}, $self->{pos}, $length);
648 1         2 $self->{pos} += $length;
649             # Write into $_[1] at $offset (like POSIX read)
650 1         2 substr($_[1], $offset) = $chunk;
651 1         2 return $length;
652             }
653              
654             sub seek {
655 2     2   7 my ($self, $pos, $whence) = @_;
656 2   50     10 $whence ||= 0;
657 2 50       3 if ($whence == 0) {
    0          
    0          
658 2         3 $self->{pos} = $pos;
659             }
660             elsif ($whence == 1) {
661 0         0 $self->{pos} += $pos;
662             }
663             elsif ($whence == 2) {
664 0         0 $self->{pos} = length($self->{data}) + $pos;
665             }
666 2 50       3 $self->{pos} = 0 if $self->{pos} < 0;
667 2         4 return 1;
668             }
669              
670             sub tell {
671 2     2   12 my ($self) = @_;
672 2         5 return $self->{pos};
673             }
674              
675             sub getline {
676 7     7   20 my ($self) = @_;
677 7 100       15 return undef if $self->{pos} >= length($self->{data});
678 4         6 my $nl = index($self->{data}, "\n", $self->{pos});
679 4         3 my $line;
680 4 50       6 if ($nl < 0) {
681 0         0 $line = substr($self->{data}, $self->{pos});
682 0         0 $self->{pos} = length($self->{data});
683             }
684             else {
685 4         6 $line = substr($self->{data}, $self->{pos}, $nl - $self->{pos} + 1);
686 4         8 $self->{pos} = $nl + 1;
687             }
688 4         8 return $line;
689             }
690              
691             sub getlines {
692 1     1   3 my ($self) = @_;
693 1         3 my @lines;
694 1         2 while (defined(my $line = $self->getline)) {
695 2         3 push @lines, $line;
696             }
697 1         3 return @lines;
698             }
699              
700             ###############################################################################
701             # Back to main package -- demo/self-test when run directly
702             ###############################################################################
703             package HTTP::Handy;
704              
705             # Run as script: perl lib/HTTP/Handy.pm [port]
706             unless (caller) {
707             my $port = $ARGV[0] || 8080;
708              
709             my $demo_app = sub {
710             my $env = shift;
711             my $method = $env->{REQUEST_METHOD};
712             my $path = $env->{PATH_INFO};
713             my $query = $env->{QUERY_STRING};
714              
715             # Route: GET /
716             if ($method eq 'GET' && $path eq '/') {
717             my $html = <<'HTML';
718            
719            
720             HTTP::Handy Demo
721            
729            
730            
731            

HTTP::Handy Demo

732            

A tiny HTTP/1.0 server running on Perl 5.5.3+.

733            

GET with query string

734            
735            
736            
737            
738            

POST form

739            
740            
741            
742            
743            
744            

Server info

745            
746            
747             HTML
748             return HTTP::Handy->response_html($html);
749             }
750              
751             # Route: GET or POST /echo
752             if ($path eq '/echo') {
753             my %params;
754             if ($method eq 'GET') {
755             %params = HTTP::Handy->parse_query($query);
756             }
757             elsif ($method eq 'POST') {
758             my $body = '';
759             $env->{'psgi.input'}->read($body, $env->{CONTENT_LENGTH} || 0);
760             %params = HTTP::Handy->parse_query($body);
761             }
762              
763             my $params_html = '';
764             for my $key (sort keys %params) {
765             my $val = $params{$key};
766             $val = ref($val) eq 'ARRAY' ? join(', ', @$val) : $val;
767             # simple HTML escape
768             $val =~ s/&/&/g;
769             $val =~ s/
770             $val =~ s/>/>/g;
771             $key =~ s/&/&/g;
772             $key =~ s/
773             $params_html .= "
$key$val
774             }
775             $params_html ||= '
(no parameters)
776              
777             my $html = <<"HTML";
778            
779             Echo
780            
783            
784            

Echo: $method $path

785             $params_html
786            

Back

787            
788             HTML
789             return HTTP::Handy->response_html($html);
790             }
791              
792             # Route: GET /info
793             if ($method eq 'GET' && $path eq '/info') {
794             my $env_html = '';
795             for my $key (sort keys %$env) {
796             next if $key eq 'psgi.input' || $key eq 'psgi.errors';
797             my $val = $env->{$key};
798             $val = '' unless defined $val;
799             $val =~ s/&/&/g;
800             $val =~ s/
801             $env_html .= "
$key$val
802             }
803             my $html = <<"HTML";
804            
805             Server Info
806            
810            
811            

PSGI Environment

812             $env_html
813            

Back

814            
815             HTML
816             return HTTP::Handy->response_html($html);
817             }
818              
819             # 404 fallback
820             return [404,
821             ['Content-Type', 'text/html'],
822             ["

404 Not Found

$path

Home"]];
823             };
824              
825             HTTP::Handy->run(app => $demo_app, port => $port);
826             }
827              
828             1;
829              
830             __END__