├── spec ├── unit │ └── hati_jsonapi_error │ │ ├── errors_spec.rb │ │ ├── errors │ │ ├── helpers_render_error_spec.rb │ │ ├── helpers_handle_error_spec.rb │ │ ├── not_defined_error_class_error_spec.rb │ │ └── not_loaded_error_spec.rb │ │ ├── api_error │ │ ├── links_spec.rb │ │ ├── source_spec.rb │ │ ├── base_error_spec.rb │ │ └── error_const_spec.rb │ │ ├── kigen_spec.rb │ │ ├── registry_spec.rb │ │ ├── config_spec.rb │ │ ├── resolver_spec.rb │ │ └── poro_serializer_spec.rb ├── .DS_Store ├── support │ └── dummy.rb ├── spec_helper.rb └── integration │ └── hati_jsonapi_error │ └── helpers_spec.rb ├── lib ├── hati_jsonapi_error │ ├── version.rb │ ├── errors │ │ ├── not_loaded_error.rb │ │ ├── helpers_handle_error.rb │ │ ├── helpers_render_error.rb │ │ └── not_defined_error_class_error.rb │ ├── api_error │ │ ├── links.rb │ │ ├── source.rb │ │ ├── base_error.rb │ │ └── error_const.rb │ ├── resolver.rb │ ├── poro_serializer.rb │ ├── config.rb │ ├── registry.rb │ ├── kigen.rb │ └── helpers.rb └── hati_jsonapi_error.rb ├── .rubocop.yml ├── Gemfile ├── LICENSE ├── hati-jsonapi-error.gemspec ├── .gitignore ├── CODE_OF_CONDUCT.md ├── HTTP_STATUS_CODES.md └── README.md /spec/unit/hati_jsonapi_error/errors_spec.rb: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /spec/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackico-ai/ruby-hati-jsonapi-error/HEAD/spec/.DS_Store -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | VERSION = '0.1.0' 5 | end 6 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rspec 3 | - rubocop-rake 4 | 5 | AllCops: 6 | NewCops: enable 7 | TargetRubyVersion: 3.0 8 | 9 | Metrics/MethodLength: 10 | Max: 15 11 | Enabled: false 12 | 13 | Metrics/ClassLength: 14 | Max: 100 15 | Enabled: false -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/errors/not_loaded_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | # WIP: draft 5 | module Errors 6 | class NotLoadedError < StandardError 7 | def initialize(message = 'HatiJsonapiError::Kigen not loaded') 8 | super 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/errors/helpers_handle_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | # WIP: draft 5 | module Errors 6 | class HelpersHandleError < StandardError 7 | def initialize(message = 'Invalid Helpers:handle_error') 8 | super 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/errors/helpers_render_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | # WIP: draft 5 | module Errors 6 | class HelpersRenderError < StandardError 7 | def initialize(message = 'Invalid Helpers:render_error') 8 | super 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/errors/not_defined_error_class_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | # WIP: draft 5 | module Errors 6 | class NotDefinedErrorClassError < StandardError 7 | def initialize(message = 'Error class not defined') 8 | super 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'rake' 8 | 9 | # Spec 10 | gem 'json' 11 | gem 'rspec', '~> 3.0' 12 | gem 'rspec-collection_matchers' 13 | 14 | # Linter & Static 15 | gem 'fasterer', '~> 0.11.0' 16 | gem 'rubocop', '~> 1.21' 17 | gem 'rubocop-rake' 18 | gem 'rubocop-rspec', require: false 19 | -------------------------------------------------------------------------------- /spec/support/dummy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # NOTE: helper names follow convention 'support__' 4 | 5 | module Dummy 6 | def self.dummy_class 7 | @dummy_class ||= Class.new do 8 | include HatiJsonapiError::Helpers 9 | 10 | attr_reader :rendered_json, :rendered_status 11 | 12 | def render(json:, status:) 13 | @rendered_json = json 14 | @rendered_status = status 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/api_error/links.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | # This class is used to build the links object for the error response. 5 | class Links 6 | STR = '' 7 | 8 | attr_accessor :about, :type 9 | 10 | def initialize(about: STR, type: STR) 11 | @about = about 12 | @type = type 13 | end 14 | 15 | def to_h 16 | { 17 | about: about, 18 | type: type 19 | } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'hati_jsonapi_error' 5 | require 'json' 6 | require 'rspec/collection_matchers' 7 | 8 | RSpec.configure do |config| 9 | config.example_status_persistence_file_path = '.rspec_status' 10 | config.disable_monkey_patching! 11 | config.expect_with :rspec do |c| 12 | c.syntax = :expect 13 | end 14 | 15 | Dir[File.join('./spec/support/**/*.rb')].each { |f| require f } 16 | config.include Dummy 17 | end 18 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/api_error/source.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | # This class is used to build the source object for the error response. 5 | class Source 6 | STR = '' 7 | 8 | attr_accessor :pointer, :parameter, :header 9 | 10 | def initialize(pointer: STR, parameter: STR, header: STR) 11 | @pointer = pointer 12 | @parameter = parameter 13 | @header = header 14 | end 15 | 16 | def to_h 17 | { 18 | pointer: pointer, 19 | parameter: parameter, 20 | header: header 21 | } 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/resolver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | # This class is used to resolve errors and serialize them to a JSON API format. 5 | class Resolver 6 | attr_reader :errors, :serializer 7 | 8 | def initialize(api_error, serializer: PoroSerializer) 9 | @errors = error_arr(api_error) 10 | @serializer = serializer.new(errors) 11 | end 12 | 13 | def status 14 | errors.first.status 15 | end 16 | 17 | def to_json(*_args) 18 | serializer.serialize_to_json 19 | end 20 | 21 | private 22 | 23 | def error_arr(api_error) 24 | api_error.is_a?(Array) ? api_error : [api_error] 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'hati_jsonapi_error/version' 4 | 5 | # errors 6 | require 'hati_jsonapi_error/errors/helpers_handle_error' 7 | require 'hati_jsonapi_error/errors/helpers_render_error' 8 | require 'hati_jsonapi_error/errors/not_defined_error_class_error' 9 | require 'hati_jsonapi_error/errors/not_loaded_error' 10 | 11 | # api_error/* 12 | require 'hati_jsonapi_error/api_error/base_error' 13 | require 'hati_jsonapi_error/api_error/error_const' 14 | require 'hati_jsonapi_error/api_error/links' 15 | require 'hati_jsonapi_error/api_error/source' 16 | 17 | # logic 18 | require 'hati_jsonapi_error/config' 19 | require 'hati_jsonapi_error/kigen' 20 | require 'hati_jsonapi_error/helpers' 21 | require 'hati_jsonapi_error/poro_serializer' 22 | require 'hati_jsonapi_error/registry' 23 | require 'hati_jsonapi_error/resolver' 24 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/poro_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | # This class is used to serialize errors to a JSON API format. 5 | class PoroSerializer 6 | SHORT_KEYS = %i[status title detail source].freeze 7 | 8 | def initialize(error) 9 | @errors = normalized_errors(error) 10 | end 11 | 12 | def serialize_to_json(short: false) 13 | serializable_hash(short: short).to_json 14 | end 15 | 16 | def serializable_hash(short: false) 17 | if short 18 | { errors: errors.map { |error| error.to_h.slice(*SHORT_KEYS) } } 19 | else 20 | { errors: errors.map(&:to_h) } 21 | end 22 | end 23 | 24 | private 25 | 26 | attr_reader :errors 27 | 28 | def normalized_errors(error) 29 | error.is_a?(Array) ? error : [error] 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 hackico.ai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/config.rb: -------------------------------------------------------------------------------- 1 | module HatiJsonapiError 2 | class Config 3 | # HatiJsonapiError::Config.configure do |config| 4 | # config.load_error! 5 | # config.map_errors = { 6 | # ActiveRecord::RecordNotFound => ApiError::NotFound, 7 | # ActiveRecord::RecordInvalid => ApiError::UnprocessableEntity 8 | # ActiveRecord::RecordNotUnique => :conflict 9 | # ActiveRecord::Unauthorized => 401 10 | # } 11 | # config.use_unexpected = InternalServerError 12 | # end 13 | 14 | # TODO: preload rails rescue responses 15 | # - what to do about order ???order of loading is important 16 | # - what to do about rails app? 17 | # - what to do about rails app not loaded? 18 | # - what to do about rails app not loaded? 19 | class << self 20 | def configure 21 | yield self if block_given? 22 | end 23 | 24 | def use_unexpected=(fallback_error) 25 | HatiJsonapiError::Registry.fallback = fallback_error 26 | end 27 | 28 | def map_errors=(error_map) 29 | HatiJsonapiError::Registry.error_map = error_map 30 | end 31 | 32 | def error_map 33 | HatiJsonapiError::Registry.error_map 34 | end 35 | 36 | # TODO: check if double defintion of errors 37 | def load_errors! 38 | HatiJsonapiError::Kigen.load_errors! 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /hati-jsonapi-error.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | 6 | require 'hati_jsonapi_error/version' 7 | 8 | Gem::Specification.new do |spec| 9 | spec.name = 'hati-jsonapi-error' 10 | spec.version = HatiJsonapiError::VERSION 11 | spec.authors = ['Marie Giy'] 12 | spec.email = %w[giy.mariya@gmail.com] 13 | spec.license = 'MIT' 14 | 15 | spec.summary = 'Standardized JSON: API-compliant error responses made easy for your Web API.' 16 | spec.description = 'hati-jsonapi-error is a Ruby gem for Standardized JSON Error.' 17 | spec.homepage = "https://github.com/hackico-ai/#{spec.name}" 18 | 19 | spec.required_ruby_version = '>= 3.0.0' 20 | 21 | spec.files = Dir['CHANGELOG.md', 'LICENSE', 'README.md', 'hati-jsonapi-error.gemspec', 'lib/**/*'] 22 | spec.bindir = 'bin' 23 | spec.executables = [] 24 | spec.require_paths = ['lib'] 25 | 26 | spec.metadata['repo_homepage'] = spec.homepage 27 | spec.metadata['allowed_push_host'] = 'https://rubygems.org' 28 | 29 | spec.metadata['homepage_uri'] = spec.homepage 30 | spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md" 31 | spec.metadata['source_code_uri'] = spec.homepage 32 | spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues" 33 | 34 | spec.metadata['rubygems_mfa_required'] = 'true' 35 | end 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | # Ignore Byebug command history file. 17 | .byebug_history 18 | 19 | ## Specific to RubyMotion: 20 | .dat* 21 | .repl_history 22 | build/ 23 | *.bridgesupport 24 | build-iPhoneOS/ 25 | build-iPhoneSimulator/ 26 | 27 | ## Specific to RubyMotion (use of CocoaPods): 28 | # 29 | # We recommend against adding the Pods directory to your .gitignore. However 30 | # you should judge for yourself, the pros and cons are mentioned at: 31 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 32 | # 33 | # vendor/Pods/ 34 | 35 | ## Documentation cache and generated files: 36 | /.yardoc/ 37 | /_yardoc/ 38 | /doc/ 39 | /rdoc/ 40 | 41 | ## Environment normalization: 42 | /.bundle/ 43 | /vendor/bundle 44 | /lib/bundler/man/ 45 | 46 | # for a library or gem, you might want to ignore these files since the code is 47 | # intended to run in multiple environments; otherwise, check them in: 48 | # Gemfile.lock 49 | # .ruby-version 50 | # .ruby-gemset 51 | 52 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 53 | .rvmrc 54 | 55 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 56 | # .rubocop-https?--* 57 | 58 | # Specs 59 | .rspec_status 60 | 61 | # libs 62 | Gemfile.lock 63 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | # This class is used to register errors and provide a fallback error. 5 | class Registry 6 | class << self 7 | def fallback=(err) 8 | @fallback = loaded_error?(err) ? err : fetch_error(err) 9 | end 10 | 11 | def fallback 12 | @fallback ||= nil 13 | end 14 | 15 | # Base.loaded? # => true 16 | # Registry.error_map = { 17 | # ActiveRecord::RecordNotFound => :not_found, 18 | # ActiveRecord::RecordInvalid => 422 19 | # } 20 | def error_map=(error_map) 21 | error_map.each do |error, mapped_error| 22 | next if loaded_error?(mapped_error) 23 | 24 | error_map[error] = fetch_error(mapped_error) 25 | end 26 | 27 | @error_map = error_map 28 | end 29 | 30 | def error_map 31 | @error_map ||= {} 32 | end 33 | 34 | def lookup_error(error) 35 | error_map[error.class] || fallback 36 | end 37 | 38 | private 39 | 40 | def loaded_error?(error) 41 | error.is_a?(Class) && error <= HatiJsonapiError::BaseError 42 | end 43 | 44 | def fetch_error(error) 45 | err = HatiJsonapiError::Kigen.fetch_err(error) 46 | unless err 47 | msg = "Error #{error} definition not found in lib/hati_jsonapi_error/api_error/error_const.rb" 48 | raise HatiJsonapiError::Errors::NotDefinedErrorClassError, msg 49 | end 50 | 51 | err 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/api_error/base_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | # This is the base error class for all errors in the HatiJsonapiError gem. 5 | class BaseError < ::StandardError 6 | STR = '' 7 | OBJ = {}.freeze 8 | 9 | attr_accessor :id, :code, :title, :detail, :status, :meta, :links, :source 10 | 11 | def initialize(**attrs) 12 | @id = attrs[:id] || STR 13 | @code = attrs[:code] || STR 14 | @title = attrs[:title] || STR 15 | @detail = attrs[:detail] || STR 16 | @status = attrs[:status] || STR 17 | 18 | @links = build_links(attrs[:links]) 19 | @source = build_source(attrs[:source]) 20 | @meta = attrs[:meta] || OBJ 21 | 22 | super(error_message) 23 | end 24 | 25 | # NOTE: used in lib/hati_jsonapi_error/payload_adapter.rb 26 | def to_h 27 | { 28 | id: id, 29 | links: links.to_h, 30 | status: status, 31 | code: code, 32 | title: title, 33 | detail: detail, 34 | source: source.to_h, 35 | meta: meta 36 | } 37 | end 38 | 39 | def to_s 40 | to_h.to_s 41 | end 42 | 43 | def serializable_hash 44 | to_h 45 | end 46 | 47 | def to_json(*_args) 48 | serializable_hash.to_json 49 | end 50 | 51 | private 52 | 53 | def build_links(links_attrs) 54 | links_attrs ? Links.new(**links_attrs) : OBJ 55 | end 56 | 57 | def build_source(source_attrs) 58 | source_attrs ? Source.new(**source_attrs) : OBJ 59 | end 60 | 61 | def error_message 62 | @detail.empty? ? @title : @detail 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/kigen.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | # This class is used to load all errors from STATUS_MAP in api_error/error_const.rb 5 | class Kigen 6 | class << self 7 | # loads all errors from STATUS_MAP in api_error/error_const.rb 8 | # HatiJsonapiError::NotFound 9 | # HatiJsonapiError::BadRequest 10 | # HatiJsonapiError::Unauthorized 11 | # HatiJsonapiError::Forbidden 12 | # etc. 13 | def load_errors! 14 | return if loaded? 15 | 16 | HatiJsonapiError::STATUS_MAP.each do |status, value| 17 | next if HatiJsonapiError.const_defined?(value[:name]) 18 | 19 | err_klass = create_error_class(status, value) 20 | 21 | status_klass_map[status] = err_klass 22 | code_klass_map[value[:code]] = err_klass 23 | end 24 | 25 | @loaded = true 26 | end 27 | 28 | # HatiJsonapiError::Kigen.fetch_err(400) # => HatiJsonapiError::BadRequest 29 | # HatiJsonapiError::Kigen.fetch_err(:bad_request) 30 | def fetch_err(err) 31 | return unless loaded? 32 | 33 | status_klass_map[err] || code_klass_map[err] 34 | end 35 | 36 | # HatiJsonapiError::Kigen[400] # => HatiJsonapiError::BadRequest 37 | def [](err) 38 | fetch_err(err) 39 | end 40 | 41 | def loaded? 42 | @loaded 43 | end 44 | 45 | def status_klass_map 46 | @status_klass_map ||= {} 47 | end 48 | 49 | def code_klass_map 50 | @code_klass_map ||= {} 51 | end 52 | 53 | private 54 | 55 | def create_error_class(status, value) 56 | HatiJsonapiError.const_set(value[:name], Class.new(HatiJsonapiError::BaseError) do 57 | define_method :initialize do |**attrs| 58 | defaults = { 59 | code: value[:code], 60 | message: value[:message], 61 | title: value[:message], 62 | status: status 63 | } 64 | super(**defaults.merge(attrs)) 65 | end 66 | end) 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/unit/hati_jsonapi_error/errors/helpers_render_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError::Errors::HelpersRenderError do 6 | describe '.new' do 7 | context 'with default message' do 8 | subject(:error) { described_class.new } 9 | 10 | it 'creates an error with default message' do 11 | expect(error.message).to eq('Invalid Helpers:render_error') 12 | end 13 | 14 | it 'inherits from StandardError' do 15 | expect(error).to be_a(StandardError) 16 | end 17 | 18 | it 'is a HelpersRenderError' do 19 | expect(error).to be_a(described_class) 20 | end 21 | end 22 | 23 | context 'with custom message' do 24 | subject(:error) { described_class.new(custom_message) } 25 | 26 | let(:custom_message) { 'Custom error message' } 27 | 28 | it 'creates an error with custom message' do 29 | expect(error.message).to eq(custom_message) 30 | end 31 | 32 | it 'inherits from StandardError' do 33 | expect(error).to be_a(StandardError) 34 | end 35 | end 36 | end 37 | 38 | describe 'error handling' do 39 | it 'can be raised and caught' do 40 | expect { raise described_class.new }.to raise_error(described_class) 41 | end 42 | 43 | it 'can be raised with custom message' do 44 | custom_message = 'Test error message' 45 | 46 | expect { raise described_class.new(custom_message) }.to raise_error(described_class, custom_message) 47 | end 48 | 49 | it 'can be caught as StandardError' do 50 | expect { raise described_class.new }.to raise_error(StandardError) 51 | end 52 | end 53 | 54 | describe 'error properties' do 55 | let(:error) { described_class.new } 56 | 57 | it 'has a backtrace when raised' do 58 | raise error 59 | rescue described_class => e 60 | aggregate_failures 'backtrace' do 61 | expect(e.backtrace).to be_an(Array) 62 | expect(e.backtrace).not_to be_empty 63 | end 64 | end 65 | 66 | it 'maintains error class information' do 67 | aggregate_failures 'class' do 68 | expect(error.class).to eq(described_class) 69 | expect(error.class.name).to eq('HatiJsonapiError::Errors::HelpersRenderError') 70 | end 71 | end 72 | end 73 | 74 | describe 'module structure' do 75 | it 'is defined under HatiJsonapiError::Errors module' do 76 | expect(described_class.name).to start_with('HatiJsonapiError::Errors') 77 | end 78 | 79 | it 'is accessible through module path' do 80 | expect(HatiJsonapiError::Errors::HelpersRenderError).to eq(described_class) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | # class ApiController < ApplicationController 5 | # rescue_from ::StandardError, with: ->(e) { handle_error(e) } 6 | # end 7 | 8 | # This module contains helper methods for rendering errors in a JSON API format. 9 | module Helpers 10 | HatiErrs = HatiJsonapiError::Errors 11 | 12 | def render_error(error, status: nil, short: false) 13 | error_instance = error.is_a?(Class) ? error.new : error 14 | 15 | unless error_instance.class <= HatiJsonapiError::BaseError 16 | msg = "Supported only explicit type of HatiJsonapiError::BaseError, got: #{error_instance.class.name}" 17 | raise HatiErrs::HelpersRenderError, msg 18 | end 19 | 20 | resolver = HatiJsonapiError::Resolver.new(error_instance) 21 | 22 | unless defined?(render) 23 | msg = 'Render not defined' 24 | raise HatiErrs::HelpersRenderError, msg 25 | end 26 | 27 | render json: resolver.to_json(short: short), status: status || resolver.status 28 | end 29 | 30 | # with_original: oneOf: [false, true, :full_trace] 31 | def handle_error(error, with_original: false) 32 | error_class = error if error.class <= HatiJsonapiError::BaseError 33 | error_class ||= HatiJsonapiError::Registry.lookup_error(error) 34 | 35 | unless error_class 36 | msg = 'Used handle_error but no mapping of default error set' 37 | raise HatiErrs::HelpersHandleError, msg 38 | end 39 | 40 | # Fix: if error_class is already an instance, use it directly, otherwise create new instance 41 | api_err = error_class.is_a?(Class) ? error_class.new : error_class 42 | if with_original 43 | api_err.meta = { 44 | original_error: error.class, 45 | trace: error.backtrace[0], 46 | message: error.message 47 | } 48 | api_err.meta.merge!(backtrace: error.backtrace.join("\n")) if with_original == :full_trace 49 | end 50 | 51 | render_error(api_err) 52 | end 53 | 54 | # shorthand for API errors 55 | # raise ApiErr[404] # => ApiError::NotFound 56 | # raise ApiErr[:not_found] # => ApiError::NotFound 57 | class ApiErr 58 | class << self 59 | def [](error) 60 | call(error) 61 | end 62 | 63 | def call(error) 64 | raise HatiErrs::NotLoadedError unless HatiJsonapiError::Kigen.loaded? 65 | 66 | err = HatiJsonapiError::Kigen.fetch_err(error) 67 | 68 | unless err 69 | msg = "Error #{error} not defined on load_errors!. Check kigen.rb and api_error/error_const.rb" 70 | raise HatiErrs::NotDefinedErrorClassError, msg 71 | end 72 | 73 | err 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/unit/hati_jsonapi_error/errors/helpers_handle_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError::Errors::HelpersHandleError do 6 | describe '.new' do 7 | context 'with default message' do 8 | subject(:error) { described_class.new } 9 | 10 | it 'creates an error with default message' do 11 | expect(error.message).to eq('Invalid Helpers:handle_error') 12 | end 13 | 14 | it 'inherits from StandardError' do 15 | expect(error).to be_a(StandardError) 16 | end 17 | 18 | it 'is a HelpersHandleError' do 19 | expect(error).to be_a(described_class) 20 | end 21 | end 22 | 23 | context 'with custom message' do 24 | subject(:error) { described_class.new(custom_message) } 25 | 26 | let(:custom_message) { 'Custom handle error message' } 27 | 28 | it 'creates an error with custom message' do 29 | expect(error.message).to eq(custom_message) 30 | end 31 | 32 | it 'inherits from StandardError' do 33 | expect(error).to be_a(StandardError) 34 | end 35 | end 36 | end 37 | 38 | describe 'error handling' do 39 | it 'can be raised and caught' do 40 | expect { raise described_class.new }.to raise_error(described_class) 41 | end 42 | 43 | it 'can be raised with custom message' do 44 | custom_message = 'Handle error test message' 45 | expect { raise described_class.new(custom_message) }.to raise_error(described_class, custom_message) 46 | end 47 | 48 | it 'can be caught as StandardError' do 49 | expect { raise described_class.new }.to raise_error(StandardError) 50 | end 51 | end 52 | 53 | describe 'error properties' do 54 | let(:error) { described_class.new } 55 | 56 | it 'has a backtrace when raised' do 57 | raise error 58 | rescue described_class => e 59 | aggregate_failures 'backtrace' do 60 | expect(e.backtrace).to be_an(Array) 61 | expect(e.backtrace).not_to be_empty 62 | end 63 | end 64 | 65 | it 'maintains error class information' do 66 | aggregate_failures 'class' do 67 | expect(error.class).to eq(described_class) 68 | expect(error.class.name).to eq('HatiJsonapiError::Errors::HelpersHandleError') 69 | end 70 | end 71 | end 72 | 73 | describe 'module structure' do 74 | it 'is defined under HatiJsonapiError::Errors module' do 75 | expect(described_class.name).to start_with('HatiJsonapiError::Errors') 76 | end 77 | 78 | it 'is accessible through module path' do 79 | expect(HatiJsonapiError::Errors::HelpersHandleError).to eq(described_class) 80 | end 81 | end 82 | 83 | describe 'usage in helpers context' do 84 | it 'provides meaningful error for handle_error method failures' do 85 | error_message = 'No mapping found for error' 86 | error = described_class.new(error_message) 87 | 88 | aggregate_failures 'message' do 89 | expect(error.message).to eq(error_message) 90 | expect(error).to be_a(StandardError) 91 | end 92 | end 93 | 94 | it 'can differentiate from render errors' do 95 | handle_error = described_class.new 96 | render_error = HatiJsonapiError::Errors::HelpersRenderError.new 97 | 98 | aggregate_failures 'class' do 99 | expect(handle_error.class).not_to eq(render_error.class) 100 | expect(handle_error.message).not_to eq(render_error.message) 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/unit/hati_jsonapi_error/api_error/links_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError::Links do 6 | describe 'constants' do 7 | it 'defines STR as empty string' do 8 | expect(described_class::STR).to eq('') 9 | end 10 | end 11 | 12 | describe '#initialize' do 13 | context 'with all attributes' do 14 | subject(:links) do 15 | described_class.new( 16 | about: 'https://example.com/errors/not_found', 17 | type: 'error_documentation' 18 | ) 19 | end 20 | 21 | it 'sets all attributes correctly' do 22 | aggregate_failures 'attributes' do 23 | expect(links.about).to eq('https://example.com/errors/not_found') 24 | expect(links.type).to eq('error_documentation') 25 | end 26 | end 27 | end 28 | 29 | context 'with no attributes' do 30 | subject(:links) { described_class.new } 31 | 32 | it 'sets default values' do 33 | aggregate_failures 'defaults' do 34 | expect(links.about).to eq('') 35 | expect(links.type).to eq('') 36 | end 37 | end 38 | end 39 | 40 | context 'with partial attributes' do 41 | it 'sets about and defaults type' do 42 | links = described_class.new(about: 'https://example.com/errors/not_found') 43 | 44 | aggregate_failures 'partial about' do 45 | expect(links.about).to eq('https://example.com/errors/not_found') 46 | expect(links.type).to eq('') 47 | end 48 | end 49 | 50 | it 'sets type and defaults about' do 51 | links = described_class.new(type: 'error_documentation') 52 | 53 | aggregate_failures 'partial type' do 54 | expect(links.about).to eq('') 55 | expect(links.type).to eq('error_documentation') 56 | end 57 | end 58 | end 59 | end 60 | 61 | describe '#to_h' do 62 | it 'returns hash with all attributes' do 63 | links = described_class.new( 64 | about: 'https://example.com/errors/not_found', 65 | type: 'error_documentation' 66 | ) 67 | 68 | expect(links.to_h).to eq( 69 | about: 'https://example.com/errors/not_found', 70 | type: 'error_documentation' 71 | ) 72 | end 73 | 74 | it 'returns hash with default values when no attributes set' do 75 | links = described_class.new 76 | 77 | expect(links.to_h).to eq( 78 | about: '', 79 | type: '' 80 | ) 81 | end 82 | 83 | it 'returns hash with partial attributes' do 84 | links = described_class.new(about: 'https://example.com/errors/not_found') 85 | 86 | expect(links.to_h).to eq( 87 | about: 'https://example.com/errors/not_found', 88 | type: '' 89 | ) 90 | end 91 | end 92 | 93 | describe 'attribute accessors' do 94 | subject(:links) { described_class.new } 95 | 96 | it 'allows setting about' do 97 | links.about = 'https://example.com/errors/not_found' 98 | expect(links.about).to eq('https://example.com/errors/not_found') 99 | end 100 | 101 | it 'allows setting type' do 102 | links.type = 'error_documentation' 103 | expect(links.type).to eq('error_documentation') 104 | end 105 | 106 | it 'reflects attribute changes in to_h' do 107 | links.about = 'https://example.com/errors/not_found' 108 | links.type = 'error_documentation' 109 | 110 | expect(links.to_h).to eq( 111 | about: 'https://example.com/errors/not_found', 112 | type: 'error_documentation' 113 | ) 114 | end 115 | end 116 | 117 | describe 'JSON:API compliance' do 118 | it 'follows JSON:API links object structure' do 119 | links = described_class.new( 120 | about: 'https://example.com/errors/not_found', 121 | type: 'error_documentation' 122 | ) 123 | result = links.to_h 124 | 125 | aggregate_failures 'structure' do 126 | expect(result.keys).to match_array(%i[about type]) 127 | expect(result[:about]).to be_a(String) 128 | expect(result[:type]).to be_a(String) 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /spec/unit/hati_jsonapi_error/kigen_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError::Kigen do 6 | before(:all) { described_class.load_errors! } 7 | 8 | describe '.load_errors!' do 9 | context 'when testing loading behavior' do 10 | before do 11 | err_attrs = { name: 'TestError500', code: :test_error500, message: 'Test Error' } 12 | 13 | allow(HatiJsonapiError).to receive(:const_defined?).with('TestError500').and_return(false) 14 | allow(HatiJsonapiError::STATUS_MAP).to receive(:each).and_yield(500, err_attrs) 15 | end 16 | 17 | it 'loads error classes from STATUS_MAP' do 18 | aggregate_failures 'error classes' do 19 | expect(HatiJsonapiError::NotFound).to be < HatiJsonapiError::BaseError 20 | expect(HatiJsonapiError::BadRequest).to be < HatiJsonapiError::BaseError 21 | expect(HatiJsonapiError::InternalServerError).to be < HatiJsonapiError::BaseError 22 | end 23 | end 24 | 25 | it 'sets loaded flag when called' do 26 | described_class.instance_variable_set(:@loaded, false) 27 | 28 | expect { described_class.load_errors! }.to change(described_class, :loaded?).from(false).to(true) 29 | end 30 | 31 | it 'skips loading if already loaded' do 32 | described_class.instance_variable_set(:@loaded, true) 33 | allow(HatiJsonapiError).to receive(:const_set) 34 | 35 | described_class.load_errors! 36 | 37 | expect(HatiJsonapiError).not_to have_received(:const_set) 38 | end 39 | end 40 | end 41 | 42 | describe '.fetch_err' do 43 | it 'returns error class by status code' do 44 | error_class = described_class.fetch_err(404) 45 | 46 | aggregate_failures 'error class' do 47 | expect(error_class).to be_a(Class) 48 | expect(error_class).to be < HatiJsonapiError::BaseError 49 | expect(error_class.new.status).to eq(404) 50 | end 51 | end 52 | 53 | it 'returns error class by symbol code' do 54 | error_class = described_class.fetch_err(:not_found) 55 | 56 | aggregate_failures 'error class' do 57 | expect(error_class).to be_a(Class) 58 | expect(error_class).to be < HatiJsonapiError::BaseError 59 | expect(error_class.new.code).to eq(:not_found) 60 | end 61 | end 62 | 63 | it 'returns nil for unknown error' do 64 | expect(described_class.fetch_err(:unknown)).to be_nil 65 | end 66 | 67 | context 'when not loaded' do 68 | it 'returns nil' do 69 | original_loaded = described_class.loaded? 70 | original_status_map = described_class.status_klass_map.dup 71 | original_code_map = described_class.code_klass_map.dup 72 | 73 | begin 74 | described_class.instance_variable_set(:@loaded, false) 75 | expect(described_class.fetch_err(404)).to be_nil 76 | ensure 77 | # Restore state 78 | described_class.instance_variable_set(:@loaded, original_loaded) 79 | described_class.instance_variable_set(:@status_klass_map, original_status_map) 80 | described_class.instance_variable_set(:@code_klass_map, original_code_map) 81 | end 82 | end 83 | end 84 | end 85 | 86 | describe '.[]' do 87 | it 'delegates to fetch_err' do 88 | allow(described_class).to receive(:fetch_err) 89 | 90 | described_class[404] 91 | 92 | expect(described_class).to have_received(:fetch_err).with(404) 93 | end 94 | end 95 | 96 | describe 'generated error classes' do 97 | it 'creates error with correct defaults' do 98 | error = HatiJsonapiError::NotFound.new 99 | 100 | aggregate_failures 'default attributes' do 101 | expect(error.code).to eq(:not_found) 102 | expect(error.to_h[:title]).to eq('Not Found') 103 | expect(error.status).to eq(404) 104 | end 105 | end 106 | 107 | it 'allows overriding defaults' do 108 | error = HatiJsonapiError::NotFound.new(title: 'Custom title') 109 | 110 | aggregate_failures 'custom attributes' do 111 | expect(error.to_h[:title]).to eq('Custom title') 112 | expect(error.status).to eq(404) 113 | expect(error.code).to eq(:not_found) 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /spec/unit/hati_jsonapi_error/registry_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError::Registry do 6 | before(:all) { HatiJsonapiError::Kigen.load_errors! } 7 | 8 | before do 9 | described_class.instance_variable_set(:@fallback, nil) 10 | described_class.instance_variable_set(:@error_map, nil) 11 | end 12 | 13 | describe '.fallback=' do 14 | context 'when setting a valid error class' do 15 | it 'sets the fallback error directly' do 16 | described_class.fallback = HatiJsonapiError::NotFound 17 | 18 | expect(described_class.fallback).to eq(HatiJsonapiError::NotFound) 19 | end 20 | end 21 | 22 | context 'when setting an error by status code' do 23 | it 'fetches and sets the corresponding error class' do 24 | described_class.fallback = 404 25 | 26 | expect(described_class.fallback).to eq(HatiJsonapiError::NotFound) 27 | end 28 | end 29 | 30 | context 'when setting an error by symbol' do 31 | it 'fetches and sets the corresponding error class' do 32 | described_class.fallback = :not_found 33 | 34 | expect(described_class.fallback).to eq(HatiJsonapiError::NotFound) 35 | end 36 | end 37 | 38 | context 'when error definition is not found' do 39 | it 'raises an error' do 40 | expect { described_class.fallback = :unknown_error }.to raise_error( 41 | HatiJsonapiError::Errors::NotDefinedErrorClassError, 42 | 'Error unknown_error definition not found in lib/hati_jsonapi_error/api_error/error_const.rb' 43 | ) 44 | end 45 | end 46 | end 47 | 48 | describe '.error_map=' do 49 | it 'sets the error map with resolved error classes from status codes' do 50 | error_map = { 51 | KeyError => 404, 52 | ArgumentError => 400 53 | } 54 | error_class_map = { 55 | KeyError => HatiJsonapiError::NotFound, 56 | ArgumentError => HatiJsonapiError::BadRequest 57 | } 58 | 59 | described_class.error_map = error_map 60 | 61 | expect(described_class.error_map).to eq(error_class_map) 62 | end 63 | 64 | it 'sets the error map with resolved error classes from symbols' do 65 | error_map = { 66 | KeyError => :not_found, ArgumentError => :bad_request 67 | } 68 | error_class_map = { 69 | KeyError => HatiJsonapiError::NotFound, 70 | ArgumentError => HatiJsonapiError::BadRequest 71 | } 72 | 73 | described_class.error_map = error_map 74 | 75 | expect(described_class.error_map).to eq(error_class_map) 76 | end 77 | 78 | it 'keeps already resolved error classes unchanged' do 79 | error_map = { 80 | KeyError => HatiJsonapiError::NotFound, 81 | ArgumentError => :bad_request 82 | } 83 | error_class_map = { 84 | KeyError => HatiJsonapiError::NotFound, 85 | ArgumentError => HatiJsonapiError::BadRequest 86 | } 87 | 88 | described_class.error_map = error_map 89 | 90 | expect(described_class.error_map).to eq(error_class_map) 91 | end 92 | end 93 | 94 | describe '.lookup_error' do 95 | context 'when error class is mapped' do 96 | before do 97 | described_class.fallback = :internal_server_error 98 | described_class.error_map = { 99 | KeyError => :not_found, 100 | ArgumentError => :bad_request 101 | } 102 | end 103 | 104 | it 'returns the mapped error class' do 105 | error = KeyError.new 106 | 107 | expect(described_class.lookup_error(error)).to eq(HatiJsonapiError::NotFound) 108 | end 109 | 110 | it 'returns the fallback error class for unmapped errors' do 111 | error = StandardError.new 112 | 113 | expect(described_class.lookup_error(error)).to eq(HatiJsonapiError::InternalServerError) 114 | end 115 | end 116 | 117 | context 'when no fallback is set' do 118 | before do 119 | # Reset both fallback and error_map 120 | described_class.instance_variable_set(:@fallback, nil) 121 | described_class.error_map = {} 122 | end 123 | 124 | it 'returns nil for unmapped errors' do 125 | error = StandardError.new 126 | 127 | expect(described_class.lookup_error(error)).to be_nil 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /spec/unit/hati_jsonapi_error/errors/not_defined_error_class_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError::Errors::NotDefinedErrorClassError do 6 | describe '.new' do 7 | context 'with default message' do 8 | subject(:error) { described_class.new } 9 | 10 | it 'creates an error with default message' do 11 | expect(error.message).to eq('Error class not defined') 12 | end 13 | 14 | it 'inherits from StandardError' do 15 | expect(error).to be_a(StandardError) 16 | end 17 | 18 | it 'is a NotDefinedErrorClassError' do 19 | expect(error).to be_a(described_class) 20 | end 21 | end 22 | 23 | context 'with custom message' do 24 | subject(:error) { described_class.new(custom_message) } 25 | 26 | let(:custom_message) { 'Custom error class not found message' } 27 | 28 | it 'creates an error with custom message' do 29 | expect(error.message).to eq(custom_message) 30 | end 31 | 32 | it 'inherits from StandardError' do 33 | expect(error).to be_a(StandardError) 34 | end 35 | end 36 | end 37 | 38 | describe 'error handling' do 39 | it 'can be raised and caught' do 40 | expect { raise described_class.new }.to raise_error(described_class) 41 | end 42 | 43 | it 'can be raised with custom message' do 44 | custom_message = 'Error class XYZ not found' 45 | expect { raise described_class.new(custom_message) }.to raise_error(described_class, custom_message) 46 | end 47 | 48 | it 'can be caught as StandardError' do 49 | expect { raise described_class.new }.to raise_error(StandardError) 50 | end 51 | end 52 | 53 | describe 'error properties' do 54 | let(:error) { described_class.new } 55 | 56 | it 'has a backtrace when raised' do 57 | raise error 58 | rescue described_class => e 59 | aggregate_failures 'backtrace' do 60 | expect(e.backtrace).to be_an(Array) 61 | expect(e.backtrace).not_to be_empty 62 | end 63 | end 64 | 65 | it 'maintains error class information' do 66 | aggregate_failures 'class' do 67 | expect(error.class).to eq(described_class) 68 | expect(error.class.name).to eq('HatiJsonapiError::Errors::NotDefinedErrorClassError') 69 | end 70 | end 71 | end 72 | 73 | describe 'module structure' do 74 | it 'is defined under HatiJsonapiError::Errors module' do 75 | expect(described_class.name).to start_with('HatiJsonapiError::Errors') 76 | end 77 | 78 | it 'is accessible through module path' do 79 | expect(HatiJsonapiError::Errors::NotDefinedErrorClassError).to eq(described_class) 80 | end 81 | end 82 | 83 | describe 'usage scenarios' do 84 | it 'provides meaningful error for undefined error classes' do 85 | error_message = 'Error unknown_error definition not found' 86 | error = described_class.new(error_message) 87 | 88 | aggregate_failures 'message' do 89 | expect(error.message).to eq(error_message) 90 | expect(error).to be_a(StandardError) 91 | end 92 | end 93 | 94 | it 'can be used in registry context' do 95 | # Simulate the actual usage pattern from registry.rb 96 | error_name = :unknown_error 97 | message = "Error #{error_name} definition not found in lib/hati_jsonapi_error/api_error/error_const.rb" 98 | 99 | expect { raise described_class.new(message) }.to raise_error(described_class, message) 100 | end 101 | 102 | it 'can be used in helpers context' do 103 | # Simulate the actual usage pattern from helpers.rb 104 | error_name = :unknown_code 105 | message = "Error #{error_name} not defined on load_errors!. Check kigen.rb and api_error/error_const.rb" 106 | 107 | expect { raise described_class.new(message) }.to raise_error(described_class, message) 108 | end 109 | end 110 | 111 | describe 'error distinction' do 112 | it 'is different from other error classes' do 113 | not_defined_error = described_class.new 114 | not_loaded_error = HatiJsonapiError::Errors::NotLoadedError.new 115 | 116 | aggregate_failures 'class' do 117 | expect(not_defined_error.class).not_to eq(not_loaded_error.class) 118 | expect(not_defined_error.message).not_to eq(not_loaded_error.message) 119 | end 120 | end 121 | 122 | it 'has specific error semantics' do 123 | error = described_class.new 124 | 125 | # This error specifically indicates that an error class definition is missing 126 | aggregate_failures 'message' do 127 | expect(error.message).to include('not defined') 128 | expect(error.class.name).to include('NotDefined') 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /spec/unit/hati_jsonapi_error/errors/not_loaded_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError::Errors::NotLoadedError do 6 | describe '.new' do 7 | context 'with default message' do 8 | subject(:error) { described_class.new } 9 | 10 | it 'creates an error with default message' do 11 | expect(error.message).to eq('HatiJsonapiError::Kigen not loaded') 12 | end 13 | 14 | it 'inherits from StandardError' do 15 | expect(error).to be_a(StandardError) 16 | end 17 | 18 | it 'is a NotLoadedError' do 19 | expect(error).to be_a(described_class) 20 | end 21 | end 22 | 23 | context 'with custom message' do 24 | subject(:error) { described_class.new(custom_message) } 25 | 26 | let(:custom_message) { 'Custom not loaded message' } 27 | 28 | it 'creates an error with custom message' do 29 | expect(error.message).to eq(custom_message) 30 | end 31 | 32 | it 'inherits from StandardError' do 33 | expect(error).to be_a(StandardError) 34 | end 35 | end 36 | end 37 | 38 | describe 'error handling' do 39 | it 'can be raised and caught' do 40 | expect { raise described_class.new }.to raise_error(described_class) 41 | end 42 | 43 | it 'can be raised with custom message' do 44 | custom_message = 'Kigen system not initialized' 45 | expect { raise described_class.new(custom_message) }.to raise_error(described_class, custom_message) 46 | end 47 | 48 | it 'can be caught as StandardError' do 49 | expect { raise described_class.new }.to raise_error(StandardError) 50 | end 51 | end 52 | 53 | describe 'error properties' do 54 | let(:error) { described_class.new } 55 | 56 | it 'has a backtrace when raised' do 57 | raise error 58 | rescue described_class => e 59 | aggregate_failures 'backtrace' do 60 | expect(e.backtrace).to be_an(Array) 61 | expect(e.backtrace).not_to be_empty 62 | end 63 | end 64 | 65 | it 'maintains error class information' do 66 | aggregate_failures 'class' do 67 | expect(error.class).to eq(described_class) 68 | expect(error.class.name).to eq('HatiJsonapiError::Errors::NotLoadedError') 69 | end 70 | end 71 | end 72 | 73 | describe 'module structure' do 74 | it 'is defined under HatiJsonapiError::Errors module' do 75 | expect(described_class.name).to start_with('HatiJsonapiError::Errors') 76 | end 77 | 78 | it 'is accessible through module path' do 79 | expect(HatiJsonapiError::Errors::NotLoadedError).to eq(described_class) 80 | end 81 | end 82 | 83 | describe 'usage scenarios' do 84 | it 'provides meaningful error for Kigen not loaded state' do 85 | error = described_class.new 86 | 87 | aggregate_failures 'message' do 88 | expect(error.message).to include('Kigen') 89 | expect(error.message).to include('not loaded') 90 | expect(error).to be_a(StandardError) 91 | end 92 | end 93 | 94 | it 'can be used in helpers context' do 95 | # Simulate the actual usage pattern from helpers.rb 96 | expect { raise described_class.new }.to raise_error(described_class, 'HatiJsonapiError::Kigen not loaded') 97 | end 98 | 99 | it 'provides context about initialization state' do 100 | error = described_class.new 101 | 102 | # The default message specifically mentions Kigen not being loaded 103 | expect(error.message).to eq('HatiJsonapiError::Kigen not loaded') 104 | end 105 | end 106 | 107 | describe 'error distinction' do 108 | it 'is different from other error classes' do 109 | not_loaded_error = described_class.new 110 | not_defined_error = HatiJsonapiError::Errors::NotDefinedErrorClassError.new 111 | 112 | aggregate_failures 'class' do 113 | expect(not_loaded_error.class).not_to eq(not_defined_error.class) 114 | expect(not_loaded_error.message).not_to eq(not_defined_error.message) 115 | end 116 | end 117 | 118 | it 'has specific loading-related semantics' do 119 | error = described_class.new 120 | 121 | # This error specifically indicates that Kigen (error loading system) is not loaded 122 | aggregate_failures 'message' do 123 | expect(error.message).to include('not loaded') 124 | expect(error.message).to include('Kigen') 125 | expect(error.class.name).to include('NotLoaded') 126 | end 127 | end 128 | end 129 | 130 | describe 'integration with Kigen' do 131 | before { HatiJsonapiError::Kigen.load_errors! } 132 | 133 | it 'represents state when Kigen is not properly initialized' do 134 | # Mock Kigen as not loaded 135 | allow(HatiJsonapiError::Kigen).to receive(:loaded?).and_return(false) 136 | 137 | error = described_class.new 138 | expect(error.message).to eq('HatiJsonapiError::Kigen not loaded') 139 | end 140 | 141 | it 'can be used to guard against uninitialized system access' do 142 | error_message = 'System not ready for error mapping' 143 | 144 | expect { raise described_class.new(error_message) }.to raise_error(described_class, error_message) 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # [hackico-ai] Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We, as members, contributors, and leaders of [Your Community Name], pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | - Demonstrating empathy and kindness toward others. 14 | - Being respectful of differing opinions, viewpoints, and experiences. 15 | - Giving and gracefully accepting constructive feedback. 16 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience. 17 | - Focusing on what is best not just for us as individuals, but for the overall community. 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | - The use of sexualized language or imagery, and sexual attention or advances of any kind. 22 | - Trolling, insulting or derogatory comments, and personal or political attacks. 23 | - Public or private harassment. 24 | - Publishing others' private information, such as a physical or email address, without their explicit permission. 25 | - Other conduct which could reasonably be considered inappropriate in a professional setting. 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies within all community spaces and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [Yuri Gi](https://github.com/yurigitsu), [Marie Giy](https://github.com/yurigitsu). All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 46 | 47 | ### 1. Correction 48 | 49 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 50 | 51 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 52 | 53 | ### 2. Warning 54 | 55 | **Community Impact**: A violation through a single incident or series of actions. 56 | 57 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 58 | 59 | ### 3. Temporary Ban 60 | 61 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 62 | 63 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 64 | 65 | ### 4. Permanent Ban 66 | 67 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 68 | 69 | **Consequence**: A permanent ban from any sort of public interaction within the community. 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 74 | 75 | For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. 76 | 77 | [homepage]: https://www.contributor-covenant.org 78 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 79 | [FAQ]: https://www.contributor-covenant.org/faq 80 | [translations]: https://www.contributor-covenant.org/translations 81 | -------------------------------------------------------------------------------- /spec/unit/hati_jsonapi_error/api_error/source_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError::Source do 6 | describe 'constants' do 7 | it 'defines STR as empty string' do 8 | expect(described_class::STR).to eq('') 9 | end 10 | end 11 | 12 | describe '#initialize' do 13 | context 'with all attributes' do 14 | subject(:source) do 15 | described_class.new( 16 | pointer: '/data/attributes/email', 17 | parameter: 'email', 18 | header: 'Authorization' 19 | ) 20 | end 21 | 22 | it 'sets all attributes correctly' do 23 | aggregate_failures 'attributes' do 24 | expect(source.pointer).to eq('/data/attributes/email') 25 | expect(source.parameter).to eq('email') 26 | expect(source.header).to eq('Authorization') 27 | end 28 | end 29 | end 30 | 31 | context 'with no attributes' do 32 | subject(:source) { described_class.new } 33 | 34 | it 'sets default values' do 35 | aggregate_failures 'defaults' do 36 | expect(source.pointer).to eq('') 37 | expect(source.parameter).to eq('') 38 | expect(source.header).to eq('') 39 | end 40 | end 41 | end 42 | 43 | context 'with partial attributes' do 44 | it 'sets pointer and defaults others' do 45 | source = described_class.new(pointer: '/data/attributes/email') 46 | 47 | aggregate_failures 'partial pointer' do 48 | expect(source.pointer).to eq('/data/attributes/email') 49 | expect(source.parameter).to eq('') 50 | expect(source.header).to eq('') 51 | end 52 | end 53 | 54 | it 'sets parameter and defaults others' do 55 | source = described_class.new(parameter: 'email') 56 | 57 | aggregate_failures 'partial parameter' do 58 | expect(source.pointer).to eq('') 59 | expect(source.parameter).to eq('email') 60 | expect(source.header).to eq('') 61 | end 62 | end 63 | 64 | it 'sets header and defaults others' do 65 | source = described_class.new(header: 'Authorization') 66 | 67 | aggregate_failures 'partial header' do 68 | expect(source.pointer).to eq('') 69 | expect(source.parameter).to eq('') 70 | expect(source.header).to eq('Authorization') 71 | end 72 | end 73 | end 74 | end 75 | 76 | describe '#to_h' do 77 | it 'returns hash with all attributes' do 78 | source = described_class.new( 79 | pointer: '/data/attributes/email', 80 | parameter: 'email', 81 | header: 'Authorization' 82 | ) 83 | 84 | expect(source.to_h).to eq( 85 | pointer: '/data/attributes/email', 86 | parameter: 'email', 87 | header: 'Authorization' 88 | ) 89 | end 90 | 91 | it 'returns hash with default values when no attributes set' do 92 | source = described_class.new 93 | 94 | expect(source.to_h).to eq( 95 | pointer: '', 96 | parameter: '', 97 | header: '' 98 | ) 99 | end 100 | 101 | it 'returns hash with partial attributes' do 102 | source = described_class.new(pointer: '/data/attributes/email') 103 | 104 | expect(source.to_h).to eq( 105 | pointer: '/data/attributes/email', 106 | parameter: '', 107 | header: '' 108 | ) 109 | end 110 | end 111 | 112 | describe 'attribute accessors' do 113 | subject(:source) { described_class.new } 114 | 115 | it 'allows setting pointer' do 116 | source.pointer = '/data/attributes/email' 117 | expect(source.pointer).to eq('/data/attributes/email') 118 | end 119 | 120 | it 'allows setting parameter' do 121 | source.parameter = 'email' 122 | expect(source.parameter).to eq('email') 123 | end 124 | 125 | it 'allows setting header' do 126 | source.header = 'Authorization' 127 | expect(source.header).to eq('Authorization') 128 | end 129 | 130 | it 'reflects attribute changes in to_h' do 131 | source.pointer = '/data/attributes/email' 132 | source.parameter = 'email' 133 | source.header = 'Authorization' 134 | 135 | expect(source.to_h).to eq( 136 | pointer: '/data/attributes/email', 137 | parameter: 'email', 138 | header: 'Authorization' 139 | ) 140 | end 141 | end 142 | 143 | describe 'JSON:API compliance' do 144 | it 'follows JSON:API source object structure' do 145 | source = described_class.new( 146 | pointer: '/data/attributes/email', 147 | parameter: 'email', 148 | header: 'Authorization' 149 | ) 150 | result = source.to_h 151 | 152 | aggregate_failures 'structure' do 153 | expect(result.keys).to match_array(%i[pointer parameter header]) 154 | expect(result[:pointer]).to be_a(String) 155 | expect(result[:parameter]).to be_a(String) 156 | expect(result[:header]).to be_a(String) 157 | end 158 | end 159 | 160 | it 'uses JSON pointer format for pointer attribute' do 161 | source = described_class.new(pointer: '/data/attributes/email') 162 | 163 | aggregate_failures 'pointer format' do 164 | expect(source.pointer).to start_with('/') 165 | expect(source.pointer).not_to end_with('/') 166 | # Replace be_present with !empty? for standard Ruby 167 | expect(source.pointer.split('/')[1..]).to all(satisfy { |part| !part.empty? }) 168 | end 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /spec/unit/hati_jsonapi_error/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError::Config do 6 | before(:all) { HatiJsonapiError::Kigen.load_errors! } 7 | 8 | before do 9 | HatiJsonapiError::Registry.instance_variable_set(:@fallback, nil) 10 | HatiJsonapiError::Registry.instance_variable_set(:@error_map, {}) 11 | end 12 | 13 | describe '.configure' do 14 | it 'yields self when block given' do 15 | expect { |b| described_class.configure(&b) }.to yield_with_args(described_class) 16 | end 17 | 18 | it 'returns nil when no block given' do 19 | expect(described_class.configure).to be_nil 20 | end 21 | 22 | it 'allows configuration through block' do 23 | error_map = { StandardError => HatiJsonapiError::InternalServerError } 24 | fallback_error = HatiJsonapiError::InternalServerError 25 | 26 | described_class.configure do |config| 27 | config.map_errors = error_map 28 | config.use_unexpected = fallback_error 29 | end 30 | 31 | aggregate_failures 'configuration' do 32 | expect(HatiJsonapiError::Registry.error_map).to eq(error_map) 33 | expect(HatiJsonapiError::Registry.instance_variable_get(:@fallback)).to eq(fallback_error) 34 | end 35 | end 36 | end 37 | 38 | describe '.use_unexpected=' do 39 | let(:fallback_error) { HatiJsonapiError::InternalServerError } 40 | 41 | it 'sets fallback error in Registry' do 42 | described_class.use_unexpected = fallback_error 43 | 44 | expect(HatiJsonapiError::Registry.instance_variable_get(:@fallback)).to eq(fallback_error) 45 | end 46 | 47 | it 'accepts error class' do 48 | error_class = Class.new(HatiJsonapiError::BaseError) 49 | described_class.use_unexpected = error_class 50 | 51 | expect(HatiJsonapiError::Registry.instance_variable_get(:@fallback)).to eq(error_class) 52 | end 53 | 54 | it 'accepts symbol' do 55 | described_class.use_unexpected = :internal_server_error 56 | error_class = HatiJsonapiError::InternalServerError 57 | 58 | expect(HatiJsonapiError::Registry.instance_variable_get(:@fallback)).to eq(error_class) 59 | end 60 | 61 | it 'accepts status code' do 62 | described_class.use_unexpected = 500 63 | error_class = HatiJsonapiError::InternalServerError 64 | 65 | expect(HatiJsonapiError::Registry.instance_variable_get(:@fallback)).to eq(error_class) 66 | end 67 | end 68 | 69 | describe '.map_errors=' do 70 | let(:error_map) do 71 | { 72 | StandardError => HatiJsonapiError::InternalServerError, 73 | ArgumentError => HatiJsonapiError::BadRequest, 74 | RuntimeError => HatiJsonapiError::InternalServerError 75 | } 76 | end 77 | 78 | it 'sets error map in Registry' do 79 | described_class.map_errors = error_map 80 | expect(HatiJsonapiError::Registry.error_map).to eq(error_map) 81 | end 82 | 83 | it 'accepts mixed error mappings' do 84 | mixed_map = { 85 | StandardError => :internal_server_error, 86 | ArgumentError => 400, 87 | RuntimeError => HatiJsonapiError::BadRequest 88 | } 89 | 90 | described_class.map_errors = mixed_map 91 | 92 | aggregate_failures 'mixed mappings' do 93 | expect(HatiJsonapiError::Registry.error_map[StandardError]).to eq(HatiJsonapiError::InternalServerError) 94 | expect(HatiJsonapiError::Registry.error_map[ArgumentError]).to eq(HatiJsonapiError::BadRequest) 95 | expect(HatiJsonapiError::Registry.error_map[RuntimeError]).to eq(HatiJsonapiError::BadRequest) 96 | end 97 | end 98 | end 99 | 100 | describe '.error_map' do 101 | let(:error_map) { { StandardError => HatiJsonapiError::InternalServerError } } 102 | 103 | it 'returns error map from Registry' do 104 | HatiJsonapiError::Registry.error_map = error_map 105 | expect(described_class.error_map).to eq(error_map) 106 | end 107 | 108 | it 'returns empty hash when no error map set' do 109 | expect(described_class.error_map).to eq({}) 110 | end 111 | end 112 | 113 | describe '.load_errors!' do 114 | it 'delegates to Kigen.load_errors!' do 115 | allow(HatiJsonapiError::Kigen).to receive(:load_errors!) 116 | described_class.load_errors! 117 | 118 | expect(HatiJsonapiError::Kigen).to have_received(:load_errors!) 119 | end 120 | 121 | it 'loads error classes' do 122 | described_class.load_errors! 123 | 124 | aggregate_failures 'loaded errors' do 125 | expect(HatiJsonapiError.const_defined?(:NotFound)).to be true 126 | expect(HatiJsonapiError.const_defined?(:InternalServerError)).to be true 127 | expect(HatiJsonapiError.const_defined?(:UnprocessableEntity)).to be true 128 | end 129 | end 130 | end 131 | 132 | describe 'configuration example' do 133 | it 'supports the documented configuration pattern' do 134 | error_map = { 135 | StandardError => HatiJsonapiError::InternalServerError, 136 | ArgumentError => HatiJsonapiError::BadRequest, 137 | RuntimeError => HatiJsonapiError::InternalServerError 138 | } 139 | fallback_error = HatiJsonapiError::InternalServerError 140 | 141 | described_class.configure do |config| 142 | config.load_errors! 143 | config.map_errors = error_map 144 | config.use_unexpected = fallback_error 145 | end 146 | 147 | aggregate_failures 'configuration result' do 148 | expect(HatiJsonapiError::Registry.error_map).to eq(error_map) 149 | expect(HatiJsonapiError::Registry.instance_variable_get(:@fallback)).to eq(fallback_error) 150 | end 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /lib/hati_jsonapi_error/api_error/error_const.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiJsonapiError 4 | # rubocop:disable Layout/LineLength 5 | CLIENT = { 6 | 400 => { name: 'BadRequest', code: :bad_request, message: 'Bad Request' }, 7 | 401 => { name: 'Unauthorized', code: :unauthorized, message: 'Unauthorized' }, 8 | 402 => { name: 'PaymentRequired', code: :payment_required, message: 'Payment Required' }, 9 | 403 => { name: 'Forbidden', code: :forbidden, message: 'Forbidden' }, 10 | 404 => { name: 'NotFound', code: :not_found, message: 'Not Found' }, 11 | 405 => { name: 'MethodNotAllowed', code: :method_not_allowed, message: 'Method Not Allowed' }, 12 | 406 => { name: 'NotAcceptable', code: :not_acceptable, message: 'Not Acceptable' }, 13 | 407 => { name: 'ProxyAuthenticationRequired', code: :proxy_authentication_required, message: 'Proxy Authentication Required' }, 14 | 408 => { name: 'RequestTimeout', code: :request_timeout, message: 'Request Timeout' }, 15 | 409 => { name: 'Conflict', code: :conflict, message: 'Conflict' }, 16 | 410 => { name: 'Gone', code: :gone, message: 'Gone' }, 17 | 411 => { name: 'LengthRequired', code: :length_required, message: 'Length Required' }, 18 | 412 => { name: 'PreconditionFailed', code: :precondition_failed, message: 'Precondition Failed' }, 19 | 413 => { name: 'RequestEntityTooLarge', code: :request_entity_too_large, message: 'Request Entity Too Large' }, 20 | 414 => { name: 'RequestUriTooLong', code: :request_uri_too_long, message: 'Request Uri Too Long' }, 21 | 415 => { name: 'UnsupportedMediaType', code: :unsupported_media_type, message: 'Unsupported Media Type' }, 22 | 416 => { name: 'RequestedRangeNotSatisfiable', code: :requested_range_not_satisfiable, message: 'Requested Range Not Satisfiable' }, 23 | 417 => { name: 'ExpectationFailed', code: :expectation_failed, message: 'Expectation Failed' }, 24 | 421 => { name: 'MisdirectedRequest', code: :misdirected_request, message: 'Misdirected Request' }, 25 | 422 => { name: 'UnprocessableEntity', code: :unprocessable_entity, message: 'Unprocessable Entity' }, 26 | 423 => { name: 'Locked', code: :locked, message: 'Locked' }, 27 | 424 => { name: 'FailedDependency', code: :failed_dependency, message: 'Failed Dependency' }, 28 | 425 => { name: 'TooEarly', code: :too_early, message: 'Too Early' }, 29 | 426 => { name: 'UpgradeRequired', code: :upgrade_required, message: 'Upgrade Required' }, 30 | 428 => { name: 'PreconditionRequired', code: :precondition_required, message: 'Precondition Required' }, 31 | 429 => { name: 'TooManyRequests', code: :too_many_requests, message: 'Too Many Requests' }, 32 | 431 => { name: 'RequestHeaderFieldsTooLarge', code: :request_header_fields_too_large, message: 'Request Header Fields Too Large' }, 33 | 451 => { name: 'UnavailableForLegalReasons', code: :unavailable_for_legal_reasons, message: 'Unavailable for Legal Reasons' } 34 | }.freeze 35 | 36 | SERVER = { 37 | 500 => { name: 'InternalServerError', code: :internal_server_error, message: 'Internal Server Error' }, 38 | 501 => { name: 'NotImplemented', code: :not_implemented, message: 'Not Implemented' }, 39 | 502 => { name: 'BadGateway', code: :bad_gateway, message: 'Bad Gateway' }, 40 | 503 => { name: 'ServiceUnavailable', code: :service_unavailable, message: 'Service Unavailable' }, 41 | 504 => { name: 'GatewayTimeout', code: :gateway_timeout, message: 'Gateway Timeout' }, 42 | 505 => { name: 'HttpVersionNotSupported', code: :http_version_not_supported, message: 'HTTP Version Not Supported' }, 43 | 506 => { name: 'VariantAlsoNegotiates', code: :variant_also_negotiates, message: 'Variant Also Negotiates' }, 44 | 507 => { name: 'InsufficientStorage', code: :insufficient_storage, message: 'Insufficient Storage' }, 45 | 508 => { name: 'LoopDetected', code: :loop_detected, message: 'Loop Detected' }, 46 | 509 => { name: 'BandwidthLimitExceeded', code: :bandwidth_limit_exceeded, message: 'Bandwidth Limit Exceeded' }, 47 | 510 => { name: 'NotExtended', code: :not_extended, message: 'Not Extended' }, 48 | 511 => { name: 'NetworkAuthenticationRequired', code: :network_authentication_required, message: 'Network Authentication Required' } 49 | }.freeze 50 | # rubocop:enable Layout/LineLength 51 | 52 | STATUS_MAP = CLIENT.merge(SERVER) 53 | end 54 | -------------------------------------------------------------------------------- /HTTP_STATUS_CODES.md: -------------------------------------------------------------------------------- 1 | # HTTP Status Codes Reference 2 | 3 | ## Client Errors (4xx) 4 | 5 | | Status | Class Name | Code | Message | 6 | | ------ | ---------------------------- | ------------------------------- | ------------------------------- | 7 | | 400 | BadRequest | bad_request | Bad Request | 8 | | 401 | Unauthorized | unauthorized | Unauthorized | 9 | | 402 | PaymentRequired | payment_required | Payment Required | 10 | | 403 | Forbidden | forbidden | Forbidden | 11 | | 404 | NotFound | not_found | Not Found | 12 | | 405 | MethodNotAllowed | method_not_allowed | Method Not Allowed | 13 | | 406 | NotAcceptable | not_acceptable | Not Acceptable | 14 | | 407 | ProxyAuthenticationRequired | proxy_authentication_required | Proxy Authentication Required | 15 | | 408 | RequestTimeout | request_timeout | Request Timeout | 16 | | 409 | Conflict | conflict | Conflict | 17 | | 410 | Gone | gone | Gone | 18 | | 411 | LengthRequired | length_required | Length Required | 19 | | 412 | PreconditionFailed | precondition_failed | Precondition Failed | 20 | | 413 | RequestEntityTooLarge | request_entity_too_large | Request Entity Too Large | 21 | | 414 | RequestUriTooLong | request_uri_too_long | Request Uri Too Long | 22 | | 415 | UnsupportedMediaType | unsupported_media_type | Unsupported Media Type | 23 | | 416 | RequestedRangeNotSatisfiable | requested_range_not_satisfiable | Requested Range Not Satisfiable | 24 | | 417 | ExpectationFailed | expectation_failed | Expectation Failed | 25 | | 421 | MisdirectedRequest | misdirected_request | Misdirected Request | 26 | | 422 | UnprocessableEntity | unprocessable_entity | Unprocessable Entity | 27 | | 423 | Locked | locked | Locked | 28 | | 424 | FailedDependency | failed_dependency | Failed Dependency | 29 | | 425 | TooEarly | too_early | Too Early | 30 | | 426 | UpgradeRequired | upgrade_required | Upgrade Required | 31 | | 428 | PreconditionRequired | precondition_required | Precondition Required | 32 | | 429 | TooManyRequests | too_many_requests | Too Many Requests | 33 | | 431 | RequestHeaderFieldsTooLarge | request_header_fields_too_large | Request Header Fields Too Large | 34 | | 451 | UnavailableForLegalReasons | unavailable_for_legal_reasons | Unavailable for Legal Reasons | 35 | 36 | ## Server Errors (5xx) 37 | 38 | | Status | Class Name | Code | Message | 39 | | ------ | ----------------------------- | ------------------------------- | ------------------------------- | 40 | | 500 | InternalServerError | internal_server_error | Internal Server Error | 41 | | 501 | NotImplemented | not_implemented | Not Implemented | 42 | | 502 | BadGateway | bad_gateway | Bad Gateway | 43 | | 503 | ServiceUnavailable | service_unavailable | Service Unavailable | 44 | | 504 | GatewayTimeout | gateway_timeout | Gateway Timeout | 45 | | 505 | HttpVersionNotSupported | http_version_not_supported | HTTP Version Not Supported | 46 | | 506 | VariantAlsoNegotiates | variant_also_negotiates | Variant Also Negotiates | 47 | | 507 | InsufficientStorage | insufficient_storage | Insufficient Storage | 48 | | 508 | LoopDetected | loop_detected | Loop Detected | 49 | | 509 | BandwidthLimitExceeded | bandwidth_limit_exceeded | Bandwidth Limit Exceeded | 50 | | 510 | NotExtended | not_extended | Not Extended | 51 | | 511 | NetworkAuthenticationRequired | network_authentication_required | Network Authentication Required | 52 | 53 | ## Usage Examples 54 | 55 | ```ruby 56 | # By status code 57 | HatiJsonapiError::NotFound.new # 404 58 | HatiJsonapiError::BadRequest.new # 400 59 | HatiJsonapiError::InternalServerError.new # 500 60 | 61 | # By symbol 62 | ApiErr[:not_found] # 404 63 | ApiErr[:bad_request] # 400 64 | ApiErr[:internal_server_error] # 500 65 | 66 | # By numeric code 67 | ApiErr[404] # NotFound 68 | ApiErr[400] # BadRequest 69 | ApiErr[500] # InternalServerError 70 | ``` 71 | 72 | ## Notes 73 | 74 | - All error classes inherit from `HatiJsonapiError::BaseError` 75 | - Each error supports custom attributes: `id`, `detail`, `meta`, `links`, `source` 76 | - Error responses are JSON:API compliant 77 | - Status codes follow RFC 7231 and related RFCs 78 | -------------------------------------------------------------------------------- /spec/integration/hati_jsonapi_error/helpers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError::Helpers do 6 | let(:dummy_class) { Dummy.dummy_class } 7 | 8 | let(:instance) { dummy_class.new } 9 | 10 | before(:all) { HatiJsonapiError::Kigen.load_errors! } 11 | 12 | describe '#render_error' do 13 | context 'with error class' do 14 | it 'renders error with default status' do 15 | instance.render_error(HatiJsonapiError::NotFound) 16 | parsed_json = JSON.parse(instance.rendered_json) 17 | 18 | aggregate_failures 'rendered error' do 19 | expect(instance.rendered_status).to eq(404) 20 | expect(parsed_json['errors'].first).to include( 21 | 'status' => 404, 22 | 'code' => 'not_found', 23 | 'title' => 'Not Found' 24 | ) 25 | end 26 | end 27 | 28 | it 'renders error with custom status' do 29 | instance.render_error(HatiJsonapiError::NotFound, status: 400) 30 | 31 | expect(instance.rendered_status).to eq(400) 32 | end 33 | 34 | it 'renders short version when requested' do 35 | instance.render_error(HatiJsonapiError::NotFound, short: true) 36 | parsed_json = JSON.parse(instance.rendered_json) 37 | err_hash = { 38 | 'status' => 404, 39 | 'code' => 'not_found', 40 | 'title' => 'Not Found' 41 | } 42 | 43 | expect(parsed_json['errors'].first).to include(err_hash) 44 | end 45 | end 46 | 47 | context 'with error instance' do 48 | it 'renders error with custom attributes' do 49 | error = HatiJsonapiError::NotFound.new( 50 | title: 'Custom Title', 51 | detail: 'Custom Detail' 52 | ) 53 | 54 | instance.render_error(error) 55 | 56 | parsed_json = JSON.parse(instance.rendered_json) 57 | err_hash = { 58 | 'title' => 'Custom Title', 59 | 'detail' => 'Custom Detail' 60 | } 61 | 62 | expect(parsed_json['errors'].first).to include(err_hash) 63 | end 64 | end 65 | 66 | context 'with invalid error' do 67 | it 'raises HelpersRenderError for non-BaseError class' do 68 | message = 'Supported only explicit type of HatiJsonapiError::BaseError, got: StandardError' 69 | 70 | expect do 71 | instance.render_error(StandardError) 72 | end.to raise_error(HatiJsonapiError::Errors::HelpersRenderError, message) 73 | end 74 | 75 | it 'raises HelpersRenderError for non-BaseError instance' do 76 | message = 'Supported only explicit type of HatiJsonapiError::BaseError, got: StandardError' 77 | 78 | expect do 79 | instance.render_error(StandardError.new) 80 | end.to raise_error(HatiJsonapiError::Errors::HelpersRenderError, message) 81 | end 82 | end 83 | end 84 | 85 | describe '#handle_error' do 86 | before do 87 | HatiJsonapiError::Registry.error_map = { 88 | StandardError => HatiJsonapiError::InternalServerError, 89 | ArgumentError => :bad_request 90 | } 91 | HatiJsonapiError::Registry.fallback = HatiJsonapiError::InternalServerError 92 | end 93 | 94 | it 'handles BaseError directly' do 95 | error = HatiJsonapiError::NotFound.new 96 | instance.handle_error(error) 97 | 98 | parsed_json = JSON.parse(instance.rendered_json) 99 | err_hash = { 'code' => 'not_found' } 100 | 101 | aggregate_failures 'rendered error' do 102 | expect(instance.rendered_status).to eq(404) 103 | expect(parsed_json['errors'].first).to include(err_hash) 104 | end 105 | end 106 | 107 | it 'maps standard errors to API errors' do 108 | instance.handle_error(StandardError.new) 109 | parsed_json = JSON.parse(instance.rendered_json) 110 | err_hash = { 'code' => 'internal_server_error' } 111 | 112 | aggregate_failures 'rendered error' do 113 | expect(instance.rendered_status).to eq(500) 114 | expect(parsed_json['errors'].first).to include(err_hash) 115 | end 116 | end 117 | 118 | it 'maps errors using symbol codes' do 119 | instance.handle_error(ArgumentError.new) 120 | parsed_json = JSON.parse(instance.rendered_json) 121 | err_hash = { 'code' => 'bad_request' } 122 | 123 | aggregate_failures 'rendered error' do 124 | expect(instance.rendered_status).to eq(400) 125 | expect(parsed_json['errors'].first).to include(err_hash) 126 | end 127 | end 128 | 129 | it 'raises error when no mapping found and no fallback set' do 130 | HatiJsonapiError::Registry.instance_variable_set(:@error_map, {}) 131 | HatiJsonapiError::Registry.instance_variable_set(:@fallback, nil) 132 | 133 | message = 'Used handle_error but no mapping of default error set' 134 | 135 | expect do 136 | instance.handle_error(StandardError.new) 137 | end.to raise_error(HatiJsonapiError::Errors::HelpersHandleError, message) 138 | end 139 | end 140 | 141 | describe HatiJsonapiError::Helpers::ApiErr do 142 | subject(:api_err) { described_class } 143 | 144 | before { HatiJsonapiError::Kigen.load_errors! } 145 | 146 | it 'returns error class by status code' do 147 | expect(api_err[404]).to eq(HatiJsonapiError::NotFound) 148 | end 149 | 150 | it 'returns error class by symbol' do 151 | expect(api_err[:not_found]).to eq(HatiJsonapiError::NotFound) 152 | end 153 | 154 | it 'raises error for unknown code' do 155 | message = 'Error unknown not defined on load_errors!. Check kigen.rb and api_error/error_const.rb' 156 | 157 | expect { api_err[:unknown] }.to raise_error(HatiJsonapiError::Errors::NotDefinedErrorClassError, message) 158 | end 159 | 160 | it 'raises error when Kigen not loaded' do 161 | allow(HatiJsonapiError::Kigen).to receive(:loaded?).and_return(false) 162 | message = 'HatiJsonapiError::Kigen not loaded' 163 | 164 | expect { api_err[404] }.to raise_error(HatiJsonapiError::Errors::NotLoadedError, message) 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /spec/unit/hati_jsonapi_error/resolver_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError::Resolver do 6 | let(:error_attrs) do 7 | { 8 | status: '404', 9 | title: 'Not Found', 10 | detail: 'Resource not found' 11 | } 12 | end 13 | 14 | let(:error_attrs_2) do 15 | { 16 | status: '422', 17 | title: 'Unprocessable Entity', 18 | detail: 'Validation failed' 19 | } 20 | end 21 | 22 | let(:mock_error) { double('Error', status: '404', **error_attrs) } 23 | let(:mock_error_2) { double('Error', status: '422', **error_attrs_2) } 24 | 25 | describe '#initialize' do 26 | context 'with default serializer' do 27 | it 'uses PoroSerializer by default' do 28 | resolver = described_class.new(mock_error) 29 | 30 | expect(resolver.serializer).to be_a(HatiJsonapiError::PoroSerializer) 31 | end 32 | 33 | it 'normalizes single error to array' do 34 | resolver = described_class.new(mock_error) 35 | 36 | expect(resolver.errors).to eq([mock_error]) 37 | end 38 | 39 | it 'keeps array of errors unchanged' do 40 | errors = [mock_error, mock_error_2] 41 | resolver = described_class.new(errors) 42 | 43 | expect(resolver.errors).to eq(errors) 44 | end 45 | end 46 | 47 | context 'with custom serializer' do 48 | let(:custom_serializer) do 49 | Class.new do 50 | def initialize(errors); end 51 | def serialize_to_json; end 52 | end 53 | end 54 | 55 | it 'uses provided serializer' do 56 | resolver = described_class.new(mock_error, serializer: custom_serializer) 57 | 58 | expect(resolver.serializer).to be_a(custom_serializer) 59 | end 60 | end 61 | end 62 | 63 | describe '#status' do 64 | it 'returns status of first error for single error' do 65 | resolver = described_class.new(mock_error) 66 | 67 | expect(resolver.status).to eq('404') 68 | end 69 | 70 | it 'returns status of first error for multiple errors' do 71 | resolver = described_class.new([mock_error, mock_error_2]) 72 | 73 | expect(resolver.status).to eq('404') 74 | end 75 | 76 | context 'with invalid error object' do 77 | it 'raises NoMethodError when error does not respond to status' do 78 | invalid_error = Object.new 79 | resolver = described_class.new(invalid_error) 80 | 81 | expect { resolver.status }.to raise_error(NoMethodError, /undefined method `status'/) 82 | end 83 | 84 | it 'raises NoMethodError when error is nil' do 85 | resolver = described_class.new(nil) 86 | 87 | expect { resolver.status }.to raise_error(NoMethodError, /undefined method `status'/) 88 | end 89 | end 90 | end 91 | 92 | describe '#to_json' do 93 | let(:expected_json) { '{"errors":[{"status":"404","title":"Not Found"}]}' } 94 | let(:mock_serializer) { instance_double(HatiJsonapiError::PoroSerializer) } 95 | 96 | before do 97 | allow(HatiJsonapiError::PoroSerializer).to receive(:new).and_return(mock_serializer) 98 | allow(mock_serializer).to receive(:serialize_to_json).and_return(expected_json) 99 | end 100 | 101 | it 'delegates to serializer' do 102 | resolver = described_class.new(mock_error) 103 | 104 | aggregate_failures 'delegation' do 105 | expect(resolver.to_json).to eq(expected_json) 106 | expect(mock_serializer).to have_received(:serialize_to_json) 107 | end 108 | end 109 | 110 | it 'ignores any arguments passed to to_json' do 111 | resolver = described_class.new(mock_error) 112 | 113 | expect(resolver.to_json(except: [:id])).to eq(expected_json) 114 | end 115 | end 116 | 117 | describe 'integration with PoroSerializer' do 118 | let(:error) do 119 | HatiJsonapiError::BaseError.new( 120 | status: '404', 121 | title: 'Not Found', 122 | detail: 'The requested resource was not found' 123 | ) 124 | end 125 | 126 | it 'correctly serializes error to JSON API' do 127 | resolver = described_class.new(error) 128 | result = JSON.parse(resolver.to_json, symbolize_names: true) 129 | 130 | expected_result = { 131 | errors: [ 132 | { 133 | id: '', 134 | code: '', 135 | status: '404', 136 | title: 'Not Found', 137 | detail: 'The requested resource was not found', 138 | source: {}, 139 | meta: {}, 140 | links: {} 141 | } 142 | ] 143 | } 144 | 145 | aggregate_failures 'format' do 146 | expect(result).to be_a(Hash) 147 | expect(result).to have_key(:errors) 148 | expect(result[:errors]).to be_an(Array) 149 | expect(result).to eq(expected_result) 150 | end 151 | end 152 | 153 | it 'correctly handles multiple errors' do 154 | error2 = HatiJsonapiError::BaseError.new( 155 | status: '422', 156 | title: 'Unprocessable Entity', 157 | detail: 'Validation failed' 158 | ) 159 | 160 | resolver = described_class.new([error, error2]) 161 | result = JSON.parse(resolver.to_json, symbolize_names: true) 162 | 163 | expected_result = { 164 | errors: [ 165 | { 166 | id: '', 167 | code: '', 168 | status: '404', 169 | title: 'Not Found', 170 | detail: 'The requested resource was not found', 171 | source: {}, 172 | meta: {}, 173 | links: {} 174 | }, 175 | { 176 | id: '', 177 | code: '', 178 | status: '422', 179 | title: 'Unprocessable Entity', 180 | detail: 'Validation failed', 181 | source: {}, 182 | meta: {}, 183 | links: {} 184 | } 185 | ] 186 | } 187 | 188 | aggregate_failures 'format' do 189 | expect(result).to be_a(Hash) 190 | expect(result).to have_key(:errors) 191 | expect(result[:errors]).to be_an(Array) 192 | expect(result).to eq(expected_result) 193 | end 194 | end 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /spec/unit/hati_jsonapi_error/api_error/base_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError::BaseError do 6 | let(:error_attrs) do 7 | { 8 | id: 'error_123', 9 | code: 'invalid_request', 10 | title: 'Invalid Request', 11 | detail: 'The request was invalid', 12 | status: '400', 13 | meta: { timestamp: '2024-01-01T00:00:00Z' }, 14 | links: { 15 | about: 'https://example.com/errors/invalid_request', 16 | type: 'error_documentation' 17 | }, 18 | source: { 19 | pointer: '/data/attributes/email', 20 | parameter: 'email', 21 | header: 'Authorization' 22 | } 23 | } 24 | end 25 | 26 | describe 'constants' do 27 | it 'defines STR as empty string' do 28 | expect(described_class::STR).to eq('') 29 | end 30 | 31 | it 'defines OBJ as frozen' do 32 | aggregate_failures 'empty hash' do 33 | expect(described_class::OBJ).to eq({}) 34 | expect(described_class::OBJ).to be_frozen 35 | end 36 | end 37 | end 38 | 39 | describe '#initialize' do 40 | context 'with all attributes' do 41 | subject(:error) { described_class.new(**error_attrs) } 42 | 43 | it 'sets all attributes correctly' do 44 | aggregate_failures 'attributes' do 45 | expect(error.id).to eq('error_123') 46 | expect(error.code).to eq('invalid_request') 47 | expect(error.title).to eq('Invalid Request') 48 | expect(error.detail).to eq('The request was invalid') 49 | expect(error.status).to eq('400') 50 | expect(error.meta).to eq(timestamp: '2024-01-01T00:00:00Z') 51 | end 52 | end 53 | 54 | it 'builds links object' do 55 | aggregate_failures 'links' do 56 | expect(error.links).to be_a(HatiJsonapiError::Links) 57 | expect(error.links.about).to eq('https://example.com/errors/invalid_request') 58 | expect(error.links.type).to eq('error_documentation') 59 | end 60 | end 61 | 62 | it 'builds source object' do 63 | aggregate_failures 'source' do 64 | expect(error.source).to be_a(HatiJsonapiError::Source) 65 | expect(error.source.pointer).to eq('/data/attributes/email') 66 | expect(error.source.parameter).to eq('email') 67 | expect(error.source.header).to eq('Authorization') 68 | end 69 | end 70 | end 71 | 72 | context 'with minimal attributes' do 73 | subject(:error) { described_class.new(title: 'Minimal Error') } 74 | 75 | it 'sets default values for missing attributes' do 76 | aggregate_failures 'defaults' do 77 | expect(error.id).to eq('') 78 | expect(error.code).to eq('') 79 | expect(error.detail).to eq('') 80 | expect(error.status).to eq('') 81 | expect(error.meta).to eq({}) 82 | expect(error.links).to eq({}) 83 | expect(error.source).to eq({}) 84 | end 85 | end 86 | end 87 | 88 | context 'with nil attributes' do 89 | subject(:error) do 90 | described_class.new( 91 | links: nil, 92 | source: nil, 93 | meta: nil 94 | ) 95 | end 96 | 97 | it 'handles nil values gracefully' do 98 | aggregate_failures 'nil handling' do 99 | expect(error.links).to eq({}) 100 | expect(error.source).to eq({}) 101 | expect(error.meta).to eq({}) 102 | end 103 | end 104 | end 105 | end 106 | 107 | describe '#to_h' do 108 | subject(:error) { described_class.new(**error_attrs) } 109 | 110 | it 'returns hash with all attributes' do 111 | result = error.to_h 112 | 113 | expect(result).to include( 114 | id: 'error_123', 115 | code: 'invalid_request', 116 | title: 'Invalid Request', 117 | detail: 'The request was invalid', 118 | status: '400', 119 | meta: { timestamp: '2024-01-01T00:00:00Z' } 120 | ) 121 | end 122 | 123 | it 'includes nested objects as hashes' do 124 | result = error.to_h 125 | 126 | aggregate_failures 'nested objects' do 127 | expect(result[:links]).to eq( 128 | about: 'https://example.com/errors/invalid_request', 129 | type: 'error_documentation' 130 | ) 131 | 132 | expect(result[:source]).to eq( 133 | pointer: '/data/attributes/email', 134 | parameter: 'email', 135 | header: 'Authorization' 136 | ) 137 | end 138 | end 139 | end 140 | 141 | describe '#to_s' do 142 | it 'returns string representation of full error hash' do 143 | error = described_class.new(**error_attrs) 144 | 145 | expect(error.to_s).to eq(error.to_h.to_s) 146 | end 147 | 148 | it 'includes all attributes in string representation' do 149 | error = described_class.new(title: 'Error Title', detail: 'Error Detail') 150 | 151 | aggregate_failures 'string representation' do 152 | expect(error.to_s).to include('Error Title') 153 | expect(error.to_s).to include('Error Detail') 154 | end 155 | end 156 | end 157 | 158 | describe '#serializable_hash' do 159 | subject(:error) { described_class.new(**error_attrs) } 160 | 161 | it 'returns same as to_h' do 162 | expect(error.serializable_hash).to eq(error.to_h) 163 | end 164 | end 165 | 166 | describe '#to_json' do 167 | subject(:error) { described_class.new(**error_attrs) } 168 | 169 | it 'returns valid JSON string' do 170 | json = error.to_json 171 | parsed = JSON.parse(json, symbolize_names: true) 172 | 173 | expect(parsed).to eq(error.to_h) 174 | end 175 | 176 | it 'ignores arguments passed to to_json' do 177 | json1 = error.to_json 178 | json2 = error.to_json(except: [:id]) 179 | 180 | expect(json1).to eq(json2) 181 | end 182 | end 183 | 184 | describe 'JSON:API compliance' do 185 | subject(:error) { described_class.new(**error_attrs) } 186 | 187 | it 'follows JSON:API error object structure' do 188 | result = error.to_h 189 | required_members = %i[id status code title detail source meta links] 190 | 191 | aggregate_failures 'structure' do 192 | expect(result.keys).to include(*required_members) 193 | expect(result[:source]).to include(:pointer, :parameter) 194 | expect(result[:links]).to include(:about) 195 | end 196 | end 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /spec/unit/hati_jsonapi_error/api_error/error_const_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError do 6 | describe 'Error Constants' do 7 | describe 'CLIENT errors' do 8 | it 'defines client error codes in 4xx range' do 9 | expect(described_class::CLIENT.keys).to all(be_between(400, 499)) 10 | end 11 | 12 | it 'has correct structure for each client error' do 13 | described_class::CLIENT.each do |status, error| 14 | aggregate_failures "client error #{status}" do 15 | expect(error).to be_a(Hash) 16 | expect(error.keys).to match_array(%i[name code message]) 17 | expect(error[:name]).to be_a(String) 18 | expect(error[:code]).to be_a(Symbol) 19 | expect(error[:message]).to be_a(String) 20 | end 21 | end 22 | end 23 | 24 | it 'includes common client errors' do 25 | common_errors = [400, 401, 403, 404, 422] 26 | 27 | common_errors.each do |status| 28 | expect(described_class::CLIENT).to have_key(status) 29 | end 30 | end 31 | 32 | it 'has consistent naming convention' do 33 | name_regex = /^[A-Z][a-zA-Z]+(?:[A-Z][a-zA-Z]+)*$/ 34 | code_regex = /^[a-z_]+$/ 35 | 36 | described_class::CLIENT.each do |_status, error| 37 | aggregate_failures "client error #{_status}" do 38 | expect(_status).to be_between(400, 499) 39 | expect(error).to be_a(Hash) 40 | expect(error.keys).to match_array(%i[name code message]) 41 | 42 | expect(error[:name]).to match(name_regex) 43 | expect(error[:code]).to match(code_regex) 44 | end 45 | end 46 | end 47 | 48 | it 'has consistent code format' do 49 | code_regex = /^[a-z_]+$/ 50 | 51 | described_class::CLIENT.each do |_status, error| 52 | expect(error[:code]).to match(code_regex) 53 | end 54 | end 55 | end 56 | 57 | describe 'SERVER errors' do 58 | it 'defines server error codes in 5xx range' do 59 | expect(described_class::SERVER.keys).to all(be_between(500, 599)) 60 | end 61 | 62 | it 'has correct structure for each server error' do 63 | described_class::SERVER.each do |status, error| 64 | aggregate_failures "server error #{status}" do 65 | expect(error).to be_a(Hash) 66 | expect(error.keys).to match_array(%i[name code message]) 67 | expect(error[:name]).to be_a(String) 68 | expect(error[:code]).to be_a(Symbol) 69 | expect(error[:message]).to be_a(String) 70 | end 71 | end 72 | end 73 | 74 | it 'includes common server errors' do 75 | common_errors = [500, 502, 503, 504] 76 | 77 | common_errors.each do |status| 78 | aggregate_failures "server error #{status}" do 79 | expect(status).to be_between(500, 599) 80 | expect(described_class::SERVER).to have_key(status) 81 | end 82 | end 83 | end 84 | 85 | it 'has consistent naming convention' do 86 | name_regex = /^[A-Z][a-zA-Z]+(?:[A-Z][a-zA-Z]+)*$/ 87 | 88 | described_class::SERVER.each_value do |error| 89 | expect(error[:name]).to match(name_regex) 90 | end 91 | end 92 | 93 | it 'has consistent code format' do 94 | code_regex = /^[a-z_]+$/ 95 | 96 | described_class::SERVER.each_value do |error| 97 | expect(error[:code]).to match(code_regex) 98 | end 99 | end 100 | end 101 | 102 | describe 'STATUS_MAP' do 103 | it 'combines CLIENT and SERVER errors' do 104 | # Get the original maps without any test modifications 105 | original_client = HatiJsonapiError::CLIENT 106 | original_server = HatiJsonapiError::SERVER 107 | expected_map = original_client.merge(original_server) 108 | 109 | # Compare keys and values separately since the map might have been modified 110 | expected_keys = expected_map.keys 111 | expected_values = expected_map.values 112 | 113 | # Filter out any test modifications (like key 999) 114 | actual_keys = described_class::STATUS_MAP.keys.select { |k| expected_keys.include?(k) } 115 | actual_values = described_class::STATUS_MAP.values_at(*expected_keys) 116 | 117 | aggregate_failures 'map structure' do 118 | expect(actual_keys).to match_array(expected_keys) 119 | expect(actual_values.compact).to match_array(expected_values) 120 | end 121 | end 122 | 123 | it 'has unique status codes' do 124 | status_codes = described_class::STATUS_MAP.keys 125 | 126 | expect(status_codes.uniq).to eq(status_codes) 127 | end 128 | 129 | it 'has unique error names' do 130 | error_names = described_class::STATUS_MAP.values.map { |error| error[:name] } 131 | 132 | expect(error_names.uniq).to eq(error_names) 133 | end 134 | 135 | it 'has unique error codes' do 136 | error_codes = described_class::STATUS_MAP.values.map { |error| error[:code] } 137 | 138 | expect(error_codes.uniq).to eq(error_codes) 139 | end 140 | end 141 | 142 | describe 'specific error examples' do 143 | it 'defines NotFound (404) correctly' do 144 | not_found = described_class::CLIENT[404] 145 | expected_attrs = { 146 | name: 'NotFound', 147 | code: :not_found, 148 | message: 'Not Found' 149 | } 150 | 151 | aggregate_failures 'not found error' do 152 | expect(not_found[:name]).to eq(expected_attrs[:name]) 153 | expect(not_found[:code]).to eq(expected_attrs[:code]) 154 | expect(not_found[:message]).to eq(expected_attrs[:message]) 155 | end 156 | end 157 | 158 | it 'defines InternalServerError (500) correctly' do 159 | internal_error = described_class::SERVER[500] 160 | expected_attrs = { 161 | name: 'InternalServerError', 162 | code: :internal_server_error, 163 | message: 'Internal Server Error' 164 | } 165 | 166 | aggregate_failures 'internal server error' do 167 | expect(internal_error[:name]).to eq(expected_attrs[:name]) 168 | expect(internal_error[:code]).to eq(expected_attrs[:code]) 169 | expect(internal_error[:message]).to eq(expected_attrs[:message]) 170 | end 171 | end 172 | 173 | it 'defines UnprocessableEntity (422) correctly' do 174 | unprocessable = described_class::CLIENT[422] 175 | 176 | expect(unprocessable).to eq( 177 | name: 'UnprocessableEntity', 178 | code: :unprocessable_entity, 179 | message: 'Unprocessable Entity' 180 | ) 181 | end 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /spec/unit/hati_jsonapi_error/poro_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiJsonapiError::PoroSerializer do 6 | let(:error_hash) do 7 | { 8 | id: 'test_error_1', 9 | status: '404', 10 | code: 'not_found', 11 | title: 'Not Found', 12 | detail: 'The requested resource was not found', 13 | source: { pointer: '/data/attributes/user_id' }, 14 | meta: { timestamp: '2024-01-01T00:00:00Z' }, 15 | links: { about: 'https://example.com/errors/404' } 16 | } 17 | end 18 | 19 | let(:error_hash_2) do 20 | { 21 | id: 'test_error_2', 22 | status: '422', 23 | code: 'unprocessable_entity', 24 | title: 'Unprocessable Entity', 25 | detail: 'Validation failed', 26 | source: { pointer: '/data/attributes/email' }, 27 | meta: { field: 'email' }, 28 | links: { about: 'https://example.com/errors/422' } 29 | } 30 | end 31 | 32 | let(:mock_error) { double('Error', to_h: error_hash) } 33 | let(:mock_error_2) { double('Error', to_h: error_hash_2) } 34 | 35 | describe '#initialize' do 36 | context 'with a single error' do 37 | it 'normalizes single error to array' do 38 | serializer = described_class.new(mock_error) 39 | 40 | expect(serializer.send(:errors)).to eq([mock_error]) 41 | end 42 | end 43 | 44 | context 'with an array of errors' do 45 | it 'keeps array as is' do 46 | errors = [mock_error, mock_error_2] 47 | serializer = described_class.new(errors) 48 | 49 | expect(serializer.send(:errors)).to eq(errors) 50 | end 51 | end 52 | 53 | context 'with empty array' do 54 | it 'accepts empty array' do 55 | serializer = described_class.new([]) 56 | 57 | expect(serializer.send(:errors)).to eq([]) 58 | end 59 | end 60 | end 61 | 62 | describe '#serializable_hash' do 63 | let(:serializer) { described_class.new(mock_error) } 64 | 65 | context 'with short: false (default)' do 66 | it 'returns full error hash with all keys' do 67 | result = serializer.serializable_hash 68 | 69 | expect(result).to eq(errors: [error_hash]) 70 | end 71 | 72 | it 'includes all error attributes' do 73 | result = serializer.serializable_hash(short: false) 74 | error = result[:errors].first 75 | 76 | expect(error).to eq(error_hash) 77 | end 78 | end 79 | 80 | context 'with short: true' do 81 | it 'returns only short keys' do 82 | result = serializer.serializable_hash(short: true) 83 | expected_result = { 84 | errors: [{ 85 | status: '404', 86 | title: 'Not Found', 87 | detail: 'The requested resource was not found', 88 | source: { pointer: '/data/attributes/user_id' } 89 | }] 90 | } 91 | 92 | expect(result).to eq(expected_result) 93 | end 94 | 95 | it 'excludes non-short keys' do 96 | result = serializer.serializable_hash(short: true) 97 | error = result[:errors].first 98 | 99 | expect(error).not_to include(:id, :code, :meta, :links) 100 | expect(error.keys).to match_array(%i[status title detail source]) 101 | end 102 | end 103 | 104 | context 'with multiple errors' do 105 | let(:serializer) { described_class.new([mock_error, mock_error_2]) } 106 | 107 | it 'serializes all errors in full mode' do 108 | result = serializer.serializable_hash 109 | 110 | expect(result[:errors]).to eq([error_hash, error_hash_2]) 111 | end 112 | 113 | it 'serializes all errors in short mode' do 114 | result = serializer.serializable_hash(short: true) 115 | expected_result = { 116 | errors: [ 117 | { 118 | status: '404', 119 | title: 'Not Found', 120 | detail: 'The requested resource was not found', 121 | source: { pointer: '/data/attributes/user_id' } 122 | }, 123 | { 124 | status: '422', 125 | title: 'Unprocessable Entity', 126 | detail: 'Validation failed', 127 | source: { pointer: '/data/attributes/email' } 128 | } 129 | ] 130 | } 131 | 132 | expect(result).to eq(expected_result) 133 | end 134 | end 135 | end 136 | 137 | describe '#serialize_to_json' do 138 | let(:serializer) { described_class.new(mock_error) } 139 | 140 | context 'with short: false (default)' do 141 | it 'returns valid JSON string with full error data' do 142 | result = serializer.serialize_to_json 143 | expected_result = { errors: [error_hash] } 144 | parsed = JSON.parse(result, symbolize_names: true) 145 | 146 | expect(parsed).to eq(expected_result) 147 | end 148 | end 149 | 150 | context 'with short: true' do 151 | it 'returns valid JSON string with short error data' do 152 | result = serializer.serialize_to_json(short: true) 153 | expected_result = { 154 | errors: [{ 155 | status: '404', 156 | title: 'Not Found', 157 | detail: 'The requested resource was not found', 158 | source: { pointer: '/data/attributes/user_id' } 159 | }] 160 | } 161 | parsed = JSON.parse(result, symbolize_names: true) 162 | 163 | expect(parsed).to eq(expected_result) 164 | end 165 | end 166 | 167 | context 'with multiple errors' do 168 | let(:serializer) { described_class.new([mock_error, mock_error_2]) } 169 | 170 | it 'returns JSON with array of errors' do 171 | result = serializer.serialize_to_json 172 | expected_result = { errors: [error_hash, error_hash_2] } 173 | parsed = JSON.parse(result, symbolize_names: true) 174 | expect(parsed).to eq(expected_result) 175 | end 176 | end 177 | 178 | context 'with empty errors array' do 179 | let(:serializer) { described_class.new([]) } 180 | 181 | it 'returns JSON with empty errors array' do 182 | result = serializer.serialize_to_json 183 | expected_result = { errors: [] } 184 | parsed = JSON.parse(result, symbolize_names: true) 185 | expect(parsed).to eq(expected_result) 186 | end 187 | end 188 | end 189 | 190 | describe 'SHORT_KEYS constant' do 191 | it 'contains expected keys' do 192 | expect(described_class::SHORT_KEYS).to eq(%i[status title detail source]) 193 | end 194 | 195 | it 'is frozen' do 196 | expect(described_class::SHORT_KEYS).to be_frozen 197 | end 198 | end 199 | 200 | describe 'JSON:API compliance' do 201 | let(:serializer) { described_class.new(mock_error) } 202 | 203 | it 'wraps errors in errors array as per JSON:API spec' do 204 | result = serializer.serializable_hash 205 | 206 | expect(result).to have_key(:errors) 207 | expect(result[:errors]).to be_an(Array) 208 | end 209 | 210 | it 'maintains JSON:API error object structure' do 211 | result = serializer.serializable_hash 212 | error = result[:errors].first 213 | json_api_members = %i[id links status code title detail source meta] 214 | 215 | expect(error.keys - json_api_members).to be_empty 216 | end 217 | end 218 | 219 | describe 'private methods' do 220 | describe '#normalized_errors' do 221 | let(:serializer) { described_class.new(mock_error) } 222 | 223 | it 'converts single error to array' do 224 | result = serializer.send(:normalized_errors, mock_error) 225 | 226 | expect(result).to eq([mock_error]) 227 | end 228 | 229 | it 'keeps array unchanged' do 230 | errors = [mock_error, mock_error_2] 231 | result = serializer.send(:normalized_errors, errors) 232 | 233 | expect(result).to eq(errors) 234 | end 235 | end 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hati JSON:API Error 2 | 3 | [![Gem Version](https://badge.fury.io/rb/hati-jsonapi-error.svg)](https://badge.fury.io/rb/hati-jsonapi-error) 4 | [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.0.0-ruby.svg)](https://ruby-lang.org) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | [![Test Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/hackico-ai/ruby-hati-jsonapi-error) 7 | 8 | > **Production-ready JSON:API-compliant error responses for professional Web APIs** 9 | 10 | Transform inconsistent error handling into standardized, traceable responses. Built for Ruby applications requiring enterprise-grade error management. 11 | 12 | ## Table of Contents 13 | 14 | - [Why Standardized Error Handling Matters](#why-standardized-error-handling-matters) 15 | - [The Problem: Inconsistent Error Responses](#the-problem-inconsistent-error-responses) 16 | - [The Impact](#the-impact) 17 | - [The Solution: JSON:API Standard](#the-solution-jsonapi-standard) 18 | - [✨ Features](#-features) 19 | - [Installation](#installation) 20 | - [Quick Start](#quick-start) 21 | - [1. Configuration](#1-configuration) 22 | - [2. Basic Usage](#2-basic-usage) 23 | - [Usage Examples](#usage-examples) 24 | - [Basic Error Handling](#basic-error-handling) 25 | - [Rich Error Context](#rich-error-context) 26 | - [Multiple Validation Errors](#multiple-validation-errors) 27 | - [Controller Integration](#controller-integration) 28 | - [Custom Error Classes](#custom-error-classes) 29 | - [Functional Programming Integration](#functional-programming-integration) 30 | - [Configuration](#configuration) 31 | - [Error Mapping](#error-mapping) 32 | - [Available Error Classes](#available-error-classes) 33 | - [Testing](#testing) 34 | - [RSpec Integration](#rspec-integration) 35 | - [Unit Testing](#unit-testing) 36 | - [Benefits](#benefits) 37 | - [Contributing](#contributing) 38 | - [License](#license) 39 | 40 | ## Why Standardized Error Handling Matters 41 | 42 | ### The Problem: Inconsistent Error Responses 43 | 44 | Different controllers returning different error formats creates maintenance nightmares: 45 | 46 | ```ruby 47 | # Three different error formats in one application 48 | class UsersController 49 | def show 50 | render json: { error: "User not found" }, status: 404 51 | end 52 | end 53 | 54 | class OrdersController 55 | def create 56 | render json: { message: "Validation failed", details: errors }, status: 422 57 | end 58 | end 59 | 60 | class PaymentsController 61 | def process 62 | render json: { errors: errors, error_code: "INVALID", status: "failure" }, status: 400 63 | end 64 | end 65 | ``` 66 | 67 | This forces frontend developers to handle multiple error formats: 68 | 69 | ```javascript 70 | // Unmaintainable error handling 71 | if (data.error) { 72 | showError(data.error); // Users format 73 | } else if (data.message && data.details) { 74 | showError(`${data.message}: ${data.details.join(", ")}`); // Orders format 75 | } else if (data.errors && data.error_code) { 76 | showError(`${data.error_code}: ${data.errors.join(", ")}`); // Payments format 77 | } 78 | ``` 79 | 80 | ### The Impact 81 | 82 | - **API Documentation**: Each endpoint needs custom error documentation 83 | - **Error Tracking**: Different structures break centralized logging 84 | - **Client SDKs**: Cannot provide consistent error handling 85 | - **Testing**: Each format requires separate test cases 86 | - **Team Coordination**: New developers must learn multiple patterns 87 | 88 | ### The Solution: JSON:API Standard 89 | 90 | **One format across all endpoints:** 91 | 92 | ```ruby 93 | raise HatiJsonapiError::UnprocessableEntity.new( 94 | detail: "Email address is required", 95 | source: { pointer: "/data/attributes/email" } 96 | ) 97 | ``` 98 | 99 | **Always produces standardized output:** 100 | 101 | ```json 102 | { 103 | "errors": [ 104 | { 105 | "status": 422, 106 | "code": "unprocessable_entity", 107 | "title": "Validation Failed", 108 | "detail": "Email address is required", 109 | "source": { "pointer": "/data/attributes/email" } 110 | } 111 | ] 112 | } 113 | ``` 114 | 115 | ## ✨ Features 116 | 117 | - **JSON:API Compliant** - Follows the official [JSON:API error specification](https://jsonapi.org/format/#errors) 118 | - **Auto-Generated Error Classes** - Dynamic HTTP status code error classes (400-511) 119 | - **Rich Error Context** - Support for `id`, `code`, `title`, `detail`, `status`, `meta`, `links`, `source` 120 | - **Error Registry** - Map custom exceptions to standardized responses 121 | - **Controller Integration** - Helper methods for Rails, Sinatra, and other frameworks 122 | - **100% Test Coverage** - Comprehensive RSpec test suite 123 | - **Zero Dependencies** - Lightweight and fast 124 | - **Production Ready** - Thread-safe and memory efficient 125 | 126 | ## Installation 127 | 128 | ```ruby 129 | # Gemfile 130 | gem 'hati-jsonapi-error' 131 | ``` 132 | 133 | ```bash 134 | bundle install 135 | ``` 136 | 137 | ## Quick Start 138 | 139 | ### 1. Configuration 140 | 141 | ```ruby 142 | # config/initializers/hati_jsonapi_error.rb 143 | HatiJsonapiError::Config.configure do |config| 144 | config.load_errors! 145 | 146 | config.map_errors = { 147 | ActiveRecord::RecordNotFound => :not_found, 148 | ActiveRecord::RecordInvalid => :unprocessable_entity, 149 | ArgumentError => :bad_request 150 | } 151 | 152 | config.use_unexpected = HatiJsonapiError::InternalServerError 153 | end 154 | ``` 155 | 156 | ### 2. Basic Usage 157 | 158 | ```ruby 159 | # Simple error raising 160 | raise HatiJsonapiError::NotFound.new 161 | raise HatiJsonapiError::BadRequest.new 162 | raise HatiJsonapiError::Unauthorized.new 163 | ``` 164 | 165 | ## Usage Examples 166 | 167 | ### Basic Error Handling 168 | 169 | **Access errors multiple ways:** 170 | 171 | ```ruby 172 | # By class name 173 | raise HatiJsonapiError::NotFound.new 174 | 175 | # By status code 176 | api_err = HatiJsonapiError::Helpers::ApiErr 177 | raise api_err[404] 178 | 179 | # By error code 180 | raise api_err[:not_found] 181 | ``` 182 | 183 | ### Rich Error Context 184 | 185 | **Add debugging information:** 186 | 187 | ```ruby 188 | HatiJsonapiError::NotFound.new( 189 | id: 'user_lookup_failed', 190 | detail: 'User with email john@example.com was not found', 191 | source: { pointer: '/data/attributes/email' }, 192 | meta: { 193 | searched_email: 'john@example.com', 194 | suggestion: 'Verify the email address is correct' 195 | } 196 | ) 197 | ``` 198 | 199 | ### Multiple Validation Errors 200 | 201 | **Collect and return multiple errors:** 202 | 203 | ```ruby 204 | errors = [] 205 | errors << HatiJsonapiError::UnprocessableEntity.new( 206 | detail: "Email format is invalid", 207 | source: { pointer: '/data/attributes/email' } 208 | ) 209 | errors << HatiJsonapiError::UnprocessableEntity.new( 210 | detail: "Password too short", 211 | source: { pointer: '/data/attributes/password' } 212 | ) 213 | 214 | resolver = HatiJsonapiError::Resolver.new(errors) 215 | render json: resolver.to_json, status: resolver.status 216 | ``` 217 | 218 | ## Controller Integration 219 | 220 | ```ruby 221 | class ApiController < ApplicationController 222 | include HatiJsonapiError::Helpers 223 | 224 | rescue_from StandardError, with: :handle_error 225 | 226 | def show 227 | # ActiveRecord::RecordNotFound automatically mapped to JSON:API NotFound 228 | user = User.find(params[:id]) 229 | render json: user 230 | end 231 | 232 | def create 233 | user = User.new(user_params) 234 | 235 | unless user.save 236 | validation_error = HatiJsonapiError::UnprocessableEntity.new( 237 | detail: user.errors.full_messages.join(', '), 238 | source: { pointer: '/data/attributes' }, 239 | meta: { validation_errors: user.errors.messages } 240 | ) 241 | 242 | return render_error(validation_error) 243 | end 244 | 245 | render json: user, status: :created 246 | end 247 | end 248 | ``` 249 | 250 | ### Custom Error Classes 251 | 252 | **Domain-specific errors:** 253 | 254 | ```ruby 255 | class PaymentRequiredError < HatiJsonapiError::PaymentRequired 256 | def initialize(amount:, currency: 'USD') 257 | super( 258 | detail: "Payment of #{amount} #{currency} required", 259 | meta: { 260 | required_amount: amount, 261 | currency: currency, 262 | payment_methods: ['credit_card', 'paypal'] 263 | }, 264 | links: { 265 | payment_page: "https://app.com/billing/upgrade?amount=#{amount}" 266 | } 267 | ) 268 | end 269 | end 270 | 271 | # Usage 272 | raise PaymentRequiredError.new(amount: 29.99) 273 | ``` 274 | 275 | ## Functional Programming Integration 276 | 277 | Perfect for functional programming patterns with [hati-operation gem](https://github.com/hackico-ai/ruby-hati-operation): 278 | 279 | ```ruby 280 | require 'hati_operation' 281 | 282 | class Api::User::CreateOperation < Hati::Operation 283 | ApiErr = HatiJsonapiError::Helpers::ApiErr 284 | 285 | def call(params) 286 | user_params = step validate_params(params), err: ApiErr[422] 287 | user = step create_user(user_params), err: ApiErr[409] 288 | profile = step create_profile(user), err: ApiErr[503] 289 | 290 | Success(profile) 291 | end 292 | 293 | private 294 | 295 | def validate_params(params) 296 | return Failure('Invalid parameters') unless params[:name] 297 | Success(params) 298 | end 299 | end 300 | ``` 301 | 302 | ## Configuration 303 | 304 | ### Error Mapping 305 | 306 | ```ruby 307 | HatiJsonapiError::Config.configure do |config| 308 | config.map_errors = { 309 | # Rails exceptions 310 | ActiveRecord::RecordNotFound => :not_found, 311 | ActiveRecord::RecordInvalid => :unprocessable_entity, 312 | 313 | # Custom exceptions 314 | AuthenticationError => :unauthorized, 315 | RateLimitError => :too_many_requests, 316 | 317 | # Infrastructure exceptions 318 | Redis::TimeoutError => :service_unavailable, 319 | Net::ReadTimeout => :gateway_timeout 320 | } 321 | 322 | config.use_unexpected = HatiJsonapiError::InternalServerError 323 | end 324 | ``` 325 | 326 | ## Available Error Classes 327 | 328 | **Quick Reference - Most Common:** 329 | 330 | | Status | Class | Code | 331 | | ------ | --------------------- | ----------------------- | 332 | | 400 | `BadRequest` | `bad_request` | 333 | | 401 | `Unauthorized` | `unauthorized` | 334 | | 403 | `Forbidden` | `forbidden` | 335 | | 404 | `NotFound` | `not_found` | 336 | | 422 | `UnprocessableEntity` | `unprocessable_entity` | 337 | | 429 | `TooManyRequests` | `too_many_requests` | 338 | | 500 | `InternalServerError` | `internal_server_error` | 339 | | 502 | `BadGateway` | `bad_gateway` | 340 | | 503 | `ServiceUnavailable` | `service_unavailable` | 341 | 342 | **[Complete list of all 39 HTTP status codes →](HTTP_STATUS_CODES.md)** 343 | 344 | ## Testing 345 | 346 | ### RSpec Integration 347 | 348 | ```ruby 349 | # Shared examples for JSON:API compliance 350 | RSpec.shared_examples 'JSON:API error response' do |expected_status, expected_code| 351 | it 'returns proper JSON:API error format' do 352 | json = JSON.parse(response.body) 353 | 354 | expect(response).to have_http_status(expected_status) 355 | expect(json['errors'].first['status']).to eq(expected_status) 356 | expect(json['errors'].first['code']).to eq(expected_code) 357 | end 358 | end 359 | 360 | # Usage in specs 361 | describe 'GET #show' do 362 | context 'when user not found' do 363 | subject { get :show, params: { id: 'nonexistent' } } 364 | include_examples 'JSON:API error response', 404, 'not_found' 365 | end 366 | end 367 | ``` 368 | 369 | ### Unit Testing 370 | 371 | ```ruby 372 | RSpec.describe HatiJsonapiError::NotFound do 373 | it 'has correct default attributes' do 374 | error = described_class.new 375 | 376 | expect(error.status).to eq(404) 377 | expect(error.code).to eq(:not_found) 378 | expect(error.to_h[:title]).to eq('Not Found') 379 | end 380 | end 381 | ``` 382 | 383 | ## Benefits 384 | 385 | **For Development Teams:** 386 | 387 | - Reduced development time with single error pattern 388 | - Easier onboarding for new developers 389 | - Better testing with standardized structure 390 | - Improved debugging with consistent error tracking 391 | 392 | **For Frontend/Mobile Teams:** 393 | 394 | - One error parser for entire API 395 | - Rich error context for better user experience 396 | - Easier SDK development 397 | 398 | **For Operations:** 399 | 400 | - Centralized monitoring and alerting 401 | - Consistent error analysis 402 | - Simplified documentation 403 | 404 | ## Contributing 405 | 406 | ```bash 407 | git clone https://github.com/hackico-ai/ruby-hati-jsonapi-error.git 408 | cd ruby-hati-jsonapi-error 409 | bundle install 410 | bundle exec rspec 411 | ``` 412 | 413 | ## License 414 | 415 | MIT License - see [LICENSE](LICENSE) file. 416 | 417 | --- 418 | 419 | **Professional error handling for professional APIs** 420 | --------------------------------------------------------------------------------