Skip to content

Commit 696ec9d

Browse files
authored
3.0.4 - Plugins (#79)
* Add plugin system * document plugins * clean up * fix tests * clean up
1 parent d67cc65 commit 696ec9d

16 files changed

+273
-59
lines changed

META6.json

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
"build-depends": [
77
],
88
"depends": [
9-
"HTTP::Status",
10-
"DateTime::Format",
11-
"MIME::Types",
12-
"JSON::Fast",
13-
"URI::Encode",
14-
"UUID::V4"
9+
"HTTP::Status:auth<zef:lizmat>",
10+
"DateTime::Format:auth<zef:raku-community-modules>",
11+
"MIME::Types:auth<zef:raku-community-modules>",
12+
"JSON::Fast:auth<cpan:TIMOTIMO>",
13+
"URI::Encode:auth<zef:raku-community-modules>",
14+
"UUID::V4:auth<zef:masukomi>",
15+
"Terminal::ANSIColor:auth<zef:lizmat>"
1516
],
1617
"description": "A simple and composable web applications framework.",
1718
"license": "MIT",
@@ -23,7 +24,11 @@
2324
"Humming-Bird::Backend::HTTPServer": "lib/Humming-Bird/Backend/HTTPServer.rakumod",
2425
"Humming-Bird::Middleware": "lib/Humming-Bird/Middleware.rakumod",
2526
"Humming-Bird::Advice": "lib/Humming-Bird/Advice.rakumod",
26-
"Humming-Bird::Glue": "lib/Humming-Bird/Glue.rakumod"
27+
"Humming-Bird::Glue": "lib/Humming-Bird/Glue.rakumod",
28+
"Humming-Bird::Plugin": "lib/Humming-Bird/Plugin.rakumod",
29+
"Humming-Bird::Plugin::Config": "lib/Humming-Bird/Plugin/Config.rakumod",
30+
"Humming-Bird::Plugin::Logger": "lib/Humming-Bird/Plugin/Logger.rakumod",
31+
"Humming-Bird::Plugin::Session": "lib/Humming-Bird/Plugin/Session.rakumod"
2732
},
2833
"resources": [
2934
],

README.md

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Then you have the actual application logic in `Humming-Bird::Core` that handles:
2222
- Middleware
2323
- Advice (end of stack middleware)
2424
- Simple global error handling
25+
- Plugin system
2526

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

104+
#### Using plugins
105+
```raku
106+
use v6.d;
107+
108+
use Humming-Bird::Core;
109+
110+
plugin 'Logger'; # Corresponds to the pre-built Humming-Bird::Plugin::Logger plugin.
111+
plugin 'Config'; # Corresponds to the pre-built Humming-Bird::Plugin::Config plugin.
112+
113+
get('/', sub ($request, $response) {
114+
my $database_url = $request.config<database_url>;
115+
$response.html('Here's my database url :D ' ~ $database_url);
116+
});
117+
118+
listen(8080);
119+
```
120+
103121
#### Routers
104122
```raku
105123
use v6.d;
@@ -112,7 +130,7 @@ use Humming-Bird::Middleware;
112130
# regardless of whether you're using the sub or Router process for defining routes.
113131
my $router = Router.new(root => '/');
114132

115-
$router.middleware(&middleware-logger); # middleware-logger is provided by the Middleware package
133+
plugin 'Logger';
116134

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

177+
Since Humming-Bird `3.0.4` it may be more favorable to use plugins to register global middlewares.
178+
159179
#### Swappable Backends
160180
```raku
161181
use v6.d;
@@ -172,6 +192,61 @@ listen(:backend(Humming-Bird::Backend::MyBackend));
172192

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

