Skip to content

Commit

Permalink
3.0.4 - Plugins (#79)
Browse files Browse the repository at this point in the history
* Add plugin system

* document plugins

* clean up

* fix tests

* clean up
  • Loading branch information
rawleyfowler authored Dec 11, 2023
1 parent d67cc65 commit 696ec9d
Show file tree
Hide file tree
Showing 16 changed files with 273 additions and 59 deletions.
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

0 comments on commit 696ec9d

Please sign in to comment.