diff --git a/CHANGELOG.md b/CHANGELOG.md index 1991a9f..7508a8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The "Signs And Portents" Update Added `Cuprum.initializer` to initialize dependencies and error messages. Call `Cuprum.initializer.call` in the entry point or initializers of your application to avoid missing error messages. +### Errors + +Errors now support defining messages using `SleepingKingStudios::Tools::Messages` or by defining a `:MESSAGE` constant, including support for named parameters (see `Kernel#format`). + ### Results Added `Result.success` and `Result.failure`, which provide singleton results with respective `:success` and `:failure` statuses. diff --git a/Gemfile b/Gemfile index 491bbc1..c4ea6ff 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,9 @@ gemspec gem 'sleeping_king_studios-tasks', '~> 0.4', '>= 0.4.1' +gem 'sleeping_king_studios-tools', + git: 'https://github.com/sleepingkingstudios/sleeping_king_studios-tools' + group :development, :test do gem 'byebug', '~> 12.0' gem 'irb', '~> 1.16' diff --git a/config/messages.yml b/config/messages.yml new file mode 100644 index 0000000..6162944 --- /dev/null +++ b/config/messages.yml @@ -0,0 +1,7 @@ +--- +cuprum: + errors: + command_not_implemented: 'no implementation defined for %s' + invalid_parameters: 'invalid parameters for %s - %s' + multiple_errors: 'the command encountered one or more errors' + operation_not_called: '%s was not called and does not have a result' diff --git a/cuprum.gemspec b/cuprum.gemspec index 28e5a93..9306c8e 100644 --- a/cuprum.gemspec +++ b/cuprum.gemspec @@ -27,7 +27,12 @@ Gem::Specification.new do |gem| gem.required_ruby_version = ['>= 3.1', '< 5'] gem.require_path = 'lib' - gem.files = Dir['lib/**/*.rb', 'LICENSE', '*.md'] + gem.files = Dir[ + 'config/**/*.yml', + 'lib/**/*.rb', + 'LICENSE', + '*.md' + ] gem.add_runtime_dependency 'sleeping_king_studios-tools', '~> 1.3' end diff --git a/docs/_constants/cuprum/version/minor.yml b/docs/_constants/cuprum/version/minor.yml index 8545a51..898cc36 100644 --- a/docs/_constants/cuprum/version/minor.yml +++ b/docs/_constants/cuprum/version/minor.yml @@ -3,7 +3,7 @@ name: Cuprum::Version::MINOR parent_path: cuprum/version slug: minor short_description: Minor version. -value: '3' +value: '4' data_path: cuprum/version/minor metadata: api: private diff --git a/docs/_constants/cuprum/version/patch.yml b/docs/_constants/cuprum/version/patch.yml index 6a79711..c4ec0a8 100644 --- a/docs/_constants/cuprum/version/patch.yml +++ b/docs/_constants/cuprum/version/patch.yml @@ -3,7 +3,7 @@ name: Cuprum::Version::PATCH parent_path: cuprum/version slug: patch short_description: Patch version. -value: '1' +value: '0' data_path: cuprum/version/patch metadata: api: private diff --git a/docs/_constants/cuprum/version/prerelease.yml b/docs/_constants/cuprum/version/prerelease.yml index b9089e9..be743f9 100644 --- a/docs/_constants/cuprum/version/prerelease.yml +++ b/docs/_constants/cuprum/version/prerelease.yml @@ -3,7 +3,7 @@ name: Cuprum::Version::PRERELEASE parent_path: cuprum/version slug: prerelease short_description: Prerelease version. -value: nil +value: ":alpha" data_path: cuprum/version/prerelease metadata: api: private diff --git a/docs/_methods/cuprum/c-gem-path.yml b/docs/_methods/cuprum/c-gem-path.yml new file mode 100644 index 0000000..a8b46cb --- /dev/null +++ b/docs/_methods/cuprum/c-gem-path.yml @@ -0,0 +1,12 @@ +--- +name: Cuprum.gem_path +parent_path: cuprum +signature: gem_path +slug: gem-path +constructor: false +data_path: cuprum/c-gem-path +returns: +- description: the absolute path to the gem directory. + type: + - name: String +version: "*" diff --git a/docs/_modules/cuprum.yml b/docs/_modules/cuprum.yml index 3d042a3..4542b23 100644 --- a/docs/_modules/cuprum.yml +++ b/docs/_modules/cuprum.yml @@ -36,6 +36,10 @@ class_attributes: slug: initializer inherited: false class_methods: +- name: gem_path + path: cuprum/c-gem-path + slug: gem-path + inherited: false - name: version path: cuprum/c-version slug: version diff --git a/lib/cuprum.rb b/lib/cuprum.rb index 71ec41d..6be9e40 100644 --- a/lib/cuprum.rb +++ b/lib/cuprum.rb @@ -8,6 +8,7 @@ module Cuprum autoload :CommandFactory, 'cuprum/command_factory' autoload :Currying, 'cuprum/currying' autoload :Error, 'cuprum/error' + autoload :Errors, 'cuprum/errors' autoload :ExceptionHandling, 'cuprum/exception_handling' autoload :MapCommand, 'cuprum/map_command' autoload :Matcher, 'cuprum/matcher' @@ -20,6 +21,13 @@ module Cuprum @initializer = SleepingKingStudios::Tools::Toolbox::Initializer.new do SleepingKingStudios::Tools.initializer.call + + SleepingKingStudios::Tools::Messages::Registry + .global + .register( + file: File.join(Cuprum.gem_path, 'config', 'messages.yml'), + scope: 'cuprum.errors' + ) end class << self @@ -27,6 +35,14 @@ class << self # for the module. attr_reader :initializer + # @return [String] the absolute path to the gem directory. + def gem_path + sep = File::SEPARATOR + pattern = /#{sep}lib#{sep}?\z/ + + __dir__.sub(pattern, '') + end + # @return [String] the current version of the gem. def version VERSION diff --git a/lib/cuprum/error.rb b/lib/cuprum/error.rb index f4e5f1a..8a2438e 100644 --- a/lib/cuprum/error.rb +++ b/lib/cuprum/error.rb @@ -63,8 +63,8 @@ class Error # @param properties [Hash] Additional properties used to compare errors. # @param type [String] Short string used to identify the type of error. def initialize(message: nil, type: nil, **properties) - @message = message - @type = type || self.class::TYPE + @type = type || self.class::TYPE + @message = message || default_message_for(**properties) @comparable_properties = properties.merge(message:, type:) end @@ -109,5 +109,25 @@ def as_json def as_json_data {} end + + def default_message_for(**parameters) + if type && !(type.respond_to?(:empty?) && type.empty?) + message = tools.messages.message(type, default: nil, parameters:) + + return message if message + end + + format_default_message(parameters) + end + + def format_default_message(parameters) + return unless self.class.const_defined?(:MESSAGE) + + format(self.class::MESSAGE, parameters) + rescue KeyError => exception + "Message missing parameters: #{type} #{exception.message}" + end + + def tools = SleepingKingStudios::Tools::Toolbelt.instance end end diff --git a/lib/cuprum/errors/command_not_implemented.rb b/lib/cuprum/errors/command_not_implemented.rb index c8989ad..02e9327 100644 --- a/lib/cuprum/errors/command_not_implemented.rb +++ b/lib/cuprum/errors/command_not_implemented.rb @@ -9,9 +9,6 @@ class CommandNotImplemented < Cuprum::Error COMPARABLE_PROPERTIES = %i[command].freeze private_constant :COMPARABLE_PROPERTIES - MESSAGE_FORMAT = 'no implementation defined for %s' - private_constant :MESSAGE_FORMAT - # Short string used to identify the type of error. TYPE = 'cuprum.errors.command_not_implemented' @@ -20,9 +17,8 @@ def initialize(command:) @command = command class_name = command&.class&.name || 'command' - message = MESSAGE_FORMAT % class_name - super(command:, message:) + super(class_name:, command:) end # @return [Cuprum::Command] The command called without a definition. diff --git a/lib/cuprum/errors/invalid_parameters.rb b/lib/cuprum/errors/invalid_parameters.rb index a179767..29d6937 100644 --- a/lib/cuprum/errors/invalid_parameters.rb +++ b/lib/cuprum/errors/invalid_parameters.rb @@ -12,13 +12,16 @@ class InvalidParameters < Cuprum::Error # @param command_class [Class] the class of the failed command. # @param failures [Array] the messages for the failed validations. def initialize(command_class:, failures:) - @command_class = command_class - @failures = failures + @command_class = command_class + @failures = failures + class_name = command_class.name + failure_messages = failures.join(', ') super( + class_name:, command_class:, failures:, - message: generate_message + failure_messages: ) end @@ -36,9 +39,5 @@ def as_json_data 'failures' => failures.map(&:to_s) } end - - def generate_message - "invalid parameters for #{command_class.name} - #{failures.join(', ')}" - end end end diff --git a/lib/cuprum/errors/multiple_errors.rb b/lib/cuprum/errors/multiple_errors.rb index 5db14ee..4eb0a23 100644 --- a/lib/cuprum/errors/multiple_errors.rb +++ b/lib/cuprum/errors/multiple_errors.rb @@ -15,10 +15,7 @@ class MultipleErrors < Cuprum::Error def initialize(errors:, message: nil) @errors = errors - super( - errors:, - message: message || default_message - ) + super end # @return [Array] the wrapped errors. @@ -31,9 +28,5 @@ def as_json_data 'errors' => errors.map { |error| error&.as_json } } end - - def default_message - 'the command encountered one or more errors' - end end end diff --git a/lib/cuprum/errors/operation_not_called.rb b/lib/cuprum/errors/operation_not_called.rb index 91fc08a..147acd7 100644 --- a/lib/cuprum/errors/operation_not_called.rb +++ b/lib/cuprum/errors/operation_not_called.rb @@ -6,20 +6,15 @@ module Cuprum::Errors # Error returned when trying to access the result of an uncalled Operation. class OperationNotCalled < Cuprum::Error - MESSAGE_FORMAT = '%s was not called and does not have a result' - private_constant :MESSAGE_FORMAT - # Short string used to identify the type of error. TYPE = 'cuprum.errors.operation_not_called' # @param operation [Cuprum::Operation] The uncalled operation. def initialize(operation:) @operation = operation - class_name = operation&.class&.name || 'operation' - message = MESSAGE_FORMAT % class_name - super(message:, operation:) + super(class_name:, message:, operation:) end # @return [Cuprum::Operation] The uncalled operation. diff --git a/lib/cuprum/version.rb b/lib/cuprum/version.rb index 86abe7e..7c57df1 100644 --- a/lib/cuprum/version.rb +++ b/lib/cuprum/version.rb @@ -10,11 +10,11 @@ module Version # Major version. MAJOR = 1 # Minor version. - MINOR = 3 + MINOR = 4 # Patch version. - PATCH = 1 + PATCH = 0 # Prerelease version. - PRERELEASE = nil + PRERELEASE = :alpha # Build metadata. BUILD = nil diff --git a/spec/cuprum/error_spec.rb b/spec/cuprum/error_spec.rb index d765b1d..7e66090 100644 --- a/spec/cuprum/error_spec.rb +++ b/spec/cuprum/error_spec.rb @@ -11,6 +11,10 @@ let(:properties) { {} } let(:type) { nil } + define_method :tools do + SleepingKingStudios::Tools::Toolbelt.instance + end + describe '::TYPE' do include_examples 'should define immutable constant', :TYPE, @@ -421,6 +425,27 @@ end describe '#message' do + deferred_context 'when there are custom error messages defined' do + let(:messages) do + { + spec: { + custom_error: 'This is a custom error message', + example_error: 'This is an example error message', + locale_error: 'Locale not found with key %s' + } + } + end + let(:registry) do + SleepingKingStudios::Tools::Messages::Registry + .new + .register(hash: messages, scope: 'spec') + end + + before(:example) do + allow(tools.messages).to receive(:registry).and_return(registry) + end + end + include_examples 'should have reader', :message, nil context 'when initialized with no arguments' do @@ -434,6 +459,135 @@ it { expect(error.message).to be == message } end + + context 'when initialized with a type' do + let(:type) { 'spec.custom_error' } + + it { expect(error.message).to be nil } + + wrap_deferred 'when there are custom error messages defined' do + let(:expected) { 'This is a custom error message' } + + it { expect(error.message).to be == expected } + + context 'when initialized with a message' do + let(:message) { 'Something went wrong.' } + + it { expect(error.message).to be == message } + end + + context 'when the defined message requires parameters' do + let(:type) { 'spec.locale_error' } + let(:expected) do + 'Message missing parameters: spec.locale_error key not ' \ + 'found' + end + + it { expect(error.message).to be == expected } + + describe 'with the required parameters' do # rubocop:disable RSpec/NestedGroups + let(:properties) { super().merge(locale: 'en') } + let(:expected) { 'Locale not found with key en' } + + it { expect(error.message).to be == expected } + end + end + end + end + + context 'when there is an error subclass' do + let(:described_class) { Spec::ExampleError } + + example_class 'Spec::ExampleError', described_class do |klass| + klass.const_set :TYPE, 'spec.example_error' + end + + it { expect(error.message).to be nil } + + context 'when the subclass defines :MESSAGE' do + let(:message_template) do + 'This is a static error message' + end + let(:expected) { message_template } + + before(:example) do + Spec::ExampleError.const_set(:MESSAGE, message_template) + end + + it { expect(error.message).to be == expected } + + context 'when initialized with a message' do + let(:message) { 'Something went wrong.' } + + it { expect(error.message).to be == message } + end + + wrap_deferred 'when there are custom error messages defined' do + let(:expected) { 'This is an example error message' } + + it { expect(error.message).to be == expected } + end + + context 'when the message template takes parameters' do + let(:message_template) do + 'Locale not found with key %s' + end + let(:expected) do + 'Message missing parameters: spec.example_error key not ' \ + 'found' + end + + it { expect(error.message).to be == expected } + + describe 'with the required parameters' do # rubocop:disable RSpec/NestedGroups + let(:properties) { super().merge(locale: 'en') } + let(:expected) { 'Locale not found with key en' } + + it { expect(error.message).to be == expected } + end + end + end + + wrap_deferred 'when there are custom error messages defined' do + let(:expected) { 'This is an example error message' } + + it { expect(error.message).to be == expected } + + context 'when initialized with a message' do + let(:message) { 'Something went wrong.' } + + it { expect(error.message).to be == message } + end + + context 'when initialized with a type' do + let(:type) { 'spec.custom_error' } + let(:expected) { 'This is a custom error message' } + + it { expect(error.message).to be == expected } + end + + context 'when the defined message requires parameters' do + let(:described_class) { Spec::LocaleError } + let(:expected) do + 'Message missing parameters: spec.locale_error key not ' \ + 'found' + end + + example_class 'Spec::LocaleError', described_class do |klass| + klass.const_set :TYPE, 'spec.locale_error' + end + + it { expect(error.message).to be == expected } + + describe 'with the required parameters' do # rubocop:disable RSpec/NestedGroups + let(:properties) { super().merge(locale: 'en') } + let(:expected) { 'Locale not found with key en' } + + it { expect(error.message).to be == expected } + end + end + end + end end describe '#type' do @@ -446,7 +600,9 @@ end context 'when there is an error subclass' do - example_class 'Spec::Error', described_class do |klass| + let(:described_class) { Spec::ExampleError } + + example_class 'Spec::ExampleError', described_class do |klass| klass.const_set :TYPE, 'spec.example_error' end diff --git a/spec/cuprum_spec.rb b/spec/cuprum_spec.rb index f558ddb..dc226dc 100644 --- a/spec/cuprum_spec.rb +++ b/spec/cuprum_spec.rb @@ -17,6 +17,18 @@ -> { be_a(SleepingKingStudios::Tools::Toolbox::Initializer) } end + describe '.gem_path' do + let(:expected) do + sep = File::SEPARATOR + + __dir__.sub(/#{sep}spec#{sep}?\z/, '') + end + + include_examples 'should define class reader', + :gem_path, + -> { be == expected } + end + describe '.version' do it 'should define the reader' do expect(described_class)