Skip to content

Commit

Permalink
feat: support problem+json for error messages (#583)
Browse files Browse the repository at this point in the history
  • Loading branch information
bethesque authored Dec 5, 2022
1 parent 1b9ebdf commit 92957eb
Show file tree
Hide file tree
Showing 17 changed files with 514 additions and 77 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Formats a message string into application/problem+json format.

module PactBroker
module Api
module Decorators
class CustomErrorProblemJSONDecorator

# @option title [String]
# @option type [String]
# @option detail [String]
# @option status [Integer] HTTP status code
def initialize(title:, type:, detail:, status: )
@title = title
@type = type
@detail = detail
@status = status
end

# @return [Hash]
def to_hash(decorator_options = {})
{
"title" => @title,
"type" => "#{decorator_options.dig(:user_options, :base_url)}/problem/#{@type}",
"detail" => @detail,
"status" => @status
}
end

# @return [String] JSON
def to_json(decorator_options = {})
to_hash(decorator_options).to_json
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Formats a message string into application/problem+json format.

module PactBroker
module Api
module Decorators
class RuntimeErrorProblemJSONDecorator

# @param message [String]
def initialize(message)
@message = message
end

# @return [Hash]
def to_hash(decorator_options = {})
{
"title" => "Server error",
"type" => "#{decorator_options.dig(:user_options, :base_url)}/problems/server_error",
"detail" => message,
"status" => 500
}
end

# @return [String] JSON
def to_json(decorator_options = {})
to_hash(decorator_options).to_json
end

private

attr_reader :message
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Formats a nested Hash of errors as it comes out of the Dry Validation library
# into application/problem+json format.

module PactBroker
module Api
module Decorators
class ValidationErrorsProblemJSONDecorator

# @param errors [Hash]
def initialize(errors)
@errors = errors
end

# @return [Hash]
def to_hash(decorator_options = {})
error_list = []
walk_errors(errors, error_list, "", decorator_options.dig(:user_options, :base_url))
{
"title" => "Validation errors",
"type" => "#{decorator_options.dig(:user_options, :base_url)}/problems/validation-error",
"status" => 400,
"errors" => error_list
}
end

# @return [String] JSON
def to_json(decorator_options = {})
to_hash(decorator_options).to_json
end

private

attr_reader :errors

def walk_errors(object, list, path, base_url)
if object.is_a?(Hash)
object.each { | key, value | walk_errors(value, list, "#{path}/#{key}", base_url) }
elsif object.is_a?(Array)
object.each { | value | walk_errors(value, list, path, base_url) }
elsif object.is_a?(String)
append_error(list, object, path, base_url)
end
end

def append_error(list, message, path, base_url)
list << {
"type" => "#{base_url}/problems/invalid-body-property-value",
"title" => "Validation error",
"detail" => message,
"instance" => path,
"status" => 400
}
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/pact_broker/api/resources/badge_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def moved_temporarily?
badge_url
rescue StandardError => e
# Want to render a badge, even if there's an error
badge_service.error_badge_url("error", ErrorResponseBodyGenerator.display_message(e, "reference: #{PactBroker::Errors.generate_error_reference}"))
badge_service.error_badge_url("error", ErrorResponseGenerator.display_message(e, "reference: #{PactBroker::Errors.generate_error_reference}"))
end
end

Expand Down
29 changes: 7 additions & 22 deletions lib/pact_broker/api/resources/base_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
require "pact_broker/api/resources/authentication"
require "pact_broker/api/resources/authorization"
require "pact_broker/errors"
require "pact_broker/api/resources/error_handling_methods"

module PactBroker
module Api
Expand All @@ -22,6 +23,7 @@ class BaseResource < Webmachine::Resource
include PactBroker::Api::PactBrokerUrls
include PactBroker::Api::Resources::Authentication
include PactBroker::Api::Resources::Authorization
include PactBroker::Api::Resources::ErrorHandlingMethods

include PactBroker::Logging

Expand Down Expand Up @@ -111,15 +113,6 @@ def decorator_options options = {}
{ user_options: decorator_context(options) }
end

def handle_exception(error)
error_reference = PactBroker::Errors.generate_error_reference
application_context.error_logger.call(error, error_reference, request.env)
if PactBroker::Errors.reportable_error?(error)
PactBroker::Errors.report(error, error_reference, request.env)
end
response.body = application_context.error_response_body_generator.call(error, error_reference, request.env)
end

# rubocop: disable Metrics/CyclomaticComplexity
def params(options = {})
return options[:default] if options.key?(:default) && request_body.empty?
Expand Down Expand Up @@ -158,16 +151,6 @@ def pact_params
@pact_params ||= PactBroker::Pacts::PactParams.from_request(request, identifier_from_path)
end

def set_json_error_message message
response.headers["Content-Type"] = "application/hal+json;charset=utf-8"
response.body = { error: message }.to_json
end

def set_json_validation_error_messages errors
response.headers["Content-Type"] = "application/hal+json;charset=utf-8"
response.body = { errors: errors }.to_json
end

def request_body
@request_body ||= request.body.to_s
end
Expand Down Expand Up @@ -215,13 +198,13 @@ def invalid_json?
rescue NonUTF8CharacterFound => e
logger.info(e.message) # Don't use the default SemanticLogger error logging method because it will try and print out the cause which will contain non UTF-8 chars in the message
set_json_error_message(e.message)
response.headers["Content-Type"] = "application/hal+json;charset=utf-8"
response.headers["Content-Type"] = error_response_content_type
true
rescue StandardError => e
message = "#{e.cause ? e.cause.class.name : e.class.name} - #{e.message}"
logger.info(message)
set_json_error_message(message)
response.headers["Content-Type"] = "application/hal+json;charset=utf-8"
response.headers["Content-Type"] = error_response_content_type
true
end
end
Expand All @@ -244,7 +227,9 @@ def contract_validation_errors? contract, params

def find_pacticipant name, role
pacticipant_service.find_pacticipant_by_name(name).tap do | pacticipant |
set_json_error_message("No #{role} with name '#{name}' found") if pacticipant.nil?
if pacticipant.nil?
set_json_error_message("No #{role} with name '#{name}' found", title: "Not found", type: "not_found", status: 404)
end
end
end

Expand Down
57 changes: 57 additions & 0 deletions lib/pact_broker/api/resources/error_handling_methods.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
require "pact_broker/api/decorators/validation_errors_problem_json_decorator"
require "pact_broker/api/decorators/custom_error_problem_json_decorator"

module PactBroker
module Api
module Resources
module ErrorHandlingMethods

# @override
def handle_exception(error)
error_reference = PactBroker::Errors.generate_error_reference
application_context.error_logger.call(error, error_reference, request.env)
if PactBroker::Errors.reportable_error?(error)
PactBroker::Errors.report(error, error_reference, request.env)
end
headers, body = application_context.error_response_generator.call(error, error_reference, request.env)
headers.each { | key, value | response.headers[key] = value }
response.body = body
end

def set_json_error_message detail, title: "Server error", type: "server_error", status: 500
response.headers["Content-Type"] = error_response_content_type
response.body = error_response_body(detail, title, type, status)
end

def set_json_validation_error_messages errors
response.headers["Content-Type"] = error_response_content_type
if problem_json_error_content_type?
response.body = PactBroker::Api::Decorators::ValidationErrorsProblemJSONDecorator.new(errors).to_json(decorator_options)
else
response.body = { errors: errors }.to_json
end
end

def error_response_content_type
if problem_json_error_content_type?
"application/problem+json;charset=utf-8"
else
"application/hal+json;charset=utf-8"
end
end

def error_response_body(detail, title, type, status)
if problem_json_error_content_type?
PactBroker::Api::Decorators::CustomErrorProblemJSONDecorator.new(detail: detail, title: title, type: type, status: status).to_json(decorator_options)
else
{ error: detail }.to_json
end
end

def problem_json_error_content_type?
request.headers["Accept"]&.include?("application/problem+json")
end
end
end
end
end
41 changes: 0 additions & 41 deletions lib/pact_broker/api/resources/error_response_body_generator.rb

This file was deleted.

70 changes: 70 additions & 0 deletions lib/pact_broker/api/resources/error_response_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
require "pact_broker/configuration"
require "pact_broker/api/decorators/runtime_error_problem_json_decorator"

module PactBroker
module Api
module Resources
class ErrorResponseGenerator
include PactBroker::Logging

# @param error [StandardError]
# @param error_reference [String] an error reference to display to the user
# @param env [Hash] the rack env
# @return [Hash, String] the response headers to set, the response body to set
def self.call error, error_reference, env = {}
body = response_body_hash(error, error_reference, env, display_message(error, obfuscated_error_message(error_reference)))
return headers(env), body.to_json
end

def self.display_message(error, obfuscated_message)
if PactBroker.configuration.show_backtrace_in_error_response?
error.message || obfuscated_message
else
PactBroker::Errors.reportable_error?(error) ? obfuscated_message : error.message
end
end

private_class_method def self.response_body_hash(error, error_reference, env, message)
if problem_json?(env)
problem_json_response_body(message, env)
else
hal_json_response_body(error, error_reference, message)
end
end

private_class_method def self.hal_json_response_body(error, error_reference, message)
response_body = {
error: {
message: message,
reference: error_reference
}
}
if PactBroker.configuration.show_backtrace_in_error_response?
response_body[:error][:backtrace] = error.backtrace
end
response_body
end

private_class_method def self.problem_json_response_body(message, env)
PactBroker::Api::Decorators::RuntimeErrorProblemJSONDecorator.new(message).to_hash(user_options: { base_url: env["pactbroker.base_url" ] })
end

private_class_method def self.obfuscated_error_message(error_reference)
"An error has occurred. The details have been logged with the reference #{error_reference}"
end

private_class_method def self.headers(env)
if problem_json?(env)
{ "Content-Type" => "application/problem+json;charset=utf-8" }
else
{ "Content-Type" => "application/hal+json;charset=utf-8" }
end
end

private_class_method def self.problem_json?(env)
env["HTTP_ACCEPT"]&.include?("application/problem+json")
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/pact_broker/api/resources/pact_version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def allowed_methods
["GET", "OPTIONS"]
end

def decorator_options(options)
def decorator_options(options = {})
super(options.merge(consumer_versions: consumer_versions_from_metadata&.reverse))
end
end
Expand Down
Loading

0 comments on commit 92957eb

Please sign in to comment.