lib/view_model/error.rb
# frozen_string_literal: true # Abstract base for renderable errors in ViewModel-based APIs. Errors of this # type will be caught by ViewModel controllers and rendered in a standard format # by ViewModel::ErrorView, which loosely follows errors in JSON-API. class ViewModel::AbstractError < StandardError class << self # Brief DSL for quickly defining constant attribute values in subclasses [:detail, :status, :title, :code].each do |attribute| define_method(attribute) do |x| define_method(attribute) { x } end end end def initialize # `detail` is used to provide the exception message. However, it's not safe # to just override StandardError's `message` or `to_s` to call `detail`, # since some of Ruby's C implementation of Exceptions internally ignores # these methods and fetches the invisible internal `idMesg` attribute # instead. (!) # # This means that all fields necessary to derive the detail message must be # initialized before calling super(). super(detail) end # Human-readable reason for use displaying this error. def detail 'ViewModel::AbstractError' end # HTTP status code most appropriate for this error def status 500 end # Human-readable title for displaying this error def title nil end # Unique symbol identifying this error type def code 'ViewModel.AbstractError' end # Additional information specific to this error type. def meta {} end # Some types of error may be aggregations over multiple causes def aggregation? false end # If so, the causes of this error (as AbstractErrors) def causes nil end # The exception responsible for this error. In most cases, that should be this # object, but sometimes an Error may be used to wrap an external exception. def exception self end def view ViewModel::ErrorView.new(self) end def to_s detail end protected def format_references(viewmodel_refs) viewmodel_refs.map do |viewmodel_ref| format_reference(viewmodel_ref) end end def format_reference(viewmodel_ref) { ViewModel::TYPE_ATTRIBUTE => viewmodel_ref.viewmodel_class.view_name, ViewModel::ID_ATTRIBUTE => viewmodel_ref.model_id, } end end # For errors associated with specific viewmodel nodes, include metadata # describing the node to blame. class ViewModel::AbstractErrorWithBlame < ViewModel::AbstractError attr_reader :nodes def initialize(blame_nodes) @nodes = Array.wrap(blame_nodes) unless @nodes.all? { |n| n.is_a?(ViewModel::Reference) } raise ArgumentError.new("#{self.class.name}: 'blame_nodes' must all be of type ViewModel::Reference") end super() end def meta { nodes: format_references(nodes), } end end # Abstract collection of errors class ViewModel::AbstractErrorCollection < ViewModel::AbstractError attr_reader :causes def initialize(causes) @causes = Array.wrap(causes) unless @causes.present? raise ArgumentError.new('A collection must have at least one cause') end super() end def status causes.inject(causes.first.status) do |status, cause| if status == cause.status status else 400 end end end def detail "ViewModel::AbstractErrors: #{cause_details}" end def aggregation? true end def self.for_errors(errors) if errors.size == 1 errors.first else self.new(errors) end end protected def cause_details causes.map(&:detail).join('; ') end end # Error type to wrap an arbitrary exception as a renderable ViewModel::AbstractError class ViewModel::WrappedExceptionError < ViewModel::AbstractError attr_reader :exception, :status def initialize(exception, status, code) @exception = exception @status = status @code = code super() end def detail exception.message end def code @code || "Exception.#{exception.class.name}" end end # Implementation of ViewModel::AbstractError with constructor parameters for # each error data field. class ViewModel::Error < ViewModel::AbstractError attr_reader :detail, :status, :title, :code, :meta def initialize(status: 400, detail: 'ViewModel Error', title: nil, code: nil, meta: {}) @detail = detail @status = status @title = title @code = code @meta = meta super() end end