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

Add ability to add handlers for raised exceptions #688

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
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
93 changes: 93 additions & 0 deletions spec/exception_handler_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,99 @@ describe "Kemal::ExceptionHandler" do
response.body.should eq "Something happened"
end

it "renders custom error for a crystal exception" do
error RuntimeError do
"A RuntimeError has occured"
end

get "/" do
raise RuntimeError.new
end

request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::ExceptionHandler::INSTANCE.next = Kemal::RouteHandler::INSTANCE
Kemal::ExceptionHandler::INSTANCE.call(context)
response.close
io.rewind
response = HTTP::Client::Response.from_io(io, decompress: false)
response.status_code.should eq 500
response.headers["Content-Type"].should eq "text/html"
response.body.should eq "A RuntimeError has occured"
end

it "renders custom error for a custom exception" do
error CustomExceptionType do
"A custom exception of CustomExceptionType has occurred"
end

get "/" do
raise CustomExceptionType.new
end

request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::ExceptionHandler::INSTANCE.next = Kemal::RouteHandler::INSTANCE
Kemal::ExceptionHandler::INSTANCE.call(context)
response.close
io.rewind
response = HTTP::Client::Response.from_io(io, decompress: false)
response.status_code.should eq 500
response.headers["Content-Type"].should eq "text/html"
response.body.should eq "A custom exception of CustomExceptionType has occurred"
end

it "renders custom error for a custom exception with a specific HTTP status code" do
error CustomExceptionType do |env|
env.response.status_code = 503
"A custom exception of CustomExceptionType has occurred"
end

get "/" do
raise CustomExceptionType.new
end

request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::ExceptionHandler::INSTANCE.next = Kemal::RouteHandler::INSTANCE
Kemal::ExceptionHandler::INSTANCE.call(context)
response.close
io.rewind
response = HTTP::Client::Response.from_io(io, decompress: false)
response.status_code.should eq 503
response.headers["Content-Type"].should eq "text/html"
response.body.should eq "A custom exception of CustomExceptionType has occurred"
end

it "renders custom error for a child of a custom exception" do
error CustomExceptionType do |env, error|
"A custom exception of #{error.class} has occurred"
end

get "/" do
raise ChildCustomExceptionType.new
end

request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::ExceptionHandler::INSTANCE.next = Kemal::RouteHandler::INSTANCE
Kemal::ExceptionHandler::INSTANCE.call(context)
response.close
io.rewind
response = HTTP::Client::Response.from_io(io, decompress: false)
response.status_code.should eq 500
response.headers["Content-Type"].should eq "text/html"
response.body.should eq "A custom exception of ChildCustomExceptionType has occurred"
end

it "overrides the content type for filters" do
before_get do |env|
env.response.content_type = "application/json"
Expand Down
6 changes: 6 additions & 0 deletions spec/spec_helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ class AnotherContextStorageType
@name = "kemal-context"
end

class CustomExceptionType < Exception
end

class ChildCustomExceptionType < CustomExceptionType
end

add_context_storage_type(TestContextStorageType)
add_context_storage_type(AnotherContextStorageType)

Expand Down
23 changes: 18 additions & 5 deletions src/kemal/config.cr
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ module Kemal
# Kemal.config
# ```
class Config
INSTANCE = Config.new
HANDLERS = [] of HTTP::Handler
CUSTOM_HANDLERS = [] of Tuple(Nil | Int32, HTTP::Handler)
FILTER_HANDLERS = [] of HTTP::Handler
ERROR_HANDLERS = {} of Int32 => HTTP::Server::Context, Exception -> String
INSTANCE = Config.new
HANDLERS = [] of HTTP::Handler
CUSTOM_HANDLERS = [] of Tuple(Nil | Int32, HTTP::Handler)
FILTER_HANDLERS = [] of HTTP::Handler
ERROR_HANDLERS = {} of Int32 => HTTP::Server::Context, Exception -> String
EXCEPTION_HANDLERS = {} of Exception.class => HTTP::Server::Context, Exception -> String

