Skip to content

Commit

Permalink
Add Multipart uploads (#68)
Browse files Browse the repository at this point in the history
* big

* Finalize multiparts
  • Loading branch information
rawleyfowler authored Nov 10, 2023
1 parent 5a78ca1 commit f518e3a
Show file tree
Hide file tree
Showing 8 changed files with 107 additions and 54 deletions.
2 changes: 1 addition & 1 deletion META6.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,5 @@
"Test::Util::ServerPort",
"Cro::HTTP::Client"
],
"version": "3.0.1"
"version": "3.0.2"
}
10 changes: 10 additions & 0 deletions examples/basic/basic.raku
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ get('/session', sub ($request, $response) {
$response.write($request.stash<session><user>.raku)
}, [ middleware-session ]);

get('/form', sub ($request, $response) {
$response.html('<form enctype="multipart/form-data" action="/form" method="POST"><input type="file" name="file"><input name="name"></form>');
});

# Echo a file back to the user.
post('/form', sub ($request, $response) {
my $file = $request.content.<file>.<body>;
$response.blob($file);
});

# Routers
my $router = Router.new(root => '/foo');
$router.middleware(&middleware-logger); # Append this middleware to all proceeding routes
Expand Down
48 changes: 29 additions & 19 deletions it/01-basic.rakutest
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ get('/', -> $request, $response {
$response.write($body);
});

my $content;
post('/form', -> $request, $response {
ok $request.body;
ok $request.content;
$content := $request.content;
$response;
});

my $port = get-unused-port;
Expand All @@ -33,22 +34,31 @@ ok $response, 'Was response a success?';
is (await $response.body-text), 'abc', 'Is response body OK?';

# TODO: Fix this.
#lives-ok({
#$response = await $client.post: '/form',
# content-type => 'multipart/form-data',
# body => [
# name => 'foo',
# age => 123,
# Cro::HTTP::Body::MultiPartFormData::Part.new(
# headers => [Cro::HTTP::Header.new(
# name => 'Content-type',
# value => 'image/jpeg'
# )],
# name => 'photo',
# filename => 'baobao.jpg',
# body-blob => slurp('t/static/baobao.jpg', :bin)
# )
# ];
#});
my $blob = slurp('t/static/baobao.jpg', :bin);
lives-ok({
$response = await $client.post: '/form',
content-type => 'multipart/form-data',
content-length => $blob.bytes,
body => [
name => 'foo',
age => 123,
Cro::HTTP::Body::MultiPartFormData::Part.new(
headers => [Cro::HTTP::Header.new(
name => 'Content-type',
value => 'image/jpeg'
)],
name => 'photo',
filename => 'baobao.jpg',
body-blob => $blob
)
];
}, "Can we send the baobao?");

await $response.body;

ok $content.<name>, 'Is multipart param 1 good?';
ok $content.<age>, 'Is multipart param 2 good?';
ok $content.<photo>, 'Is multipart param 3 (file param) good?';
is $content.<photo>.<body>, $blob, 'Is file param correct data?';

done-testing;
44 changes: 33 additions & 11 deletions lib/Humming-Bird/Backend/HTTPServer.rakumod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use v6;

use Humming-Bird::Backend;
use Humming-Bird::Glue;
use HTTP::Status;

unit class Humming-Bird::Backend::HTTPServer does Humming-Bird::Backend;

Expand Down Expand Up @@ -40,14 +41,25 @@ method !timeout {
}