195+
## Swappable backends
196+
197+
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,
198+
and view `Humming-Bird::Backend::HTTPServer` for an example of how to implement a Humming-Bird backend.
199+
200+
## Plugin system
201+
202+
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
203+
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
204+
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.
205+
206+
Here is an example of a plugin:
207+
208+
```raku
209+
use MONKEY-TYPING;
210+
use JSON::Fast;
211+
use Humming-Bird::Plugin;
212+
use Humming-Bird::Core;
213+
214+
unit class Humming-Bird::Plugin::Config does Humming-Bird::Plugin;
215+
216+
method register($server, %routes, @middleware, @advice, **@args) {
217+
my $filename = @args[0] // '.humming-bird.json';
218+
my %config = from-json($filename.IO.slurp // '{}');
219+
220+
augment class Humming-Bird::Glue::HTTPAction {
221+
method config(--> Hash:D) {
222+
%config;
223+
}
224+
}
225+
226+
CATCH {
227+
default {
228+
warn 'Failed to find or parse your ".humming-bird.json" configuration. Ensure your file is well formed, and does exist.';
229+
}
230+
}
231+
}
232+
```
233+
234+
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.
235+
236+
Then to register it in a Humming-Bird application:
237+
238+
```
239+
use Humming-Bird::Core;
240+
241+
plugin 'Config', 'config/humming-bird.json'; # Second arg will be pushed to he **@args array in the register method.
242+
243+
get('/', sub ($request, $response) {
244+
$response.write($request.config<foo>); # Echo back the <foo> field in our JSON config.
245+
});
246+
247+
listen(8080);
248+
```
249+
175250
## Design
176251
- Humming-Bird should be easy to pickup, and simple for developers new to Raku and/or web development.
177252
- 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.

examples/basic/.humming-bird.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"secret_message": "boo"
3+
}

examples/basic/basic.raku

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ use Humming-Bird::Core;
55
use Humming-Bird::Middleware;
66
use Humming-Bird::Advice;
77

8+
plugin 'Config';
9+
plugin 'Logger';
10+
811
# Simple static routes
912
get('/', -> $request, $response {
1013
$response.html('<h1>Hello World!</h1>');
@@ -39,7 +42,7 @@ get('/help.txt', -> $request, $response {
3942
# Simple Middleware example
4043
get('/logged', -> $request, $response {
4144
$response.html('<h1>Your request has been logged. Check the console.</h1>');
42-
}, [ &middleware-logger ]); # m_logger is provided by Humming-Bird::Middleware
45+
});
4346

4447

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

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

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

7073

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

109-
# After middleware, Response --> Response
110-
advice(&advice-logger);
111-
112-
113112
# Static content
114113
static('/static', '/var/www/static'); # Server static content on '/static', from '/var/www/static'
115114

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

123122
get('/session', sub ($request, $response) {
124123
$response.write($request.stash<session><user>.raku)
125-
}, [ middleware-session ]);
124+
}, [ &middleware-session ]);
126125

