File Coverage

blib/lib/Plack/Middleware/PrettyException.pm
Criterion Covered Total %
statement 98 104 94.2
branch 30 36 83.3
condition 23 30 76.6
subroutine 14 14 100.0
pod 1 2 50.0
total 166 186 89.2


line stmt bran cond sub pod time code
1             package Plack::Middleware::PrettyException;
2              
3             # ABSTRACT: Capture exceptions and present them as HTML or JSON
4              
5             our $VERSION = '1.009'; # VERSION
6              
7 1     1   719 use 5.010;
  1         4  
8 1     1   5 use strict;
  1         2  
  1         20  
9 1     1   5 use warnings;
  1         2  
  1         25  
10 1     1   5 use parent qw(Plack::Middleware);
  1         2  
  1         5  
11 1     1   66 use Plack::Util;
  1         3  
  1         35  
12 1     1   7 use Plack::Util::Accessor qw(force_json);
  1         2  
  1         6  
13 1     1   57 use HTTP::Headers;
  1         2  
  1         38  
14 1     1   6 use JSON::MaybeXS qw(encode_json);
  1         2  
  1         66  
15 1     1   529 use HTTP::Status qw(is_error);
  1         4865  
  1         144  
16 1     1   8 use Scalar::Util 'blessed';
  1         2  
  1         50  
17 1     1   472 use Log::Any qw($log);
  1         8487  
  1         5  
