Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

3.0.4 - Plugins #79

Merged
merged 5 commits into from
Dec 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions META6.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
"build-depends": [
],
"depends": [
"HTTP::Status",
"DateTime::Format",
"MIME::Types",
"JSON::Fast",
"URI::Encode",
"UUID::V4"
"HTTP::Status:auth<zef:lizmat>",
"DateTime::Format:auth<zef:raku-community-modules>",
"MIME::Types:auth<zef:raku-community-modules>",
"JSON::Fast:auth<cpan:TIMOTIMO>",
"URI::Encode:auth<zef:raku-community-modules>",
"UUID::V4:auth<zef:masukomi>",
"Terminal::ANSIColor:auth<zef:lizmat>"
],
"description": "A simple and composable web applications framework.",
"license": "MIT",
Expand All @@ -23,7 +24,11 @@
"Humming-Bird::Backend::HTTPServer": "lib/Humming-Bird/Backend/HTTPServer.rakumod",
"Humming-Bird::Middleware": "lib/Humming-Bird/Middleware.rakumod",
"Humming-Bird::Advice": "lib/Humming-Bird/Advice.rakumod",
"Humming-Bird::Glue": "lib/Humming-Bird/Glue.rakumod"
"Humming-Bird::Glue": "lib/Humming-Bird/Glue.rakumod",
"Humming-Bird::Plugin": "lib/Humming-Bird/Plugin.rakumod",
"Humming-Bird::Plugin::Config": "lib/Humming-Bird/Plugin/Config.rakumod",
"Humming-Bird::Plugin::Logger": "lib/Humming-Bird/Plugin/Logger.rakumod",
"Humming-Bird::Plugin::Session": "lib/Humming-Bird/Plugin/Session.rakumod"
},
"resources": [
],
Expand Down
77 changes: 76 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Then you have the actual application logic in `Humming-Bird::Core` that handles:
- Middleware
- Advice (end of stack middleware)
- Simple global error handling
- Plugin system

