Skip to content

Commit

Permalink
Add infered content bodies (#47)
Browse files Browse the repository at this point in the history
  • Loading branch information
rawleyfowler authored Mar 5, 2023
1 parent b6ff32a commit 1da51dd
Show file tree
Hide file tree
Showing 6 changed files with 129 additions and 32 deletions.
3 changes: 2 additions & 1 deletion META6.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"depends": [
"HTTP::Status",
"DateTime::Format",
"MIME::Types"
"MIME::Types",
"JSON::Fast"
],
"description": "A simple and composable web applications framework.",
"license": "MIT",
Expand Down
97 changes: 72 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@
![Zef Badge](https://raku.land/zef:rawleyfowler/Humming-Bird/badges/version?)
[![SparrowCI](https://ci.sparrowhub.io/project/gh-rawleyfowler-Humming-Bird/badge)](https://ci.sparrowhub.io)

Humming-Bird is a simple, composable, and performant, all in one HTTP web-framework for Raku.
Humming-Bird was inspired mainly by [Opium](https://github.com/rgrinberg/opium), [Sinatra](https://sinatrarb.com), and [Express](https://expressjs.com), and tries to keep
Humming-Bird is a simple, composable, and performant web-framework for Raku on MoarVM.
Humming-Bird was inspired mainly by [Sinatra](https://sinatrarb.com), and [Express](https://expressjs.com), and tries to keep
things minimal, allowing the user to pull in things like templating engines, and ORM's on their own terms.

Humming-Bird comes with what you need to quickly, and efficiently spin up REST API's, and with a few of the users favorite libraries, dynamic MVC style web-apps.
Humming-Bird provides a rich API for crafting HTTP responses, as well as a few nice quality-of-life features like
infered data encoding, meaning you shouldn't ever have to parse JSON to a Raku map again, a simple functional interface
allowing users to compose functions together to create their routes, middlewares, and advice, a simple error handling system
for ensuring stability, and crazy fast routing system.

Humming-Bird is not meant to face the internet directly. Please use a reverse proxy such as httpd or NGiNX.
Humming-Bird comes with what you need to quickly, and efficiently spin up REST API's, static sites,
and with a few of the users favorite libraries, dynamic MVC style web-apps. Humming-Bird stays out of your way
letting you structure your code however you like.

**Note**: Humming-Bird is not meant to face the internet directly. Please use a reverse proxy such as httpd or NGiNX.

## How to install
Make sure you have [zef](https://github.com/ugexe/zef) installed.
Expand All @@ -24,9 +31,9 @@ zef install Humming-Bird
```

## Performance
Around ~20% faster than Ruby's `Sinatra`, but still providing a majority of it's features!
Around ~20% faster than Ruby's `Sinatra`, and only improving as time goes on!

See [this](https://github.com/rawleyfowler/Humming-Bird/issues/43#issuecomment-1454252501) for a more detailed performance review.
See [this](https://github.com/rawleyfowler/Humming-Bird/issues/43#issuecomment-1454252501) for a more detailed performance preview.

## Examples

Expand All @@ -45,27 +52,70 @@ listen(8080);
# Navigate to localhost:8080!
```

#### Simple JSON example:
#### Simple JSON CRUD example:
```raku
use v6.d;

use Humming-Bird::Core;
use JSON::Fast; # A dependency of Humming-Bird

my %users = Map.new('bob', '{ "name": "bob" }', 'joe', '{ "name": "joe" }');
my %users = Map.new('bob', %('name', 'bob'), 'joe', %('name', 'joe'));

get('/users/:user', -> $request, $response {
my $user = $request.param('user');

if %users{$user}:exists {
$response.json(%users{$user});
$response.json(to-json: %users{$user});
} else {
$response.status(404).html("Sorry, $user does not exist.");
}
});

post('/users', -> $request, $response {
my %user = $request.content; # Different from $request.body, $request.content will do its best to encode the data to a Map.
if my-user-validation-logic(%user) { # Validate somehow, i'll leave that up to you.
%users{%user<name>} = %user;
$response.status(204); # 204 created
} else {
$response.status(404);
$response.status(400).html('Bad request');
}
});

listen(8080);
```

#### Routers
```raku
use v6.d;

use Humming-Bird::Core;
use Humming-Bird::Middleware;

# NOTE: Declared routes persist through multiple 'use Humming-Bird::Core' statements
# allowing you to declare routing logic in multiple places if you want. This is true
# regardless of whether you're using the sub or Router process for defining routes.
my $router = Router.new(root => '/');

$router.middleware(&middleware-logger); # middleware-logger is provided by the Middleware package

$router.get(-> $request, $response { # Register a GET route on the root of the router
$response.html('<h1>Hello World</h1>);
});
$router.get('/foo', -> $request, $response { # Register a GET route on /foo
$response.html('<span style="color: blue;">Bar</span>');
});

my $other-router = Router.new(root => '/bar');

$other-router.get('/baz', -> $request, $response { # Register a GET route on /bar/baz
$response.file('hello-world.html'); # Will render hello-world.html and infer its content type
});

# No need to register routers, it's underlying routes are registered with Humming-Bird on creation.
listen(8080);
```

#### Middleware
```raku
use v6.d;
Expand All @@ -86,19 +136,6 @@ sub block-firefox($request, $response, &next) {
get('/no-firefox', -> $request, $response {
$response.html('You are not using Firefox!');
}, [ &middleware-logger, &block-firefox ]);

# Scoped middleware

# Both of these routes will now share the middleware specified in the last parameter of the group.
group([
&get.assuming('/', -> $request, $response {
$response.write('Index');
}),

&post.assuming('/users', -> $request, $response {
$response.write($request.body).status(204);
})
], [ &middleware-logger, &block-firefox ]);
```

More examples can be found in the [examples](https://github.com/rawleyfowler/Humming-Bird/tree/main/examples) directory.
Expand All @@ -119,10 +156,20 @@ some people get involved :D

Please make sure you squash your branch, and name it accordingly before it gets merged!

Before submitting any feature/code pull requests, ensure that the following passes:
#### Testing

Install App::prove6

```bash
zef install --force-install App::Prove6
```

Ensure that the following passes:

```bash
cd Humming-Bird
prove6 -I. t/ it/
zef install . --force-install --/test
prove6 -v -I. t/ it/
```

## License
Expand Down
6 changes: 5 additions & 1 deletion lib/Humming-Bird/Advice.rakumod
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ use Humming-Bird::Core;
unit module Humming-Bird::Advice;

sub advice-logger(Response:D $response --> Response) is export {
say "{ $response.status.Int } { $response.status } | { $response.initiator.path } | { $response.header('Content-Type') }";
if $response.header('Content-Type') {
say "{ $response.status.Int } { $response.status } | { $response.initiator.path } | { $response.header('Content-Type') }";
} else {
say "{ $response.status.Int } { $response.status } | { $response.initiator.path } | no-content";
}
$response;
}
32 changes: 28 additions & 4 deletions lib/Humming-Bird/Core.rakumod
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ sub now-rfc2822(--> Str:D) {
enum HTTPMethod is export <GET POST PUT PATCH DELETE HEAD>;

# Convert a string to HTTP method, defaults to GET
sub http_method_of_str(Str:D $method --> HTTPMethod:D) {
sub http-method-of-str(Str:D $method --> HTTPMethod:D) {
given $method.lc {
when 'get' { GET }
when 'post' { POST; }
Expand Down Expand Up @@ -90,13 +90,37 @@ class HTTPAction {
}
}

my sub parse-urlencoded(Str:D $urlencoded --> Map:D) {
$urlencoded.split('&', :skip-empty)>>.split('=', :skip-empty)>>.map(-> $a, $b { $b.contains(',') ?? slip $a => $b.split(',', :skip-empty) !! slip $a => $b }).flat.Map;
}

class X::Humming-Bird::BadBody is X::AdHoc { }

class Request is HTTPAction is export {
has Str $.path is required;
has HTTPMethod $.method is required;
has Str $.version is required;
has %.params;
has %.query;

# Attempts to parse the body to a Map or return an empty map if we can't decode it
method content(--> Map:D) {
use JSON::Fast;

return Map.new unless self.header('Content-Type');

try {
CATCH { default { warn "Failed trying to parse a body of type { self.header('Content-Type') }"; return Map.new } }
if self.header('Content-Type').ends-with: 'json' {
return from-json(self.body).Map;
} elsif self.header('Content-Type').ends-with: 'urlencoded' {
return parse-urlencoded(self.body);
}
}

Map.new;
}

method param(Str:D $param --> Str) {
return Nil without %!params{$param};
%!params{$param};
Expand All @@ -113,10 +137,10 @@ class Request is HTTPAction is export {
my @lines = $raw-request.lines;
my ($method_raw, $path, $version) = @lines.head.split(' ');

my $method = http_method_of_str($method_raw);
my $method = http-method-of-str($method_raw);

# Find query params
my %query is Hash;
my %query;
if @lines[0] ~~ m:g /<[a..z A..Z 0..9]>+"="<[a..z A..Z 0..9]>+/ {
%query = Map.new($<>.map({ .split('=') }).flat);
$path = $path.split('?', :skip-empty)[0];
Expand Down Expand Up @@ -545,7 +569,7 @@ sub handle($raw-request) {
}

sub listen(Int:D $port, :$no-block, :$timeout) is export {
my $timeout-real = $timeout // 5;
my $timeout-real = $timeout // 3; # Sockets are closed after 3 seconds of inactivity
my $server = HTTPServer.new(:$port, timeout => $timeout-real);
if $no-block {
start {
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 @@ -10,7 +10,7 @@ class Humming-Bird::HTTPServer is export {
has Int:D $.port = 8080;
has Int:D $.timeout is required;
has Channel:D $.requests .= new;
has Lock $.lock .= new;
has Lock $!lock .= new;
has @!connections;

method !timeout {
Expand Down
21 changes: 21 additions & 0 deletions t/10-content-guessing.rakutest
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
use v6.d;
use Humming-Bird::Core;
use Test;

plan 3;

my $request = Request.new(body => '{ "foo": "bar" }', path => '/home', method => GET, version => 'HTTP/1.1');
$request.header('Content-Type', 'application/json');

is $request.content, { foo => 'bar' }, 'Is JSON content decoding OK?';

$request = Request.new(body => 'bob=123&john=abc', path => '/home', method => GET, version => 'HTTP/1.1');
$request.header('Content-Type', 'application/urlencoded');

is $request.content, Map.new('bob', '123', 'john', 'abc'), 'Is urlencoded content decoding OK?';

$request.body = 'tom=abc&bob=123,456,789&john=abc';

is $request.content, Map.new('tom', 'abc', 'bob' => (123,456,789), 'john', 'abc'), 'Is complex urlencoded content decoding OK?';

done-testing;

0 comments on commit 1da51dd

Please sign in to comment.