Skip to content

Commit

Permalink
Case insensitive headers (#64)
Browse files Browse the repository at this point in the history
* Case insensitive headers

* Clean up + bump version

* Fix connection header
  • Loading branch information
rawleyfowler authored Nov 5, 2023
1 parent 74286ea commit d2b05d6
Show file tree
Hide file tree
Showing 8 changed files with 56 additions and 34 deletions.
2 changes: 1 addition & 1 deletion META6.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,5 @@
"Test::Util::ServerPort",
"Cro::HTTP::Client"
],
"version": "2.1.7"
"version": "2.2.0"
}
41 changes: 21 additions & 20 deletions lib/Humming-Bird/Core.rakumod
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use Humming-Bird::HTTPServer;

unit module Humming-Bird::Core;

our constant $VERSION = '2.1.7';
our constant $VERSION = '2.2.0';

# Mime type parser from MIME::Types
my constant $mime = MIME::Types.new;
Expand Down Expand Up @@ -38,8 +38,8 @@ sub http-method-of-str(Str:D $method --> HTTPMethod:D) {
}

# Converts a string of headers "KEY: VALUE\r\nKEY: VALUE\r\n..." to a map of headers.
sub decode_headers(Str:D $header_block --> Map:D) {
Map.new($header_block.lines.map({ .split(": ", 2, :skip-empty) }).flat);
sub decode-headers(@header_block --> Map:D) {
Map.new(@header_block.map(*.trim.split(': ', 2, :skip-empty).map(*.trim)).map({ [@^a[0].lc, @^a[1]] }).flat);
}

subset SameSite of Str where 'Strict' | 'Lax';
Expand Down Expand Up @@ -78,12 +78,13 @@ class HTTPAction {

# Find a header in the action, return (Any) if not found
multi method header(Str:D $name --> Str) {
return Nil without %.headers{$name};
%.headers{$name};
my $lc-name = $name.lc;
return Nil without %.headers{$lc-name};
%.headers{$lc-name};
}

multi method header(Str:D $name, Str:D $value --> HTTPAction:D) {
%.headers{$name} = $value;
multi method header(Str:D $name, Str:D $value) {
%.headers{$name.lc} = $value;
self;
}

Expand Down Expand Up @@ -177,24 +178,24 @@ class Request is HTTPAction is export {
my $body = "";

# Lose the request line and parse an assoc list of headers.
my %headers = Map.new(|@split_request[0].split("\r\n", :skip-empty).tail(*-1).map(*.split(':', 2).map(*.trim)).flat);
my %headers = decode-headers(@split_request[0].split("\r\n", :skip-empty).skip(1));

# Body should only exist if either of these headers are present.
with %headers<Content-Length> || %headers<Transfer-Encoding> {
with %headers<content-length> || %headers<transfer-encoding> {
$body = @split_request[1] || $body;
}

# Absolute uris need their path encoded differently.
without %headers<Host> {
without %headers<host> {
my $abs-uri = $path;
$path = $abs-uri.match(/^'http' 's'? '://' <[A..Z a..z \w \. \- \_ 0..9]>+ <('/'.*)>? $/).Str;
%headers<Host> = $abs-uri.match(/^'http''s'?'://'(<-[/]>+)'/'?.* $/)[0].Str;
%headers<host> = $abs-uri.match(/^'http''s'?'://'(<-[/]>+)'/'?.* $/)[0].Str;
}
my %cookies;
# Parse cookies
with %headers<Cookie> {
%cookies := Cookie.decode(%headers<Cookie>);
with %headers<cookie> {
%cookies := Cookie.decode(%headers<cookie>);
}
my $context-id = rand.Str.subst('0.', '').substr: 0, 5;
Expand Down Expand Up @@ -237,7 +238,7 @@ class Response is HTTPAction is export {
# Redirect to a given URI, :$permanent allows for a 308 status code vs a 307
method redirect(Str:D $to, :$permanent, :$temporary) {
%.headers<Location> = $to;
self.header('Location', $to);
self.status(303);

self.status(307) if $temporary;
Expand Down Expand Up @@ -275,7 +276,7 @@ class Response is HTTPAction is export {
# Write a blob or buffer
method blob(Buf:D $body, Str:D $content-type = 'application/octet-stream', --> Response:D) {
$.body = $body;
%.headers<Content-Type> = $content-type;
self.header('Content-Type', $content-type);
self;
}
# Alias for blob
Expand All @@ -285,7 +286,7 @@ class Response is HTTPAction is export {
# Write a string to the body of the response, optionally provide a content type
multi method write(Str:D $body, Str:D $content-type = 'text/plain', --> Response:D) {
$.body = $body;
%.headers<Content-Type> = $content-type;
self.header('Content-Type', $content-type);
self;
}
multi method write(Failure $body, Str:D $content-type = 'text/plain', --> Response:D) {
Expand All @@ -296,7 +297,7 @@ class Response is HTTPAction is export {

# Set content type of the response
method content-type(Str:D $type --> Response) {
%.headers<Content-Type> = $type;
self.header('Content-Type', $type);
self;
}

Expand All @@ -305,8 +306,8 @@ class Response is HTTPAction is export {
my $out = sprintf("HTTP/1.1 %d $!status\r\n", $!status.code);
my $body-size = $.body ~~ Buf:D ?? $.body.bytes !! $.body.chars;

if $body-size > 0 && %.headers<Content-Type> {
%.headers<Content-Type> ~= '; charset=utf8';
if $body-size > 0 && self.header('Content-Type') && self.header('Content-Type') !~~ /.*'octet-stream'.*/ {
%.headers<content-type> ~= '; charset=utf8';
}

$out ~= sprintf("Content-Length: %d\r\n", $body-size);
Expand Down Expand Up @@ -611,7 +612,7 @@ sub handle($raw-request) {
my Bool:D $keep-alive = False;
my &advice = [o] @ADVICE; # Advice are Response --> Response

with $request.headers<Connection> {
with $request.header('Connection') {
$keep-alive = .lc eq 'keep-alive';
}

Expand Down
2 changes: 1 addition & 1 deletion lib/Humming-Bird/HTTPServer.rakumod
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class Humming-Bird::HTTPServer is export {
$!lock.protect({
@!connections = @!connections.grep({ !$_<closed>.defined }); # Remove dead connections
for @!connections.grep({ now - $_<last-active> >= $!timeout }) {
try {
{
$_<closed> = True;
$_<socket>.write(Blob.new);
$_<socket>.close;
Expand Down
4 changes: 2 additions & 2 deletions t/01-basic.rakutest
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ is routes{'/'}{GET}.path, '/', 'Is route path OK?';
is routes{'/'}{GET}.callback.raku, &cb.raku, 'Is callback OK?';

my $req = Request.new(path => '/', method => GET, version => 'HTTP/1.1');
is routes{'/'}{GET}($req).headers{'Content-Type'}, 'text/html', 'Is response header content type OK?';
is routes{'/'}{GET}($req).header('Content-Type'), 'text/html', 'Is response header content type OK?';
is routes{'/'}{GET}($req).body, 'Hello World', 'Is response body OK?';

post('/', &cb);
is routes{'/'}{POST}.path, '/', 'Is route path OK?';
is routes{'/'}{POST}.callback.raku, &cb.raku, 'Is callback OK?';

$req = Request.new(path => '/', method => POST, version => 'HTTP/1.1');
is routes{'/'}{POST}($req).headers{'Content-Type'}, 'text/html', 'Is response header content type OK?';
is routes{'/'}{POST}($req).header('Content-Type'), 'text/html', 'Is response header content type OK?';
is routes{'/'}{POST}($req).body, 'Hello World', 'Is response body OK?';


Expand Down
12 changes: 6 additions & 6 deletions t/02-request_encoding.rakutest
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ my $simple_header_raw_request = "GET /bob HTTP/1.1\r\nAccepted-Encoding: utf-8\r
my $simple_header_request = Request.decode($simple_header_raw_request);

ok $simple_header_request.method === GET, 'Is method for header request OK?';
is $simple_header_request.headers{'Accepted-Encoding'}, 'utf-8', 'Is header OK?';
is $simple_header_request.header('Accepted-Encoding'), 'utf-8', 'Is header OK?';

my $many_header_raw_request = "GET /bob HTTP/1.1\r\nAccepted-Encoding: utf-8\r\nAccept-Language: en-US\r\nConnection: keep-alive\r\nHost: bob.com\r\n";
my $many_header_request = Request.decode($many_header_raw_request);

is $many_header_request.headers{'Accepted-Encoding'}, 'utf-8', 'Is header 1 OK?';
is $many_header_request.headers{'Accept-Language'}, 'en-US', 'Is header 2 OK?';
is $many_header_request.headers{'Connection'}, 'keep-alive', 'Is header 3 OK?';
is $many_header_request.header('Accepted-Encoding'), 'utf-8', 'Is header 1 OK?';
is $many_header_request.header('Accept-Language'), 'en-US', 'Is header 2 OK?';
is $many_header_request.header('Connection'), 'keep-alive', 'Is header 3 OK?';

my $body = 'aaaaaaaaaa';
my $simple_post_raw_request = "POST / HTTP/1.1\r\nHost: bob.com\r\nContent-Type: application/json\r\nContent-Length: { $body.chars }\r\n\r\n$body";
Expand All @@ -41,12 +41,12 @@ is $simple_post_empty_request.body, '', 'Is empty post body OK?';
my $simple-absolute-uri-raw-request = "POST http://localhost/ HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: { $body.chars }\r\n\r\n$body";
my $simple-absolute-uri-request = Request.decode($simple-absolute-uri-raw-request);
is $simple-absolute-uri-request.body, $body, 'Is absolute URI body OK?';
is $simple-absolute-uri-request.headers<Host>, 'localhost', 'Is absolute URI host header OK?';
is $simple-absolute-uri-request.header('Host'), 'localhost', 'Is absolute URI host header OK?';
is $simple-absolute-uri-request.path, '/', 'Is absolute URI path OK?';

my $complex-absolute-uri-raw-request = "POST http://localhost/name/person?bob=123 HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: { $body.chars }\r\n\r\n$body";
my $complex-absolute-uri-request = Request.decode($complex-absolute-uri-raw-request);
is $complex-absolute-uri-request.body, $body, 'Is absolute URI body OK?';
is $complex-absolute-uri-request.headers<Host>, 'localhost', 'Is absolute URI host header OK?';
is $complex-absolute-uri-request.header('Host'), 'localhost', 'Is absolute URI host header OK?';
is $complex-absolute-uri-request.path, '/name/person', 'Is absolute URI path OK?';
is $complex-absolute-uri-request.query('bob'), '123', 'Is query param OK?';
10 changes: 7 additions & 3 deletions t/03-response_decoding.rakutest
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ use Test;
use Humming-Bird::Core;
use HTTP::Status;

plan 3;
plan 5;

my $initiator = Request.new(path => '/', method => GET, version => 'HTTP/1.1');
my $simple_response = Response.new(:$initiator, status => HTTP::Status(200));

ok $simple_response.encode, 'Does decode not die?';

my %headers = 'Content-Length', 10, 'Encoding', 'utf-8';
my $simple_response_headers = Response.new(:$initiator, status => HTTP::Status(200), :%headers);
my $simple_response_headers = Response.new(:$initiator, status => HTTP::Status(200));

$simple_response_headers.header('Content-Length', 10.Str).header('Encoding', 'utf-8');

ok $simple_response_headers.header('encoding');
ok $simple_response_headers.header('content-LENGTH');

ok $simple_response_headers.encode, 'Does encode with headers not die?';
$simple_response_headers.write('abc');
Expand Down
2 changes: 1 addition & 1 deletion t/08-static.rakutest
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ ok routes{'/'}{'static'}, 'Is route OK?';
ok routes{'/'}{'static'}{GET}, 'Is route method OK?';
is routes{'/'}{'static'}{GET}(Request.new(path => 't/static/test.css', method => GET, version => 'HTTP/1.1')).status, HTTP::Status(200), 'Is response status OK?';
is routes{'/'}{'static'}{GET}(Request.new(path => 't/static/test.css', method => GET, version => 'HTTP/1.1')).body.chomp, q<img { color: 'blue'; }>, 'Is response body OK?';
is routes{'/'}{'static'}{GET}(Request.new(path => 't/static/test.css', method => GET, version => 'HTTP/1.1')).headers<Content-Type>, 'text/css', 'Is content-type OK?';
is routes{'/'}{'static'}{GET}(Request.new(path => 't/static/test.css', method => GET, version => 'HTTP/1.1')).header('Content-Type'), 'text/css', 'Is content-type OK?';
is routes{'/'}{'static'}{GET}(Request.new(path => 't/static/test.css.bob', method => GET, version => 'HTTP/1.1')).status, HTTP::Status(404), 'Is missing response status OK?';

done-testing;
17 changes: 17 additions & 0 deletions t/12-headers.rakutest
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
use v6;
use lib 'lib';
use strict;
use Test;
use Humming-Bird::Core;

plan 4;

my $req = Request.new(path => '/', method => GET, version => 'HTTP/1.1');

ok $req.header('Foo', 'bar'), 'Does add ok?';
ok $req.header('Bar', 'foo'), 'Does add ok?';

is $req.header('foo'), 'bar', 'Does get case insensitive?';
is $req.header('BaR'), 'foo', 'Does get case insensitive?';

done-testing;

0 comments on commit d2b05d6

Please sign in to comment.