127126
get('/form', sub ($request, $response) {
128127
$response.html('<form enctype="multipart/form-data" action="/form" method="POST"><input type="file" name="file"><input name="name"></form>');
@@ -136,14 +135,11 @@ post('/form', sub ($request, $response) {
136135

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

145-
# Run the application
146-
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.
147-
# You can also set the :no-block adverb to stop the call from blocking, and be run on the task scheduler.
142+
# Run the app
143+
listen(9000, timeout => 3);
148144

149145
# vim: expandtab shiftwidth=4

lib/Humming-Bird/Advice.rakumod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,5 @@ unit module Humming-Bird::Advice;
66
sub advice-logger(Humming-Bird::Glue::Response:D $response --> Humming-Bird::Glue::Response:D) is export {
77
my $log = "{ $response.status.Int } { $response.status } | { $response.initiator.path } | ";
88
$log ~= $response.header('Content-Type') ?? $response.header('Content-Type') !! "no-content";
9-
109
$response.log: $log;
1110
}

lib/Humming-Bird/Core.rakumod

Lines changed: 72 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,30 @@ our %ROUTES;
1717
our @MIDDLEWARE;
1818
our @ADVICE = [{ $^a }];
1919
our %ERROR;
20+
our @PLUGINS;
2021

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

27-
submethod TWEAK {
28-
@!middlewares.prepend: @MIDDLEWARE;
29-
}
30-
31-
method CALL-ME(Request:D $req) {
28+
method CALL-ME(Request:D $req --> Response:D) {
29+
my @middlewares = [|@!middlewares, |@MIDDLEWARE, -> $a, $b, &c { &!callback($a, $b) }];
3230
my $res = Response.new(initiator => $req, status => HTTP::Status(200));
33-
if @!middlewares.elems {
34-
state &composition = @!middlewares.map({ .assuming($req, $res) }).reduce(-> &a, &b { &a({ &b }) });
35-
# Finally, the main callback is added to the end of the chain
36-
&composition(&!callback.assuming($req, $res));
37-
} else {
31+
if @middlewares.elems > 1 {
32+
# For historical purposes this code will stay here, unfortunately, it was not performant enough.
33+
# This code was written on the first day I started working on Humming-Bird. - RF
34+
# 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) } );
35+
36+
for @middlewares -> &middleware {
37+
my Bool:D $next = False;
38+
&middleware($req, $res, sub { $next = True } );
39+
last unless $next;
40+
}
41+
return $res;
42+
}
43+
else {
3844
# If there is are no middlewares, just process the callback
3945
&!callback($req, $res);
4046
}
@@ -48,7 +54,7 @@ sub split_uri(Str:D $uri --> List:D) {
4854

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

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

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

7480
method !add-route(Route:D $route, HTTPMethod:D $method --> Route:D) {
7581
my &advice = [o] @!advice;
7682
my &cb = $route.callback;
7783
my $r = $route.clone(path => $!root ~ $route.path,
78-
middlewares => [|@!middlewares, |$route.middlewares],
84+
middlewares => [|$route.middlewares, |@!middlewares],
7985
callback => { &advice(&cb($^a, $^b)) });
8086
@!routes.push: $r;
8187
delegate-route($r, $method);
@@ -116,6 +122,10 @@ class Router is export {
116122
self.delete('', &callback, @middlewares);
117123
}
118124

125+
method plugin($plugin) {
126+
@PLUGINS.push: $plugin;
127+
}
128+
119129
method middleware(&middleware) {
120130
@!middlewares.push: &middleware;
121131
}
@@ -162,18 +172,18 @@ sub dispatch-request(Request:D $request --> Response:D) {
162172

163173
return NOT-FOUND($request);
164174
} elsif $possible-param && !%loc{$uri} {
165-
$request.params{~$possible-param.match(/<[A..Z a..z 0..9 \- \_]>+/)} = $uri;
166-
%loc := %loc{$possible-param};
167-
} else {
175+
$request.params{~$possible-param.match(/<[A..Z a..z 0..9 \- \_]>+/)} = $uri;
176+
%loc := %loc{$possible-param};
177+
} else {
168178
%loc := %loc{$uri};
169179
}
170180

171181
# If the route could possibly be static
172182
with %loc{$request.method} {
173-
if %loc{$request.method}.static {
174-
return %loc{$request.method}($request);
175-
}
176-
}
183+
if %loc{$request.method}.static {
184+
return %loc{$request.method}($request);
185+
}
186+
}
177187
}
178188

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

290+
sub plugin(Str:D $plugin, **@args --> Array:D) is export {
291+
@PLUGINS.push: [$plugin, @args];
292+
}
293+
280294
sub handle(Humming-Bird::Glue::Request:D $request) {
281-
return ([o] @ADVICE).(dispatch-request($request));
295+
state &advice-handler = [o] @ADVICE;
296+
return &advice-handler(dispatch-request($request));
282297
}
283298

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

287-
say "Humming-Bird listening on port http://localhost:$port";
303+
for @PLUGINS -> [$plugin, @args] {
304+
my $fq = 'Humming-Bird::Plugin::' ~ $plugin;
305+
{
306+
{
307+
require ::($fq);
308+
CATCH {
309+
default {
310+
die "It doesn't look like $fq is a valid plugin? Are you sure it's installed? $_";
311+
}
312+
}
313+
}
314+
use MONKEY;
315+
my $instance;
316+
EVAL "use $fq; \$instance = $fq.new;";
317+
$instance.register($server, %ROUTES, @MIDDLEWARE, @ADVICE, |@args);
318+
say "Plugin: $fq ", colored('', 'green');
319+
320+
CATCH {
321+
default {
322+
die "Something went wrong registering plugin: $fq\n\n$_";
323+
}
324+
}
325+
}
326+
}
327+
328+
say(
329+
colored('Humming-Bird', 'green'),
330+
" listening on port http://localhost:$port"
331+
);
332+
say '';
333+
say(
334+
colored('Warning', 'yellow'),
335+
': Humming-Bird is currently running in DEV mode, please set HUMMING_BIRD_ENV to PROD or PRODUCTION to enable PRODUCTION mode.',
336+
"\n"
337+
) without ($*ENV<HUMMING_BIRD_ENV>);
288338
if $no-block {
289339
start {
290340
$server.listen(&handle);

0 commit comments

Comments
 (0)