From f518e3a81e782c07aad1d0ef07c3051772b8cd22 Mon Sep 17 00:00:00 2001 From: Rawley <75388349+rawleyfowler@users.noreply.github.com> Date: Thu, 9 Nov 2023 21:44:59 -0600 Subject: [PATCH] Add Multipart uploads (#68) * big * Finalize multiparts --- META6.json | 2 +- examples/basic/basic.raku | 10 +++++ it/01-basic.rakutest | 48 +++++++++++++-------- lib/Humming-Bird/Backend/HTTPServer.rakumod | 44 ++++++++++++++----- lib/Humming-Bird/Core.rakumod | 2 +- lib/Humming-Bird/Glue.rakumod | 42 ++++++++++-------- t/01-basic.rakutest | 1 - t/02-request_encoding.rakutest | 12 ++++-- 8 files changed, 107 insertions(+), 54 deletions(-) diff --git a/META6.json b/META6.json index 1f3a597..c65a738 100644 --- a/META6.json +++ b/META6.json @@ -44,5 +44,5 @@ "Test::Util::ServerPort", "Cro::HTTP::Client" ], - "version": "3.0.1" + "version": "3.0.2" } diff --git a/examples/basic/basic.raku b/examples/basic/basic.raku index ea30540..e703abc 100644 --- a/examples/basic/basic.raku +++ b/examples/basic/basic.raku @@ -124,6 +124,16 @@ get('/session', sub ($request, $response) { $response.write($request.stash.raku) }, [ middleware-session ]); +get('/form', sub ($request, $response) { + $response.html('
'); + }); + +# Echo a file back to the user. +post('/form', sub ($request, $response) { + my $file = $request.content..; + $response.blob($file); + }); + # Routers my $router = Router.new(root => '/foo'); $router.middleware(&middleware-logger); # Append this middleware to all proceeding routes diff --git a/it/01-basic.rakutest b/it/01-basic.rakutest index 356b39f..bf5d4e1 100644 --- a/it/01-basic.rakutest +++ b/it/01-basic.rakutest @@ -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; @@ -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., 'Is multipart param 1 good?'; +ok $content., 'Is multipart param 2 good?'; +ok $content., 'Is multipart param 3 (file param) good?'; +is $content.., $blob, 'Is file param correct data?'; done-testing; diff --git a/lib/Humming-Bird/Backend/HTTPServer.rakumod b/lib/Humming-Bird/Backend/HTTPServer.rakumod index a778576..570f4c4 100644 --- a/lib/Humming-Bird/Backend/HTTPServer.rakumod +++ b/lib/Humming-Bird/Backend/HTTPServer.rakumod @@ -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; @@ -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); - my Humming-Bird::Glue::Response $response = &handler($hb-request); - $request.write: $response.encode; - $request = True with $hb-request.header('keep-alive'); + CATCH { + when X::IO { + $request.write: $four-eleven($request); + $request = True; + } + default { .say } + } + my $hb-request = $request; + my Humming-Bird::Glue::Response $hb-response = &handler($hb-request); + $request.write: $hb-response.encode; + $request:delete; # Mark this request as handled. + $request = False with $hb-request.header('keep-alive'); } } } @@ -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 = now; - $.requests.send: { - connection => %connection-map, - data => $bytes; - }; + + my $header-request = False; + if %connection-map:!exists { + %connection-map = Humming-Bird::Glue::Request.decode($bytes); + $header-request = True; + } + + my $hb-request = %connection-map; + 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 = True } } diff --git a/lib/Humming-Bird/Core.rakumod b/lib/Humming-Bird/Core.rakumod index c565e35..598b70d 100644 --- a/lib/Humming-Bird/Core.rakumod +++ b/lib/Humming-Bird/Core.rakumod @@ -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 = ':'; diff --git a/lib/Humming-Bird/Glue.rakumod b/lib/Humming-Bird/Glue.rakumod index 28e283e..54b4fed 100644 --- a/lib/Humming-Bird/Glue.rakumod +++ b/lib/Humming-Bird/Glue.rakumod @@ -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}; } @@ -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'"; @@ -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 { - 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; @@ -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); @@ -249,13 +257,13 @@ 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. @@ -263,7 +271,7 @@ class Request is HTTPAction is export { with %headers { if ($idx + 1 < $payload.bytes) { my $len = +%headers; - $body = $payload.subbuf: $idx, $len + $idx; + $body = Buf.new: $payload[$idx..($payload.bytes - 1)].Slip; } } diff --git a/t/01-basic.rakutest b/t/01-basic.rakutest index 342c2a7..91faa07 100644 --- a/t/01-basic.rakutest +++ b/t/01-basic.rakutest @@ -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 diff --git a/t/02-request_encoding.rakutest b/t/02-request_encoding.rakutest index 093f32a..a405957 100644 --- a/t/02-request_encoding.rakutest +++ b/t/02-request_encoding.rakutest @@ -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); @@ -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); @@ -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);