18              
19             sub call {
20 16     16 1 93992 my $self = shift;
21 16         28 my $env = shift;
22              
23 16         48 my $r;
24             my $error;
25 16         0 my $exception;
26 16         28 my $died = 0;
27             eval {
28 16         62 $r = $self->app->($env);
29 7         163 1;
30 16 100       23 } or do {
31 9         150799 my $e = $@;
32 9         19 $died = 1;
33 9 100       36 if ( blessed($e) ) {
34 7         13 $exception = $e;
35 7 50       59 if ( $e->can('message') ) {
36 7         25 $error = $e->message;
37             }
38             else {
39 0         0 $error = '' . $e;
40             }
41 7 50       82 $r->[0] =
    100          
42             $e->can('status_code') ? $e->status_code
43             : $e->can('http_status') ? $e->http_status
44             : 500;
45 7   100     37 $r->[0] ||= 500;
46              
47 7 100 66     36 if ( $r->[0] =~ /^3/ && $e->can('location') ) {
48 1         3 push( @{ $r->[1] }, Location => $e->location );
  1         6  
49 1 50       7 push( @{ $r->[2] }, $e->location ) unless $r->[2];
  1         5  
50             }
51              
52             }
53             else {
54 2         6 $r->[0] = 500;
55 2         4 $error = $e;
56             }
57             };
58              
59             return Plack::Util::response_cb(
60             $r,
61             sub {
62 16     16   217 my $r = shift;
63              
64 16 100 100     58 if ( !$died && !is_error( $r->[0] ) ) {
65              
66             # all is ok!
67 2         18 return;
68             }
69 14 100       90 if ( $r->[0] =~ /^3/ ) {
70              
71             # it's a redirect
72 1         3 return;
73             }
74              
75             # there was an error!
76              
77 13 100       36 unless ($error) {
78 6   100     16 my $body = $r->[2] || 'error not found in body';
79 6 100       27 $error = ref($body) eq 'ARRAY' ? join( '', @$body ) : $body;
80             }
81              
82             my $location = join( '',
83 13         28 map { $env->{$_} } qw(HTTP_HOST SCRIPT_NAME PATH_INFO) );
  39         107  
84 13         75 $log->error( $location . ': ' . $error );
85              
86 13         47 my $orig_headers = HTTP::Headers->new( @{ $r->[1] } );
  13         81  
87 13         668 my $err_headers = Plack::Util::headers( [] );
88 13         283 my $err_body;
89              
90             # it already is JSON, so return that
91 13 100       43 if ( $orig_headers->content_type =~ m{application/json}i ) {
92 2         58 return;
93             }
94              
95             # force json, or client requested JSON, so render errors as JSON
96 11 100 66     176 if ($self->force_json
      100        
97             || ( exists $env->{HTTP_ACCEPT}
98             && $env->{HTTP_ACCEPT} =~ m{application/json}i )
99             ) {
100 4         57 $err_headers->set( 'content-type' => 'application/json' );
101 4         91 my $err_payload = { status => 'error', message => "" . $error };
102 4 50 66     16 if ($exception && $exception->can('does')) {
103 0 0       0 if ($exception->does('Throwable::X')) {
104 0         0 my $payload = $exception->payload;
105 0         0 while (my ($k, $v) = each %$payload) {
106 0         0 $err_payload->{$k} = $v;
107             }
108 0         0 $err_payload->{ident} = $exception->ident;
109             }
110             }
111              
112 4         35 $err_body = encode_json( $err_payload );
113             }
114              
115             # return HTML as default
116             else {
117 7         111 $err_headers->set(
118             'content-type' => 'text/html;charset=utf-8' );
119 7         169 $err_body = $self->render_html_error( $r->[0], $error, $exception, $env );
120             }
121 11         52 $r->[1] = $err_headers->headers;
122 11         156 $r->[2] = [$err_body];
123 11         118 return;
124             }
125 16         118 );
126             }
127              
128             sub render_html_error {
129 7     7 0 20 my ( $self, $status, $error, $exception, $env ) = @_;
130              
131 7   50     16 $status ||= 'unknown HTTP status code';
132 7   50     17 $error ||= 'unknown error';
133              
134 7         11 my $more='';
135 7 100 100     43 if ($exception && $exception->can('does')) {
136 2         49 my @more;
137 2 100       9 if ($exception->does('Throwable::X')) {
138 1         7 push(@more, "<li><strong>".$exception->ident."</strong></li>");
139 1   50     6 push(@more, "<li><strong>".($exception->message || 'unknown exception message')."</strong></li>");
140 1         9 my $payload = $exception->payload;
141 1         9 while (my ($k, $v) = each %$payload) {
142 1   50     10 push(@more,sprintf("<li>%s: %s</li>", $k, $v // ''));
143             }
144             }
145 2 100       62 if (@more) {
146 1         5 $more='<ul>'.join("\n",@more).'</ul>';
147             }
148             }
149              
150 7         38 return <<"UGLYERROR";
151             <html>
152             <head><title>Error $status</title></head>
153             <body>
154             <h1>Error $status</h1>
155             <p>$error</p>
156             $more
157             </body>
158             </html>
159             UGLYERROR
160             }
161              
162             1;
163              
164             __END__
165              
166             =pod
167              
168             =encoding UTF-8
169              
170             =head1 NAME
171              
172             Plack::Middleware::PrettyException - Capture exceptions and present them as HTML or JSON
173              
174             =head1 VERSION
175              
176             version 1.009
177              
178             =head1 SYNOPSIS
179              
180             use Plack::Builder;
181             builder {
182             enable "Plack::Middleware::PrettyException",
183             $app;
184             };
185              
186             # then in your app in some controller / model / wherever
187              
188             # just die
189             die "something went wrong";
190              
191             # use HTTP::Throwable
192             http_throw(
193             NotAcceptable => { message => 'You have to be kidding me!' }
194             );
195              
196             # use a custom exception that implements http_status
197             My::X::BadParam->throw({ status=>400, message=>'required param missing'})
198              
199             # clients get either
200             # JSON (if Accept-header indicates that JSON is expected)
201             # or a plain HTML error message
202              
203             =head1 DESCRIPTION
204              
205             C<Plack::Middleware::PrettyException> allows you to use exceptions in
206             your models (and also controllers, but they should be kept slim!) and
207             have them rendered as JSON or nice-ish HTML with very little fuzz.
208              
209             But if your Plack app returns an HTTP status code indicating an error
210             (4xx/5xx) or just dies somewhere, the client also sees a pretty
211             exception.
212              
213             So instead of capturing exceptions in your controller actions and
214             converting them to proper error messages, you just let the exception
215             propagate up to this middleware, which will then do the rendering.
216             This leads to much cleaner code in your controller, and to proper
217             exception usage in your model.
218              
219             =head2 Example
220              
221             Here is am example controller implementing some kind of update:
222              
223             # SomeController.pm
224             sub some_update_action {
225             my ($self, $req, $id) = @_;
226              
227             my $item = $self->some_model->load_item($id);
228             my $user = $req->get_user;
229             $self->auth_model->may_edit($user, $item);
230              
231             my $payload = $req->get_json_payload;
232              
233             my $rv = $self->some_model->update_item($item, $payload);
234              
235             $req->json_response($rv);
236             }
237              
238             The lack of error handling makes the intention of this piece of code very clear. "The code is easy to reason about", as the current saying goes.
239              
240             Here's the matching model
241              
242             # SomeModel
243             sub load_item {
244             my ($self, $id) = @_;
245              
246             my $item = $self->resultset('Foo')->find($id);
247             return $item if $item;
248              
249             My::X::NotFound->throw({
250             ident=>'cannot_find_foo',
251             message=>'Cannot load Foo from id %{id}s',
252             id=>$id,
253             });
254             }
255              
256             C<My::X::NotFound> could be a exception class based on L<Throwable::X|https://metacpan.org/pod/Throwable::X>:
257              
258             package My::X;
259             use Moose;
260             with qw(Throwable::X);
261            
262             use Throwable::X -all;
263            
264             has [qw(http_status)] => (
265             is => 'ro',
266             default => 400,
267             traits => [Payload],
268             );
269            
270             no Moose;
271             __PACKAGE__->meta->make_immutable;
272            
273             package My::X::NotFound;
274             use Moose;
275             extends 'My::X';
276             use Throwable::X -all;
277            
278             has id => (
279             is => 'ro',
280             traits => [Payload],
281             );
282            
283             has '+http_status' => ( default => 404, );
284              
285             If we now call the endpoint with an invalid id, we get:
286              
287             ~$ curl -i http://localhost/thing/42/update
288             HTTP/1.1 404 Not Found
289             Content-Type: text/html;charset=utf-8
290            
291             <html>
292             <head><title>Error 404</title></head>
293             <body>
294             <h1>Error 404</h1>
295             <p>Cannot load Foo from id 42</p>
296             </body>
297             </html>
298              
299             If we want JSON, we just need to tell the server:
300              
301             ~$ curl -i -H 'Accept: application/json' http://localhost/thing/42/update
302              
303             HTTP/1.1 404 Not Found
304             Content-Type: application/json
305              
306             {"status":"error","message":"Cannot load Foo from id 42"}
307              
308             Smooth!
309              
310             =head2 Content Negotiation / Force JSON
311              
312             As of now there is no real content-negotiation, because all I need is
313             HTML and JSON. There is some semi-dumb checking of the
314             C<Accept>-Header, but I only check for literal C<application/json>
315             (while I should do the whole q-factor weighting dance).
316              
317             If you want to force all your errors to JSON, pass C<force_json =E<gt> 1>
318             when loading the middleware:
319              
320             builder {
321             enable "Plack::Middleware::PrettyException" => ( force_json => 1 );
322             $app
323             };
324              
325             This will be replace in the near future by some proper content
326             negitiation and a new C<default_response_encoding> field.
327              
328             =head2 Finetune HTML output via subclassing
329              
330             The default HTML is rather basic^wugly. To finetune this, just
331             subclass C<Plack::Middleware::PrettyException> and implement a method
332             called C<render_html_error>. This method will be called with the HTTP
333             status code, the stringified error message, the original exception
334             object (if there was one!) and the original request C<$env>. You can
335             then render it as fancy as you (or your graphic designer) wants.
336              
337             Here's an example:
338              
339             package Oel::Middleware::Error;
340            
341             use 5.020;
342             use strict;
343             use warnings;
344             use parent qw(Plack::Middleware::PrettyException);
345             use Plack::Util::Accessor qw(html_page_model renderer);
346            
347             sub render_html_error {
348             my ( $self, $status, $message, $exception, $env ) = @_;
349            
350             my %data = (base=>'/',title=>'Error '.$status, error => $message, code => $status );
351             eval {
352             if (my $page = $self->html_page_model->load('/_error/'.$status)) {
353             $data{title} = $page->title;
354             $data{description} = $page->teaser;
355             }
356             };
357            
358             my $rendered='';
359             $self->renderer->tt->process('error.tt',\%data,\$rendered);
360             return $rendered if $rendered;
361            
362             return "Error while rendering error: ".$self->renderer->tt->error;
363             }
364            
365             1;
366              
367             This middleware uses a C<html_page_model> to retrieve the title and
368             description of the error page from a database (where admins can edit
369             those fields via a CMS). It uses
370             L<Template::Toolkit|https://metacpan.org/pod/Template> to render the
371             page.
372              
373             C<html_page_model> and C<renderer> are two attributes needed by this
374             middleware, implemented via C<Plack::Util::Accessor>. You have to
375             provide some meaningful objects when loading the middleware, maybe
376             like this:
377              
378             use Plack::Builder;
379              
380             builder {
381             enable "Plack::Middleware::PrettyException",
382             renderer => Oel::Renderer->new,
383             html_page_model => Oel::Model::HtmlPage->new;
384              
385             $app;
386             };
387              
388             Of course you'll need to init C<Oel::Renderer> and
389             C<Oel::Model::HtmlPage>, so you'll probably want to use
390             L<Bread::Board|https://metacpan.org/pod/Bread::Board> or
391             L<OX|https://metacpan.org/pod/OX>.
392              
393             =head1 SEE ALSO
394              
395             =over
396              
397             =item * L<Plack::Middleware::ErrorDocument|https://metacpan.org/pod/Plack::Middleware::ErrorDocument>
398              
399             Set Error Document based on HTTP status code. Does not capture errors.
400              
401             =item * L<Plack::Middleware::CustomErrorDocument|https://metacpan.org/pod/Plack::Middleware::CustomErrorDocument>
402              
403             Dynamically select error documents based on HTTP status code. Improved version of Plack::Middleware::ErrorDocument, also does not capture errors.
404              
405             =item * L<Plack::Middleware::DiePretty|https://metacpan.org/pod/Plack::Middleware::DiePretty>
406              
407             Show a 500 error page if you die. Converts B<all> exceptions to 500 Server Error.
408              
409             =back
410              
411             =head1 THANKS
412              
413             Thanks to
414              
415             =over
416              
417             =item *
418              
419             L<validad.com|https://www.validad.com/> for supporting Open Source.
420              
421             =item *
422              
423             L<oe1.orf.at|http://oe1.orf.at> for the motivation to extract the code from the Validad stack.
424              
425             =item *
426              
427             L<sixtease|https://metacpan.org/author/SIXTEASE> for coming up with C<Oel> as the name for my example app (after miss-reading C<oe1>).
428              
429             =back
430              
431             =head1 AUTHOR
432              
433             Thomas Klausner <domm@plix.at>
434              
435             =head1 COPYRIGHT AND LICENSE
436              
437             This software is copyright (c) 2016 - 2021 by Thomas Klausner.
438              
439             This is free software; you can redistribute it and/or modify it under
440             the same terms as the Perl 5 programming language system itself.
441              
442             =cut