{% if flag?(:without_openssl) %}
@ssl : Bool?
Expand Down Expand Up @@ -88,14 +89,26 @@ module Kemal
FILTER_HANDLERS << handler
end

# Returns the defined error handlers for HTTP status codes
def error_handlers
ERROR_HANDLERS
end

# Adds an error handler for the given HTTP status code
def add_error_handler(status_code : Int32, &handler : HTTP::Server::Context, Exception -> _)
ERROR_HANDLERS[status_code] = ->(context : HTTP::Server::Context, error : Exception) { handler.call(context, error).to_s }
end

# Returns tje defined error handlers for exceptions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo:

Suggested change
# Returns tje defined error handlers for exceptions
# Returns the defined error handlers for exceptions

def exception_handlers
EXCEPTION_HANDLERS
end

# Adds an error handler for the given exception
def add_exception_handler(exception : Exception.class, &handler : HTTP::Server::Context, Exception -> _)
EXCEPTION_HANDLERS[exception] = ->(context : HTTP::Server::Context, error : Exception) { handler.call(context, error).to_s }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this can be added as given, avoiding an additional closure.

Suggested change
EXCEPTION_HANDLERS[exception] = ->(context : HTTP::Server::Context, error : Exception) { handler.call(context, error).to_s }
EXCEPTION_HANDLERS[exception] = handler

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I agree requiring String output type should be preferred in general. This might have some UX consequences though...
@syeopite What's the motivation for an unspecified block output type?

end

def extra_options(&@extra_options : OptionParser ->)
end

Expand Down
6 changes: 6 additions & 0 deletions src/kemal/dsl.cr
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,16 @@ def ws(path : String, &block : HTTP::WebSocket, HTTP::Server::Context -> Void)
Kemal::WebSocketHandler::INSTANCE.add_route path, &block
end

# Defines an error handler to be called when route returns the given HTTP status code
def error(status_code : Int32, &block : HTTP::Server::Context, Exception -> _)
Kemal.config.add_error_handler status_code, &block
end

# Defines an error handler to be called when the given exception is raised
def error(exception : Exception.class, &block : HTTP::Server::Context, Exception -> _)
Kemal.config.add_exception_handler exception, &block
end

# All the helper methods available are:
# - before_all, before_get, before_post, before_put, before_patch, before_delete, before_options
# - after_all, after_get, after_post, after_put, after_patch, after_delete, after_options
Expand Down
29 changes: 29 additions & 0 deletions src/kemal/exception_handler.cr
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,41 @@ module Kemal
rescue ex : Kemal::Exceptions::CustomException
call_exception_with_status_code(context, ex, context.response.status_code)
rescue ex : Exception
# Matches an error handler for the given exception
#
# Matches based on order of declaration rather than inheritance relationship
# for child exceptions
Kemal.config.exception_handlers.each do |expected_exception, handler|
if ex.class <= expected_exception
return call_exception_with_exception(context, ex, handler, 500)
end
end

log("Exception: #{ex.inspect_with_backtrace}")
# Else use generic 500 handler if defined
return call_exception_with_status_code(context, ex, 500) if Kemal.config.error_handlers.has_key?(500)
verbosity = Kemal.config.env == "production" ? false : true
render_500(context, ex, verbosity)
end

# Calls the given error handler with the current exception
#
# The logic for validating that the current exception should be handled
# by the given error handler should be done by the caller of this method.
private def call_exception_with_exception(
context : HTTP::Server::Context,
exception : Exception,
handler : Proc(HTTP::Server::Context, Exception, String),
status_code : Int32 = 500,
)
return if context.response.closed?

context.response.content_type = "text/html" unless context.response.headers.has_key?("Content-Type")
context.response.status_code = status_code
context.response.print handler.call(context, exception)
context
end

private def call_exception_with_status_code(context : HTTP::Server::Context, exception : Exception, status_code : Int32)
return if context.response.closed?
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(status_code)
Expand Down