diff --git a/CHANGES b/CHANGES index ba92854..551b13b 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,9 @@ +0.004 2023-08-02 + - Alpha release #4 + - Add error handling for routes and events + - Add the Slick::Error class + - Various POD changes + 0.003 2023-08-01 - Alpha release #3 - Add routers allowing for easier use in multi-file projects diff --git a/README.md b/README.md index 04de05c..a4afde4 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,28 @@ you can also take advantage of swappable backends and Plack middlewares extremel Currently, Slick supports `MySQL` and `Postgres` but there are plans to implement `Oracle` and `MS SQL Server`. +## Philosophy + +Slick is aiming to become a "Batteries Included" framework for building REST API's and Micro-Services in +Perl. This will include tooling for all sorts of Micro-Service concerns like Databases, Caching, Queues, +User-Agents, and much more. + +### Goals + +- [x] Database management (auto-enabled) +- [x] Migrations (auto-enabled) +- [ ] CLI +- [ ] Caching via Redis (optional) +- [ ] RabbitMQ built-ins (optional) +- [ ] AWS S3 support (optional) +- [ ] User-Agents, including Client API exports +- [ ] AWS SQS support (optional) + +*Note*: All of these features excluding database stuff will be enabled optionally at run-time. + ## Examples +### Single File App ```perl use 5.036; @@ -58,6 +78,62 @@ $s->post('/users' => sub { $s->run; # Run the application. ``` +See the examples directory for this example. + +### Multi-file Router App + +```perl +### INSIDE lib/MyApp/ItemRouter.pm + +package MyApp::ItemRouter; + +use base qw(Slick::Router); + +my $router = __PACKAGE__->new(base => '/items'); + +$router->get('/{id}' => sub { + my ($app, $context) = @_; + my $item = $app->database('items')->select_one({ id => $context->param('id') }); + $context->json($item); +}); + +$router->post('' => sub { + my ($app, $context) = @_; + my $new_item = $context->content; + + # Do some sort of validation + if (not $app->helper('item_validator')->validate($new_item)) { + $context->status(400)->json({ error => 'Bad Request' }); + } + else { + $app->database('items')->insert('items', $new_item); + $context->json($new_item); + } +}); + +sub router { + return $router; +} + +1; + +### INSIDE OF YOUR RUN SCRIPT + +use 5.036; +use lib 'lib'; + +use Slick; +use MyApp::ItemRouter; + +my $slick = Slick->new; + +$slick->register(MyApp::ItemRouter->router); + +$slick->run; +``` + +See the examples directory for this example. + ### Running with `plackup` If you wish to use `plackup` you can change the final call to `run` to a call to `app` diff --git a/examples/multi-file/app.pl b/examples/multi-file/app.pl new file mode 100644 index 0000000..fd6f15a --- /dev/null +++ b/examples/multi-file/app.pl @@ -0,0 +1,13 @@ +use 5.036; +use lib 'lib'; + +use Slick; +use MyApp::ItemRouter; + +my $slick = Slick->new; + +$slick->helper(item_validator => sub { return exists shift->{name} }); + +$slick->register(MyApp::ItemRouter->router); + +$slick->run; diff --git a/examples/multi-file/lib/MyApp/ItemRouter.pm b/examples/multi-file/lib/MyApp/ItemRouter.pm new file mode 100644 index 0000000..bba277b --- /dev/null +++ b/examples/multi-file/lib/MyApp/ItemRouter.pm @@ -0,0 +1,31 @@ +package MyApp::ItemRouter; + +use base qw(Slick::Router); + +my $router = __PACKAGE__->new(base => '/items'); + +$router->get('/{id}' => sub { + my ($app, $context) = @_; + my $item = $app->database('items')->select_one({ id => $context->param('id') }); + $context->json($item); +}); + +$router->post('' => sub { + my ($app, $context) = @_; + my $new_item = $context->content; + + # Do some sort of validation + if (not $app->helper('item_validator')->($new_item)) { + $context->status(400)->json({ error => 'Bad Request' }); + } + else { + $app->database('items')->insert('items', $new_item); + $context->json($new_item); + } +}); + +sub router { + return $router; +} + +1; diff --git a/lib/Slick.pm b/lib/Slick.pm index 04153ea..e37af8c 100644 --- a/lib/Slick.pm +++ b/lib/Slick.pm @@ -11,14 +11,18 @@ use Plack::Builder qw(builder enable); use Plack::Request; use Slick::Context; use Slick::Events qw(EVENTS BEFORE_DISPATCH AFTER_DISPATCH); +use Slick::Error; use Slick::Database; use Slick::Methods qw(METHODS); use Slick::Route; use Slick::Router; use Slick::RouteMap; use Slick::Util; +use experimental qw(try); -our $VERSION = '0.003'; +no warnings qw(experimental::try); + +our $VERSION = '0.004'; with 'Slick::EventHandler'; with 'Slick::RouteManager'; @@ -45,11 +49,10 @@ has timeout => ( ); has env => ( - is => 'ro', - lazy => 1, + is => 'rw', isa => Str, default => sub { - return $ENV{SLICK_ENV} || $ENV{PLACK_ENV} || 'dev'; + return $ENV{SLICK_ENV} // $ENV{PLACK_ENV} // 'dev'; } ); @@ -95,37 +98,56 @@ sub _dispatch { my $request = Plack::Request->new(shift); my $context = Slick::Context->new( request => $request, ); - - my $method = lc( $request->method ); + my $method = lc( $request->method ); for ( @{ $self->event_handlers->{ BEFORE_DISPATCH() } } ) { - if ( !$_->( $self, $context ) ) { - goto DONE; - } + try { $_->( $self, $context ) // goto DONE; } + catch ($e) { + push $context->stash->{'slick.errors'}->@*, Slick::Error->new($e) + }; } my $route = $self->handlers->get( $context->request->request_uri, $method, $context ); + if ( defined $route ) { - $route->dispatch( $self, $context ); + try { $route->dispatch( $self, $context ); } + catch ($e) { + push $context->stash->{'slick.errors'}->@*, Slick::Error->new($e) + } } else { $context->status(405); $context->body('405 Method Not Supported'); - goto DONE; } - $_->( $self, $context ) - for ( @{ $self->event_handlers->{ AFTER_DISPATCH() } } ); + for ( @{ $self->event_handlers->{ AFTER_DISPATCH() } } ) { + try { $_->( $self, $context ) // goto DONE; } + catch ($e) { + push $context->stash->{'slick.errors'}->@*, Slick::Error->new($e); + } + } DONE: + if ( $context->stash->{'slick.errors'}->@* && ( $self->env eq 'dev' ) ) { + say $_->error for $context->stash->{'slick.errors'}->@*; + $context->json( + [ map { $_->to_hash } $context->stash->{'slick.errors'}->@* ] ); + } + # HEAD requests only want headers. $context->body = [] if $method eq 'head'; return $context->to_psgi; } +sub BUILD { + my $self = shift; + + return $self; +} + sub helper { my ( $self, $name, $helper ) = @_; @@ -206,7 +228,9 @@ sub app { return builder { enable 'Plack::Middleware::AccessLog' => format => 'combined'; enable $_->@* for $self->middlewares->@*; - sub { return $self->_dispatch(@_); } + sub { + return $self->_dispatch(@_); + } }; } @@ -280,6 +304,63 @@ has a migration, and also serves some JSON. $s->run; # Run the application. +=head3 A Multi-file application + +This is a multi-file application that uses L. + + ### INSIDE OF YOUR ROUTER MODULE lib/MyApp/ItemRouter.pm + + package MyApp::ItemRouter; + + use base qw(Slick::Router); + + my $router = __PACKAGE__->new(base => '/items'); + + $router->get('/{id}' => sub { + my ($app, $context) = @_; + my $item = $app->database('items')->select_one({ id => $context->param('id') }); + $context->json($item); + }); + + $router->post('' => sub { + my ($app, $context) = @_; + my $new_item = $context->content; + + # Do some sort of validation + if (not $app->helper('item_validator')->($new_item)) { + $context->status(400)->json({ error => 'Bad Request' }); + } + else { + $app->database('items')->insert('items', $new_item); + $context->json($new_item); + } + }); + + sub router { + return $router; + } + + 1; + + ### INSIDE OF YOUR RUN SCRIPT 'app.pl' + + use 5.036; + use lib 'lib'; + + use Slick; + use MyApp::ItemRouter; + + my $slick = Slick->new; + + $slick->helper(item_validator => sub { + return exists shift->{name}; + }); + + # Register as many routers as you want! + $slick->register(MyApp::ItemRouter->router); + + $slick->run; + =head3 Running with plackup If you wish to use C you can change the final call to C to a call to C: @@ -478,6 +559,8 @@ Inherited from L. =item * L +=item * L + =back =cut diff --git a/lib/Slick/Context.pm b/lib/Slick/Context.pm index 642e533..44d16c3 100644 --- a/lib/Slick/Context.pm +++ b/lib/Slick/Context.pm @@ -24,7 +24,7 @@ has id => ( has stash => ( is => 'rw', isa => HashRef, - default => sub { return {}; } + default => sub { return { 'slick.errors' => [] }; } ); has request => ( @@ -117,7 +117,12 @@ sub query { } sub to_psgi { - my $response = shift->response; + my $self = shift; + my $response = $self->response; + + $response->{status} = 500 + if $self->stash->{'slick.errors'}->@*; + return [ $response->{status}, $response->{headers}, $response->{body} ]; } diff --git a/lib/Slick/Error.pm b/lib/Slick/Error.pm new file mode 100644 index 0000000..1245416 --- /dev/null +++ b/lib/Slick/Error.pm @@ -0,0 +1,86 @@ +package Slick::Error; + +use 5.036; + +use Moo; +use Scalar::Util qw(blessed); + +has error => ( + is => 'ro', + required => 1 +); + +has error_type => ( + is => 'ro', + required => 1 +); + +around new => sub { + return $_[2] + if blessed( $_[2] ) && ( blessed( $_[2] ) eq 'Slick::Error' ); + + return bless { + error_type => blessed( $_[2] ) // 'SCALAR', + error => $_[2] + }, + $_[1]; +}; + +sub to_hash { + my $self = shift; + return { error => $self->error, error_type => $self->error_type }; +} + +1; + +=encoding utf8 + +=head1 NAME + +Slick::Error + +=head1 SYNOPSIS + +A Moo wrapper for errors in L. + +=head1 API + +=head2 error + +Returns the actual error that was used to construct this object. + +=head2 error_type + +Returns the type of the error, C, or a blessed type. + +=head2 to_hash + +Converts the error to a hash. + +=head1 See also + +=over 2 + +=item * L + +=item * L + +=item * L + +=item * L + +=item * L + +=item * L + +=item * L + +=item * L + +=item * L + +=item * L + +=back + +=cut diff --git a/lib/Slick/Route.pm b/lib/Slick/Route.pm index 05310a4..cbfd228 100644 --- a/lib/Slick/Route.pm +++ b/lib/Slick/Route.pm @@ -1,8 +1,13 @@ package Slick::Route; +use 5.036; + use Moo; use Types::Standard qw(CodeRef HashRef Str); use Slick::Events qw(EVENTS BEFORE_DISPATCH AFTER_DISPATCH); +use experimental qw(try); + +no warnings qw(experimental::try); with 'Slick::EventHandler'; @@ -19,19 +24,34 @@ has callback => ( ); sub dispatch { - my ( $self, @args ) = @_; + my ( $self, $app, $context ) = @_; + + my @args = ( $app, $context ); for ( @{ $self->event_handlers->{ BEFORE_DISPATCH() } } ) { - if ( !$_->(@args) ) { - goto DONE; + try { + if ( !$_->(@args) ) { + goto DONE; + } } + catch ($e) { + push $context->stash->{'slick.errors'}->@*, Slick::Error->new($e); + }; } - $self->callback->(@args); + try { $self->callback->(@args); } + catch ($e) { + push $context->stash->{'slick.errors'}->@*, Slick::Error->new($e) + }; for ( @{ $self->event_handlers->{ AFTER_DISPATCH() } } ) { - if ( !$_->(@args) ) { - goto DONE; + try { + if ( !$_->(@args) ) { + goto DONE; + } + } + catch ($e) { + push $context->stash->{'slick.errors'}->@*, Slick::Error->new($e); } } diff --git a/t/01-basic.t b/t/01-basic.t index 75dddb6..d37cb59 100644 --- a/t/01-basic.t +++ b/t/01-basic.t @@ -92,6 +92,15 @@ $slick->get( '/redirect', sub { $_[1]->body( $_[1]->redirect('/bob') ) } ); $slick->get( '/redirect_other', sub { $_[1]->body( $_[1]->redirect( '/bob', 301 ) ) } ); +$slick->get( + '/dies', + sub { + is rindex( $_[1]->stash->{'slick.errors'}->[0]->error, 'test die', 0 ), + 0; + }, + { before_dispatch => [ sub { die 'test die' } ] } +); + my $router = Slick::Router->new( base => '/foo' ); $router->get( '/bob' => sub { return $_[1]->json( { foo => 'bar' } ); } ); $slick->register($router); @@ -231,4 +240,51 @@ $response = $slick->_dispatch($t); is $response->[0], '200'; is $response->[2]->[0], '{"foo":"bar"}'; +$t = { + QUERY_STRING => '', + REMOTE_ADDR => "127.0.0.1", + REMOTE_PORT => 46604, + REQUEST_METHOD => "GET", + REQUEST_URI => "/foo/bob", + SCRIPT_NAME => "", + SERVER_NAME => "127.0.0.1", + SERVER_PORT => 8000, + SERVER_PROTOCOL => "HTTP/1.1" +}; + +$response = $slick->_dispatch($t); + +is $response->[0], '200'; +is $response->[2]->[0], '{"foo":"bar"}'; + +$t = { + QUERY_STRING => '', + REMOTE_ADDR => "127.0.0.1", + REMOTE_PORT => 46604, + REQUEST_METHOD => "GET", + REQUEST_URI => "/dies", + SCRIPT_NAME => "", + SERVER_NAME => "127.0.0.1", + SERVER_PORT => 8000, + SERVER_PROTOCOL => "HTTP/1.1" +}; + +$response = $slick->_dispatch($t); + +my $e = Slick::Error->new('foo'); + +is $e->error, 'foo'; +isa_ok $e, 'Slick::Error'; +is $e->error_type, 'SCALAR'; + +isa_ok Slick::Error->new($e), 'Slick::Error'; +is Slick::Error->new($e)->error, 'foo'; +is Slick::Error->new($e)->error_type, 'SCALAR'; + +$e = Slick::Error->new($slick); + +isa_ok $e, 'Slick::Error'; +is Slick::Error->new($e)->error, $slick; +is Slick::Error->new($e)->error_type, 'Slick'; + done_testing;