method !respond(&handler) {
state $four-eleven = sub ($initiator) {
Humming-Bird::Glue::Response.new(:$initiator, status => HTTP::Status(411)).encode;
};

start {
react {
whenever $.requests -> $request {
CATCH { default { .say } }
my $hb-request = Humming-Bird::Glue::Request.decode($request<data>);
my Humming-Bird::Glue::Response $response = &handler($hb-request);
$request<connection><socket>.write: $response.encode;
$request<connection><closed> = True with $hb-request.header('keep-alive');
CATCH {
when X::IO {
$request<socket>.write: $four-eleven($request<request>);
$request<closed> = True;
}
default { .say }
}
my $hb-request = $request<request>;
my Humming-Bird::Glue::Response $hb-response = &handler($hb-request);
$request<socket>.write: $hb-response.encode;
$request<request>:delete; # Mark this request as handled.
$request<closed> = False with $hb-request.header('keep-alive');
}
}
}
Expand All @@ -64,15 +76,25 @@ method listen(&handler) {
last-active => now
}

$!lock.protect({ @!connections.push: %connection-map });

whenever $connection.Supply: :bin -> $bytes {
CATCH { default { .say } }
%connection-map<last-active> = now;
$.requests.send: {
connection => %connection-map,
data => $bytes;
};

my $header-request = False;
if %connection-map<request>:!exists {
%connection-map<request> = Humming-Bird::Glue::Request.decode($bytes);
$header-request = True;
}

my $hb-request = %connection-map<request>;
if !$header-request {
$hb-request.body.append: $bytes;
}

my $content-length = $hb-request.header('Content-Length');
if (!$content-length.defined || ($hb-request.body.bytes == $content-length)) {
$.requests.send: %connection-map;
}
}

CATCH { default { .say; $connection.close; %connection-map<closed> = True } }
Expand Down
2 changes: 1 addition & 1 deletion lib/Humming-Bird/Core.rakumod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use Humming-Bird::Glue;

unit module Humming-Bird::Core;

our constant $VERSION = '3.0.1';
our constant $VERSION = '3.0.2';

### ROUTING SECTION
my constant $PARAM_IDX = ':';
Expand Down
42 changes: 25 additions & 17 deletions lib/Humming-Bird/Glue.rakumod
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class HTTPAction {
# Find a header in the action, return (Any) if not found
multi method header(Str:D $name --> Str) {
my $lc-name = $name.lc;
return Nil without %.headers{$lc-name};
return Str without %.headers{$lc-name};
%.headers{$lc-name};
}

Expand Down Expand Up @@ -129,7 +129,12 @@ class Request is HTTPAction is export {
$!content = parse-urlencoded($.body.decode).Map;
} elsif self.header('Content-Type').starts-with: 'multipart/form-data' {
# Multi-part parser based on: https://github.com/croservices/cro-http/blob/master/lib/Cro/HTTP/BodyParsers.pm6
my $boundary = self.header('Content-Type').match(/^'multipart/form-data; boundary="'<(.*)>'"'.*$/).Str;
my $boundary = self.header('Content-Type') ~~ /.*'boundary="' <(.*)> '"' ';'?/;

# For some reason there is no standard for quotes or no quotes.
$boundary //= self.header('Content-Type') ~~ /.*'boundary=' <(.*)> ';'?/;

$boundary .= Str with $boundary;

without $boundary {
die "Missing boundary parameter in for 'multipart/form-data'";
Expand Down Expand Up @@ -169,30 +174,32 @@ class Request is HTTPAction is export {
$payload .= substr($next-boundary + $search.chars);
}

my @parts;
my %parts;
for @part-strs -> $part {
my ($header, $body-str) = $part.split("\r\n\r\n", 2);
my %headers = decode-headers($header.trim);
my %headers = decode-headers($header.split("\r\n", :skip-empty));
with %headers<content-disposition> {
my $param-start = .value.index(';');
my $parameters = $param-start ?? .value.substr($param-start) !! Str;
my $param-start = .index(';');
my $parameters = $param-start ?? .substr($param-start) !! Str;
without $parameters {
die "Missing content-disposition parameters in multipart/form-data part";
}

my $name = $parameters.match(/.*'name='<(\w+)>';'?.*/).Str;
my $filename-param = $parameters.match(/.*'filename='<(\w+)>';'?.*/);
my $filename = $filename-param ?? $filename-param.value !! Str;
push @parts, Map.new(
:%headers, :$name, :$filename, body => Buf.new($body-str.encode)
);
my $name = $parameters.match(/'name="'<(\w+)>'";'?.*/).Str;
my $filename-param = $parameters.match(/.*'filename="'<(\w+)>'";'?.*/);
my $filename = $filename-param ?? $filename-param.Str !! Str;
%parts{$name} = {
:%headers,
$filename ?? :$filename !! (),
body => Buf.new($body-str.encode('latin-1'))
};
}
else {
die "Missing content-disposition header in multipart/form-data part";
}
}

$!content = @parts.List;
$!content := %parts;
}

return $!content;
Expand Down Expand Up @@ -224,11 +231,12 @@ class Request is HTTPAction is export {
multi submethod decode(Buf:D $payload --> Request:D) {
my $binary-str = $payload.decode('latin-1');
my $idx = 0;

loop {
$idx++;
last if (($payload[$idx] == $rn[0]
&& $payload[$idx + 1] == $rn[1])
|| $idx > ($payload.bytes + 1));
|| $idx > ($payload.bytes + 1));
}
my ($method_raw, $path, $version) = $payload.subbuf(0, $idx).decode.chomp.split(/\s/, 3, :skip-empty);

Expand All @@ -249,21 +257,21 @@ class Request is HTTPAction is export {
&& $payload[$idx + 1] == $rnrn[1]
&& $payload[$idx + 2] == $rnrn[2]
&& $payload[$idx + 3] == $rnrn[3])
|| $idx > ($payload.bytes + 3));
|| $idx > ($payload.bytes + 3));
}

my $header-section = $payload.subbuf($header-marker, $idx);

# Lose the request line and parse an assoc list of headers.
my %headers = decode-headers($header-section.decode.split("\r\n", :skip-empty));
my %headers = decode-headers($header-section.decode('latin-1').split("\r\n", :skip-empty));

$idx += 4;
# Body should only exist if either of these headers are present.
my $body;
with %headers<content-length> {
if ($idx + 1 < $payload.bytes) {
my $len = +%headers<content-length>;
$body = $payload.subbuf: $idx, $len + $idx;
$body = Buf.new: $payload[$idx..($payload.bytes - 1)].Slip;
}
}

Expand Down
1 change: 0 additions & 1 deletion t/01-basic.rakutest
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,4 @@ $req = Request.new(path => '/', method => POST, version => 'HTTP/1.1');
is routes{'/'}{POST}($req).header('Content-Type'), 'text/html', 'Is response header content type OK?';
is routes{'/'}{POST}($req).body.decode, 'Hello World', 'Is response body OK?';


# vim: expandtab shiftwidth=4
12 changes: 8 additions & 4 deletions t/02-request_encoding.rakutest
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use Test;
use Humming-Bird::Core;
use Humming-Bird::Glue;

plan 21;
plan 22;

my $simple_raw_request = "GET / HTTP/1.1\r\nHost: bob.com\r\n";
my $simple_request = Request.decode($simple_raw_request);
Expand All @@ -15,19 +15,21 @@ ok $simple_request.method === GET, 'Is method OK?';
is $simple_request.version, 'HTTP/1.1', 'Is version OK?';
is $simple_request.path, '/', 'Is path OK?';

my $simple_header_raw_request = "GET /bob HTTP/1.1\r\nAccepted-Encoding: utf-8\r\nHost: bob.com\r\n";
my $simple_header_raw_request = "GET /bob HTTP/1.1\r\nAccepted-Encoding: utf-8\r\nHost: bob.com\r\n\r\n";
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.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_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\r\n";
my $many_header_request = Request.decode($many_header_raw_request);

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?';

dies-ok({ Request.decode: 'POST / HTTP/1.1\r\nHost: bob.com\r\nContent-Type: application/json\r\nChunked-Encoding: yes\r\n\r\n123' }, 'Does chunked encoding die?');

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";
my $simple_post_request = Request.decode($simple_post_raw_request);
Expand All @@ -37,7 +39,9 @@ is $simple_post_request.header('Content-Type'), 'application/json';
is $simple_post_request.header('Content-Length'), $body.chars;
is $simple_post_request.method, POST;

is $simple_post_request.body.decode, $body, 'Is post body OK?';
say $simple_post_request.body.decode.raku;
say $body.raku;
is $simple_post_request.body.decode('latin-1'), $body, 'Is post body OK?';

my $simple_post_empty_raw_request = "POST / HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: 0\r\nHost: bob.com\r\n\r\n";
my $simple_post_empty_request = Request.decode($simple_post_empty_raw_request);
Expand Down

0 comments on commit f518e3a

Please sign in to comment.