- Simple and helpful API
- get, post, put, patch, delete, etc
Expand Down Expand Up @@ -100,6 +101,23 @@ post('/users', -> $request, $response {
listen(8080);
```

#### Using plugins
```raku
use v6.d;

use Humming-Bird::Core;

plugin 'Logger'; # Corresponds to the pre-built Humming-Bird::Plugin::Logger plugin.
plugin 'Config'; # Corresponds to the pre-built Humming-Bird::Plugin::Config plugin.

get('/', sub ($request, $response) {
my $database_url = $request.config<database_url>;
$response.html('Here's my database url :D ' ~ $database_url);
});

listen(8080);
```

#### Routers
```raku
use v6.d;
Expand All @@ -112,7 +130,7 @@ use Humming-Bird::Middleware;
# 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
plugin 'Logger';

$router.get(-> $request, $response { # Register a GET route on the root of the router
$response.html('<h1>Hello World</h1>');
Expand Down Expand Up @@ -156,6 +174,8 @@ get('/no-firefox', -> $request, $response {
listen(8080);
```

Since Humming-Bird `3.0.4` it may be more favorable to use plugins to register global middlewares.

#### Swappable Backends
```raku
use v6.d;
Expand All @@ -172,6 +192,61 @@ listen(:backend(Humming-Bird::Backend::MyBackend));

More examples can be found in the [examples](https://github.com/rawleyfowler/Humming-Bird/tree/main/examples) directory.

## Swappable backends

In Humming-Bird `3.0.0` and up you are able to write your own backend, please follow the API outlined by the `Humming-Bird::Backend` role,
and view `Humming-Bird::Backend::HTTPServer` for an example of how to implement a Humming-Bird backend.

## Plugin system

Humming-Bird `3.0.4` and up features the Humming-Bird Plugin system, this is a straight forward way to extend Humming-Bird with desired functionality before the server
starts up. All you need to do is create a class that inherits from `Humming-Bird::Plugin`, for instance `Humming-Bird::Plugin::OAuth2`, expose a single method `register` which
takes arguments that align with the arguments specified in `Humming-Bird::Plugin.register`, for more arguments, take a slurpy at the end of your register method.

Here is an example of a plugin:

```raku
use MONKEY-TYPING;
use JSON::Fast;
use Humming-Bird::Plugin;
use Humming-Bird::Core;

unit class Humming-Bird::Plugin::Config does Humming-Bird::Plugin;

method register($server, %routes, @middleware, @advice, **@args) {
my $filename = @args[0] // '.humming-bird.json';
my %config = from-json($filename.IO.slurp // '{}');

augment class Humming-Bird::Glue::HTTPAction {
method config(--> Hash:D) {
%config;
}
}

CATCH {
default {
warn 'Failed to find or parse your ".humming-bird.json" configuration. Ensure your file is well formed, and does exist.';
}
}
}
```

This plugin embeds a `.config` method on the base class for Humming-Bird's Request and Response classes, allowing your config to be accessed during the request/response lifecycle.

Then to register it in a Humming-Bird application:

```
use Humming-Bird::Core;

plugin 'Config', 'config/humming-bird.json'; # Second arg will be pushed to he **@args array in the register method.

get('/', sub ($request, $response) {
$response.write($request.config<foo>); # Echo back the <foo> field in our JSON config.
});

listen(8080);
```

## Design
- Humming-Bird should be easy to pickup, and simple for developers new to Raku and/or web development.
- Humming-Bird is not designed to be exposed to the internet directly. You should hide Humming-Bird behind a reverse-proxy like NGiNX, Apache, or Caddy.
Expand Down
3 changes: 3 additions & 0 deletions examples/basic/.humming-bird.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"secret_message": "boo"
}
24 changes: 10 additions & 14 deletions examples/basic/basic.raku
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ use Humming-Bird::Core;
use Humming-Bird::Middleware;
use Humming-Bird::Advice;

plugin 'Config';
plugin 'Logger';

# Simple static routes
get('/', -> $request, $response {
$response.html('<h1>Hello World!</h1>');
Expand Down Expand Up @@ -39,7 +42,7 @@ get('/help.txt', -> $request, $response {
# Simple Middleware example
get('/logged', -> $request, $response {
$response.html('<h1>Your request has been logged. Check the console.</h1>');
}, [ &middleware-logger ]); # m_logger is provided by Humming-Bird::Middleware
});


# Custom Middleware example
Expand All @@ -53,7 +56,7 @@ sub block_firefox($request, $response, &next) {

get('/firefox-not-allowed', -> $request, $response {
$response.html('<h1>Hello Non-firefox user!</h1>');
}, [ &middleware-logger, &block_firefox ]); # Many middlewares can be combined.
}, [&block_firefox ]);

# Grouping routes
# group: @route_callbacks, @middleware
Expand All @@ -65,7 +68,7 @@ group([
&get.assuming('/hello/world', -> $request, $response {
$response.html('<h1>Hello World!</h1>');
})
], [ &middleware-logger, &block_firefox ]);
], [ &block_firefox ]);


# Simple cookie
Expand Down Expand Up @@ -106,10 +109,6 @@ get('/throws-error', -> $request, $response {
# Error handler
error(X::AdHoc, -> $exn, $response { $response.status(500).write("Encountered an error. <br> $exn") });

# After middleware, Response --> Response
advice(&advice-logger);


# Static content
static('/static', '/var/www/static'); # Server static content on '/static', from '/var/www/static'

Expand All @@ -118,11 +117,11 @@ get('/favicon.ico', sub ($request, $response) { $response.file('favicon.ico'); }
get('/login', sub ($request, $response) {
$request.stash<session><user> = 'foobar';
$response.write('Logged in as foobar');
}, [ middleware-session ]);
}, [ &middleware-session ]);

get('/session', sub ($request, $response) {
$response.write($request.stash<session><user>.raku)
}, [ middleware-session ]);
}, [ &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>');
Expand All @@ -136,14 +135,11 @@ post('/form', sub ($request, $response) {

# Routers
my $router = Router.new(root => '/foo');
$router.middleware(&middleware-logger); # Append this middleware to all proceeding routes
$router.advice(&advice-logger); # Append this advice to all proceeding routes
$router.get(-> $request, $response { $response.write('foo') });
$router.post(-> $request, $response { $response.write('foo post!') });
$router.get('/bar', -> $request, $response { $response.write('foo bar') }); # Registered on /foo/bar

# Run the application
listen(9000, timeout => 3); # You can set timeout, to a value you'd like (this is how long keep-alive sockets are kept open) default is 5 seconds.
# You can also set the :no-block adverb to stop the call from blocking, and be run on the task scheduler.
# Run the app
listen(9000, timeout => 3);

# vim: expandtab shiftwidth=4
1 change: 0 additions & 1 deletion lib/Humming-Bird/Advice.rakumod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,5 @@ unit module Humming-Bird::Advice;
sub advice-logger(Humming-Bird::Glue::Response:D $response --> Humming-Bird::Glue::Response:D) is export {
my $log = "{ $response.status.Int } { $response.status } | { $response.initiator.path } | ";
$log ~= $response.header('Content-Type') ?? $response.header('Content-Type') !! "no-content";

$response.log: $log;
}
94 changes: 72 additions & 22 deletions lib/Humming-Bird/Core.rakumod
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,30 @@ our %ROUTES;
our @MIDDLEWARE;
our @ADVICE = [{ $^a }];
our %ERROR;
our @PLUGINS;

class Route {
has Str:D $.path is required where { ($^a eq '') or $^a.starts-with('/') };
has Bool:D $.static = False;
has &.callback is required;
has @.middlewares; # List of functions that type Request --> Request

submethod TWEAK {
@!middlewares.prepend: @MIDDLEWARE;
}

method CALL-ME(Request:D $req) {
method CALL-ME(Request:D $req --> Response:D) {
my @middlewares = [|@!middlewares, |@MIDDLEWARE, -> $a, $b, &c { &!callback($a, $b) }];
my $res = Response.new(initiator => $req, status => HTTP::Status(200));
if @!middlewares.elems {
state &composition = @!middlewares.map({ .assuming($req, $res) }).reduce(-> &a, &b { &a({ &b }) });
# Finally, the main callback is added to the end of the chain
&composition(&!callback.assuming($req, $res));
} else {
if @middlewares.elems > 1 {
# For historical purposes this code will stay here, unfortunately, it was not performant enough.
# This code was written on the first day I started working on Humming-Bird. - RF
# state &comp = @middlewares.prepend(-> $re, $rp, &n { &!callback.assuming($req, $res) }).map({ $^a.raku.say; $^a.assuming($req, $res) }).reverse.reduce(-> &a, &b { &b.assuming(&a) } );

for @middlewares -> &middleware {
my Bool:D $next = False;
&middleware($req, $res, sub { $next = True } );
last unless $next;
}
return $res;
}
else {
# If there is are no middlewares, just process the callback
&!callback($req, $res);
}
Expand All @@ -48,7 +54,7 @@ sub split_uri(Str:D $uri --> List:D) {

sub delegate-route(Route:D $route, HTTPMethod:D $meth --> Route:D) {
die 'Route cannot be empty' unless $route.path;
die "Invalid route: { $route.path }" unless $route.path.contains('/');
die "Invalid route: { $route.path }, routes must start with a '/'" unless $route.path.contains('/');

my @uri_parts = split_uri($route.path);

Expand All @@ -69,13 +75,13 @@ class Router is export {
has Str:D $.root is required;
has @.routes;
has @!middlewares;
has @!advice = { $^a }; # List of functions that type Response --> Response
has @!advice = ( { $^a } ); # List of functions that type Response --> Response

method !add-route(Route:D $route, HTTPMethod:D $method --> Route:D) {
my &advice = [o] @!advice;
my &cb = $route.callback;
my $r = $route.clone(path => $!root ~ $route.path,
middlewares => [|@!middlewares, |$route.middlewares],
middlewares => [|$route.middlewares, |@!middlewares],
callback => { &advice(&cb($^a, $^b)) });
@!routes.push: $r;
delegate-route($r, $method);
Expand Down Expand Up @@ -116,6 +122,10 @@ class Router is export {
self.delete('', &callback, @middlewares);
}

method plugin($plugin) {
@PLUGINS.push: $plugin;
}

method middleware(&middleware) {
@!middlewares.push: &middleware;
}
Expand Down Expand Up @@ -162,18 +172,18 @@ sub dispatch-request(Request:D $request --> Response:D) {

return NOT-FOUND($request);
} elsif $possible-param && !%loc{$uri} {
$request.params{~$possible-param.match(/<[A..Z a..z 0..9 \- \_]>+/)} = $uri;
%loc := %loc{$possible-param};
} else {
$request.params{~$possible-param.match(/<[A..Z a..z 0..9 \- \_]>+/)} = $uri;
%loc := %loc{$possible-param};
} else {
%loc := %loc{$uri};
}

# If the route could possibly be static
with %loc{$request.method} {
if %loc{$request.method}.static {
return %loc{$request.method}($request);
}
}
if %loc{$request.method}.static {
return %loc{$request.method}($request);
}
}
}

# For HEAD requests we should return a GET request. The decoder will delete the body
Expand Down Expand Up @@ -277,14 +287,54 @@ sub routes(--> Hash:D) is export {
%ROUTES.clone;
}

sub plugin(Str:D $plugin, **@args --> Array:D) is export {
@PLUGINS.push: [$plugin, @args];
}

sub handle(Humming-Bird::Glue::Request:D $request) {
return ([o] @ADVICE).(dispatch-request($request));
state &advice-handler = [o] @ADVICE;
return &advice-handler(dispatch-request($request));
}

sub listen(Int:D $port, Str:D $addr = '0.0.0.0', :$no-block, :$timeout = 3, :$backend = Humming-Bird::Backend::HTTPServer) is export {
use Terminal::ANSIColor;
my $server = $backend.new(:$port, :$addr, :$timeout);

say "Humming-Bird listening on port http://localhost:$port";
for @PLUGINS -> [$plugin, @args] {
my $fq = 'Humming-Bird::Plugin::' ~ $plugin;
{
{
require ::($fq);
CATCH {
default {
die "It doesn't look like $fq is a valid plugin? Are you sure it's installed? $_";
}
}
}
use MONKEY;
my $instance;
EVAL "use $fq; \$instance = $fq.new;";
$instance.register($server, %ROUTES, @MIDDLEWARE, @ADVICE, |@args);
say "Plugin: $fq ", colored('✓', 'green');

CATCH {
default {
die "Something went wrong registering plugin: $fq\n\n$_";
}
}
}
}

say(
colored('Humming-Bird', 'green'),
" listening on port http://localhost:$port"
);
say '';
say(
colored('Warning', 'yellow'),
': Humming-Bird is currently running in DEV mode, please set HUMMING_BIRD_ENV to PROD or PRODUCTION to enable PRODUCTION mode.',
"\n"
) without ($*ENV<HUMMING_BIRD_ENV>);
if $no-block {
start {
$server.listen(&handle);
Expand Down
Loading