diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0560e13..809cc6b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v2 - uses: ruby/setup-ruby@v1 with: - ruby-version: "3.1" + ruby-version: "3.4" bundler-cache: true - name: Run Linter run: bundle exec ci-helper RubocopLint @@ -43,7 +43,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ["2.7", "3.0"] + ruby: ["3.1", "3.2", "3.3"] experimental: [false] include: - ruby: head diff --git a/.rubocop.yml b/.rubocop.yml index c118ba5..c1a4a51 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,7 +3,7 @@ inherit_gem: AllCops: DisplayCopNames: true - TargetRubyVersion: 2.7 + TargetRubyVersion: 3.1 Naming/MethodParameterName: AllowedNames: ["x", "y", "z"] diff --git a/Gemfile b/Gemfile index a30e2f2..dd28cb3 100644 --- a/Gemfile +++ b/Gemfile @@ -12,3 +12,6 @@ gem "rspec" gem "rubocop-config-umbrellio" gem "simplecov" gem "simplecov-lcov" + +gem "dry-initializer" +gem "smart_initializer" diff --git a/Gemfile.lock b/Gemfile.lock index db5ee30..5cc8f56 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,118 +1,130 @@ PATH remote: . specs: - resol (0.9.0) - smart_initializer (~> 0.7) + resol (1.0.0) + dry-configurable (~> 1.2.0) GEM remote: https://rubygems.org/ specs: - activesupport (7.1.2) + activesupport (7.2.2.1) base64 + benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - mutex_m - tzinfo (~> 2.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) ast (2.4.2) base64 (0.2.0) - bigdecimal (3.1.4) - bundler-audit (0.9.1) + benchmark (0.4.0) + bigdecimal (3.1.9) + bundler-audit (0.9.2) bundler (>= 1.2.0, < 3) thor (~> 1.0) - ci-helper (0.5.0) - colorize (~> 0.8) - dry-inflector (~> 0.2) - umbrellio-sequel-plugins (~> 0.4) + ci-helper (0.7.0) + colorize (~> 1.1) + dry-inflector (~> 1.0) + umbrellio-sequel-plugins (~> 0.14) coderay (1.1.3) - colorize (0.8.1) - concurrent-ruby (1.2.2) - connection_pool (2.4.1) - diff-lcs (1.5.0) - docile (1.4.0) - drb (2.2.0) - ruby2_keywords - dry-inflector (0.3.0) - i18n (1.14.1) + colorize (1.1.0) + concurrent-ruby (1.3.4) + connection_pool (2.5.0) + diff-lcs (1.5.1) + docile (1.4.1) + drb (2.2.1) + dry-configurable (1.2.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-core (1.1.0) concurrent-ruby (~> 1.0) - json (2.6.3) - method_source (1.0.0) - minitest (5.20.0) - mutex_m (0.2.0) - parallel (1.23.0) - parser (3.2.2.4) + logger + zeitwerk (~> 2.6) + dry-inflector (1.2.0) + dry-initializer (3.2.0) + i18n (1.14.6) + concurrent-ruby (~> 1.0) + json (2.9.1) + language_server-protocol (3.17.0.3) + logger (1.6.5) + method_source (1.1.0) + minitest (5.25.4) + parallel (1.26.3) + parser (3.3.6.0) ast (~> 2.4.1) racc - pry (0.14.2) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - qonfig (0.28.0) - racc (1.7.3) - rack (3.0.8) + qonfig (0.30.0) + base64 (>= 0.2) + racc (1.8.1) + rack (3.1.8) rainbow (3.1.1) - rake (13.1.0) - regexp_parser (2.8.2) - rexml (3.2.6) - rspec (3.12.0) - rspec-core (~> 3.12.0) - rspec-expectations (~> 3.12.0) - rspec-mocks (~> 3.12.0) - rspec-core (3.12.2) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.3) + rake (13.2.1) + regexp_parser (2.10.0) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.2) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.3) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-mocks (3.12.6) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-support (3.12.1) - rubocop (1.50.2) + rspec-support (~> 3.13.0) + rspec-support (3.13.2) + rubocop (1.69.2) json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.0.0) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.30.0) - parser (>= 3.2.1.0) - rubocop-capybara (2.19.0) - rubocop (~> 1.41) - rubocop-config-umbrellio (1.50.0.85) - rubocop (~> 1.50.0) - rubocop-performance (~> 1.17.0) - rubocop-rails (~> 2.19.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.37.0) + parser (>= 3.3.1.0) + rubocop-config-umbrellio (1.69.0.101) + rubocop (~> 1.69.0) + rubocop-factory_bot (~> 2.26.0) + rubocop-performance (~> 1.23.0) + rubocop-rails (~> 2.28.0) rubocop-rake (~> 0.6.0) - rubocop-rspec (~> 2.20.0) - rubocop-sequel (~> 0.3.3) - rubocop-performance (1.17.1) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) - rubocop-rails (2.19.1) + rubocop-rspec (~> 3.3.0) + rubocop-sequel (~> 0.3.0) + rubocop-factory_bot (2.26.1) + rubocop (~> 1.61) + rubocop-performance (1.23.1) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails (2.28.0) activesupport (>= 4.2.0) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) + rubocop (>= 1.52.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) rubocop-rake (0.6.0) rubocop (~> 1.0) - rubocop-rspec (2.20.0) - rubocop (~> 1.33) - rubocop-capybara (~> 2.17) - rubocop-sequel (0.3.4) + rubocop-rspec (3.3.0) + rubocop (~> 1.61) + rubocop-sequel (0.3.8) rubocop (~> 1.0) ruby-progressbar (1.13.0) - ruby2_keywords (0.0.5) - sequel (5.74.0) + securerandom (0.4.1) + sequel (5.88.0) bigdecimal simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.12.3) + simplecov-html (0.13.1) simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) smart_engine (0.17.0) @@ -122,14 +134,15 @@ GEM smart_types (~> 0.8) smart_types (0.8.0) smart_engine (~> 0.11) - symbiont-ruby (0.7.0) - thor (1.3.0) + thor (1.3.2) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - umbrellio-sequel-plugins (0.14.0.189) + umbrellio-sequel-plugins (0.17.0) sequel - symbiont-ruby - unicode-display_width (2.5.0) + unicode-display_width (3.1.3) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + zeitwerk (2.6.18) PLATFORMS arm64-darwin-21 @@ -142,6 +155,7 @@ PLATFORMS DEPENDENCIES bundler-audit ci-helper + dry-initializer pry rake resol! @@ -149,6 +163,7 @@ DEPENDENCIES rubocop-config-umbrellio simplecov simplecov-lcov + smart_initializer BUNDLED WITH - 2.4.10 + 2.6.2 diff --git a/README.md b/README.md index d4cca75..a496ca9 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,38 @@ and method `.call!` returns a value or throws an error (in case of `fail!` has b #### Params defining -All incoming params and options can be defined using a [smart_initializer](https://github.com/smart-rb/smart_initializer) gem interface. +`Resol` supports two gems, which provide abstract initialization flow for classes: + +1. [smart_initializer](https://github.com/smart-rb/smart_initializer), which was default provider for a very long time. +2. [dry-initializer](https://dry-rb.org/gems/dry-initializer/3.1), additional provider with DSL almost identical to the smart_core's DSL. + +There is an _important restriction_ on using different initializers in different services. +Descendants of a parent, into which initializer logic has already been imported, cannot override the provider + +You can use both providers for a different services: + +```ruby +# Types is a namespace for all types, defined by smart_types. +class FirstService < Resol::Service + inject_initializer :smartcore_injector + + param :first, Types::String + param :second, Types::Integer +end + +# Types is a namespace for all types, defined by dry-types. +class SecondService < Resol::Service + inject_initializer :dry_injector + + param :first, Types::Strict::String + param :second, Types::Strict::Integer +end +``` + +Both initializers support inheritance. And base features for initialization flow +like default value, arguments accessors visibility level, coercible attributes and so on. + +List of all supported initializers you can see at `DependencyContainer` definition. #### Return a result @@ -95,13 +126,15 @@ end Methods: -- `success?` – returns `true` for success result and `false` for failure result -- `failure?` – returns `true` for failure result and `false` for success result -- `value!` – unwraps a result object, returns the value for success result, and throws an error for failure result -- `value_or(other_value, &block)` – returns a value for success result or `other_value` for failure result (either calls `block` in case it given) -- `error` – returns `nil` for success result and error object (with code and data) for failure result -- `or(&block)` – calls block for failure result, for success result does nothing -- `either(success_proc, failure_proc)` – for success result calls success_proc with result value in args, for failure result calls failure_proc with error in args. +- `success?` — returns `true` for success result and `false` for failure result +- `failure?` — returns `true` for failure result and `false` for success result +- `value!` — unwraps a result object, returns the value for success result, and throws an error for failure result +- `value_or(other_value, &block)` — returns a value for success result or `other_value` for failure result (either calls `block` in case it given) +- `error` — returns `nil` for success result and error object (with code and data) for failure result +- `or(&block)` — calls block for failure result, for success result does nothing +- `either(success_proc, failure_proc)` — for success result calls success_proc with result value in args, for failure result calls failure_proc with error in args. +- `bind` — using with `block` for success result resolve value and pass it to the `block`, used to chain multiple monads. Block can return anything. Failure result ignore block and return `self`. +- `fmap` — like the `bind`, but wraps value returned by block by success monad. ### Error object @@ -115,6 +148,11 @@ methods `code` and `data`. Configuration constant references to `SmartCore::Initializer::Configuration`. You can read about available configuration options [here](https://github.com/smart-rb/smart_initializer#configuration). +### Plugin System + +Resol implements the basic logic of using plugins to extend and change the base service class. +You can write your own plugin and applie it by calling `Resol::Service#plugin(plugin_name)`. + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/rspec` to run the tests. diff --git a/lib/resol.rb b/lib/resol.rb index 383a066..8d3fc6a 100644 --- a/lib/resol.rb +++ b/lib/resol.rb @@ -1,11 +1,16 @@ # frozen_string_literal: true -require "smart_core/initializer" - +require "dry-configurable" require_relative "resol/version" -require_relative "resol/return_engine" -require_relative "resol/configuration" + +require_relative "resol/injector" +require_relative "resol/plugins" require_relative "resol/service" module Resol + extend self + + extend Dry::Configurable + + setting :classes_allowed_to_patch, default: ["Resol::Service"] end diff --git a/lib/resol/configuration.rb b/lib/resol/configuration.rb deleted file mode 100644 index 378047f..0000000 --- a/lib/resol/configuration.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module Resol - class Configuration - DEFAULT_RETURN_ENGINE = ReturnEngine::Catch - - class << self - def configure - SmartCore::Initializer::Configuration.configure do |c| - self.smartcore_config = c - yield self - self.smartcore_config = nil - end - end - - def return_engine - @return_engine || DEFAULT_RETURN_ENGINE - end - - def return_engine=(engine) - @return_engine = engine - end - - private - - attr_accessor :smartcore_config - - def method_missing(meth, *args, &block) - # rubocop:disable Style/SafeNavigation - if smartcore_config && smartcore_config.respond_to?(meth) - # rubocop:enable Style/SafeNavigation - smartcore_config.__send__(meth, *args, &block) - else - super(meth, *args, &block) - end - end - - def respond_to_missing?(meth, include_private) - smartcore_config.respond_to?(meth, include_private) - end - end - end -end diff --git a/lib/resol/injector.rb b/lib/resol/injector.rb new file mode 100644 index 0000000..3a2ed62 --- /dev/null +++ b/lib/resol/injector.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Resol + # The Injector class is responsible for injecting initialization logic provider. + # + # Supported providers: + # - :smart -> uses the `smart_core/initializer` + # - :dry -> uses the `dry-initializer` + class Injector + InjectMarker = Module.new + NAME_TO_METHOD_MAPPING = { + smart: :inject_smart, + dry: :inject_dry, + }.freeze + + def self.inject_smart(service) + require "smart_core/initializer" + + service.include(SmartCore::Initializer) + end + + def self.inject_dry(service) + require "dry/initializer" + + service.extend(Dry::Initializer) + end + + def initialize(initializer_name) + self.called_method = NAME_TO_METHOD_MAPPING.fetch(initializer_name.to_sym) + end + + def inject!(service_class) + error!("parent or this class already injected") if service_class.include?(InjectMarker) + + self.class.public_send(called_method, service_class) + service_class.include(InjectMarker) + end + + private + + attr_accessor :called_method + + def error!(msg) + raise msg + end + end +end diff --git a/lib/resol/plugins.rb b/lib/resol/plugins.rb new file mode 100644 index 0000000..c4531c5 --- /dev/null +++ b/lib/resol/plugins.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "pathname" + +module Resol + module Plugins + PLUGINS_PATH = Pathname("resol/plugins") + + class Manager + def self.resolve_module(module_name) + Plugins.const_get(module_name) + end + + def initialize + self.allowed_classes = resolve_allowed_classes + self.plugins = [] + end + + def plugin(caller_class, plugin_name) + plugin_name = plugin_name.to_s + + return unless allowed_classes.include?(caller_class) + return if plugins.include?(plugin_name) + + plugin_module = find_plugin_module(plugin_name) + if defined?(plugin_module::InstanceMethods) + caller_class.include(plugin_module::InstanceMethods) + end + + if defined?(plugin_module::ClassMethods) + caller_class.extend(plugin_module::ClassMethods) + end + + plugins << plugin_name + end + + private + + attr_accessor :allowed_classes, :plugins + + def resolve_allowed_classes + Resol.config.classes_allowed_to_patch.map { |name| Object.const_get(name) } + end + + def find_plugin_module(plugin_name) + require PLUGINS_PATH.join(plugin_name) + self.class.resolve_module(classify_plugin_name(plugin_name)) + rescue LoadError, NameError => e + raise ArgumentError, "Failed to load plugin '#{plugin_name}': #{e.message}" + end + + def classify_plugin_name(string) + string.split(/_|-/).map!(&:capitalize).join + end + end + end +end diff --git a/lib/resol/plugins/dummy.rb b/lib/resol/plugins/dummy.rb new file mode 100644 index 0000000..03fe03f --- /dev/null +++ b/lib/resol/plugins/dummy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Resol + module Plugins + module Dummy; end + end +end diff --git a/lib/resol/plugins/return_in_service.rb b/lib/resol/plugins/return_in_service.rb new file mode 100644 index 0000000..d336b03 --- /dev/null +++ b/lib/resol/plugins/return_in_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Resol + module Plugins + module ReturnInService + module ClassMethods + private + + def handle_catch(_service) + yield + end + end + + module InstanceMethods + module PrependedMethods + private + + def proceed_return(_service, data) = data + end + + def self.included(service) + service.prepend(PrependedMethods) + end + end + end + end +end diff --git a/lib/resol/result.rb b/lib/resol/result.rb index 029a3ca..7dd7368 100644 --- a/lib/resol/result.rb +++ b/lib/resol/result.rb @@ -3,26 +3,13 @@ module Resol class UnwrapError < StandardError; end - class Result - # @!method success? - # @!method failure? - # @!method value_or - # @!method value! - - def initialize(*); end - - def or - yield(@value) if failure? - end - - def either(success_proc, failure_proc) - success? ? success_proc.call(@value) : failure_proc.call(@value) - end - end + # rubocop:disable Lint/EmptyClass + class Result; end + # rubocop:enable Lint/EmptyClass class Success < Result def initialize(value) - super + super() @value = value end @@ -34,7 +21,7 @@ def failure? false end - def value_or(*) + def value_or(_other_value = nil) @value end @@ -42,14 +29,26 @@ def value! @value end - def error - nil + def error = nil + + def or = nil + + def either(success_proc, _failure_proc) + success_proc.call(@value) + end + + def bind + yield @value + end + + def fmap(&) + Resol.Success(bind(&)) end end class Failure < Result def initialize(error) - super + super() @value = error end @@ -62,11 +61,7 @@ def failure? end def value_or(other_value = nil) - if block_given? - yield(@value) - else - other_value - end + block_given? ? yield(@value) : other_value end def value! @@ -76,13 +71,29 @@ def value! def error @value end + + def or + yield @value + end + + def either(_success_proc, failure_proc) + failure_proc.call(@value) + end + + def bind = self + + alias fmap bind end - def self.Success(...) + # TODO: Should be in a module, which includes in classes. + # Example; + # rubocop:disable Naming/MethodName + def Success(...) Success.new(...) end - def self.Failure(...) + def Failure(...) Failure.new(...) end + # rubocop:enable Naming/MethodName end diff --git a/lib/resol/return_engine.rb b/lib/resol/return_engine.rb deleted file mode 100644 index 5af8772..0000000 --- a/lib/resol/return_engine.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Resol - module ReturnEngine - NOT_EXITED = Object.new.freeze - DataWrapper = Struct.new(:data) - end -end - -require_relative "return_engine/catch" -require_relative "return_engine/return" diff --git a/lib/resol/return_engine/catch.rb b/lib/resol/return_engine/catch.rb deleted file mode 100644 index 6407728..0000000 --- a/lib/resol/return_engine/catch.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module Resol - module ReturnEngine - module Catch - extend self - - def wrap_call(service) - catch(service) do - yield - NOT_EXITED - end - end - - def uncaught_call?(return_obj) - return_obj == NOT_EXITED - end - - def handle_return(service, data) - throw(service, data) - end - end - end -end diff --git a/lib/resol/return_engine/return.rb b/lib/resol/return_engine/return.rb deleted file mode 100644 index a724cb6..0000000 --- a/lib/resol/return_engine/return.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Resol - module ReturnEngine - module Return - extend self - - def wrap_call(_service) - yield - end - - def uncaught_call?(return_obj) - !return_obj.is_a?(Resol::Service::Result) - end - - def handle_return(_service, data) - data - end - end - end -end diff --git a/lib/resol/service.rb b/lib/resol/service.rb index 5132dc5..9536159 100644 --- a/lib/resol/service.rb +++ b/lib/resol/service.rb @@ -27,7 +27,6 @@ def message end end - include SmartCore::Initializer include Resol::Builder include Resol::Callbacks @@ -39,62 +38,109 @@ def inherited(klass) super end - def call(*args, **kwargs, &block) + # Allows you to configure an initializer library for this service, + # such as `dry-initializer` or `smart_core/initializer`. + # + # @param initializer_name [String, Symbol] + # The name of the initializer (e.g., `:dry` or `:smart`). + # @return [void] + def use_initializer!(initializer_name) + Resol::Injector.new(initializer_name).inject!(self) + end + + def plugin(...) + manager.plugin(self, ...) + end + + # Calls the service using class-level invocation. Builds the service object, + # runs any callbacks, and invokes the `#call` instance method. + # + # @param args [Array] Positional arguments for building service instance. + # @param kwargs [Hash] Keyword arguments for building service instance. + # @yield [block] An optional block passed into service's `#call`. + # + # @return [Resol::Success, Resol::Failure] + # Returns a `Resol::Success` if the service completed via `success!`, + # or a `Resol::Failure` if exited with`fail!` calling. + # + # @raise [InvalidCommandImplementation] + # If neither `#success!` nor `#fail!` is called inside the `#call` method. + def call(*args, **kwargs, &) service = build(*args, **kwargs) - result = return_engine.wrap_call(service) do + result = handle_catch(service) do service.instance_variable_set(:@__performing__, true) __run_callbacks__(service) - service.call(&block) + service.call(&) end + return Resol::Success(result.data) if service.__result_method__called__ - if return_engine.uncaught_call?(result) - error_message = "No `#success!` or `#fail!` called in `#call` method in #{service.class}." - raise InvalidCommandImplementation, error_message - else - Resol::Success(result.data) - end + error_message = "No `#success!` or `#fail!` called in `#call` method in #{service.class}." + raise InvalidCommandImplementation, error_message rescue self::Failure => e Resol::Failure(e) end - def return_engine - Resol::Configuration.return_engine - end - + # Same as {call}, but attempts to resolve value from monad. + # If the result is a failure, it raises the error instead of returning a `Resol::Failure`. + # + # @param args [Array] Positional arguments for building the service instance. + # @param kwargs [Hash] Keyword arguments for building the service instance. + # @yield [block] An optional block passed to the service's `#call`. + # + # @return [Object] + # Returns a value, with which instance of service interrupts with {#success!}. + # + # @raise [self::Failure] + # Raises an error if the service fails. def call!(...) call(...).value_or { |error| raise error } end + + private + + def manager + @manager ||= Plugins::Manager.new + end + + def handle_catch(service, &) + catch(service, &) + end end - # @!method call + attr_accessor :__result_method__called__ private attr_reader :__performing__ def fail!(code, data = nil) - check_performing do - raise self.class::Failure.new(code, data) - end + check_performing! + raise self.class::Failure.new(code, data) end def success!(data = nil) - check_performing do - self.class.return_engine.handle_return(self, Result.new(data)) - end + check_performing! + result_method_called! + proceed_return(self, Result.new(data)) end - def check_performing - if __performing__ - yield - else - error_message = - "It looks like #call instance method was called directly in #{self.class}. " \ - "You must always use class-level `.call` or `.call!` method." + def check_performing! + return if __performing__ - raise InvalidCommandCall, error_message - end + error_message = + "It looks like #call instance method was called directly in #{self.class}. " \ + "You must always use class-level `.call` or `.call!` method." + + raise InvalidCommandCall, error_message + end + + def result_method_called! + self.__result_method__called__ = true + end + + def proceed_return(service, data) + throw(service, data) end end end diff --git a/lib/resol/version.rb b/lib/resol/version.rb index a03808a..8886a86 100644 --- a/lib/resol/version.rb +++ b/lib/resol/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Resol - VERSION = "0.9.0" + VERSION = "1.0.0" end diff --git a/resol.gemspec b/resol.gemspec index eb29428..398eefc 100644 --- a/resol.gemspec +++ b/resol.gemspec @@ -13,9 +13,9 @@ Gem::Specification.new do |spec| spec.homepage = "https://github.com/umbrellio/resol" spec.license = "MIT" - spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0") + spec.required_ruby_version = Gem::Requirement.new(">= 3.1.0") spec.files = `git ls-files -z`.split("\x0").reject { |f| f.include?("spec") } spec.require_paths = ["lib"] - spec.add_dependency "smart_initializer", "~> 0.7" + spec.add_dependency "dry-configurable", "~>1.2.0" end diff --git a/spec/building_spec.rb b/spec/building_spec.rb index 767cfe6..698671f 100644 --- a/spec/building_spec.rb +++ b/spec/building_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class BaseService < Resol::Service +class BaseService < SmartService param :type option :option diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb deleted file mode 100644 index 472c234..0000000 --- a/spec/configuration_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Resol::Configuration do - around do |example| - example.call - - described_class.configure do |c| - c.auto_cast = true - c.return_engine = described_class::DEFAULT_RETURN_ENGINE - end - end - - it "delegates configuration" do - described_class.configure do |c| - c.auto_cast = false - c.return_engine = Resol::ReturnEngine::Return - end - - expect(SmartCore::Initializer::Configuration.config[:auto_cast]).to eq(false) - expect(described_class.return_engine).to eq(Resol::ReturnEngine::Return) - end - - context "when undefined method is called" do - let(:called_block) do - proc do - described_class.configure do |c| - c.not_exist = true - end - end - end - - it "raises error" do - expect(&called_block).to raise_error(NoMethodError) - end - end - - context "with undefined method" do - it "respond_to? returns false" do - expect(described_class.respond_to?(:not_exist)).to eq(false) - end - end -end diff --git a/spec/manager_spec.rb b/spec/manager_spec.rb new file mode 100644 index 0000000..797f489 --- /dev/null +++ b/spec/manager_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +RSpec.describe Resol::Plugins::Manager do + before do + Resol.config.classes_allowed_to_patch = %w[DummyService Resol::Service ReturnEngineService] + end + before { stub_const("DummyService", service_double) } + + let(:manager) { described_class.new } + + let(:service_double) do + class_double( + Resol::Service, :DummyService, { prepend: true, singleton_class: singleton_double } + ) + end + + let(:singleton_double) { double(prepend: true) } + + it "skips all prepends" do + manager.plugin(DummyService, :dummy) + + expect(service_double).not_to receive(:prepend) + expect(singleton_double).not_to receive(:prepend) + end + + context "when uses same plugin few times" do + before { allow(described_class).to receive(:resolve_module).and_return(Resol::Plugins::Dummy) } + + let(:manager_plugins) { manager.instance_variable_get(:@plugins) } + + it "doesn't load plugin second time" do + manager.plugin(DummyService, :dummy) + manager.plugin(DummyService, :dummy) + + expect(described_class).to have_received(:resolve_module).once + expect(manager_plugins).to eq(["dummy"]) + end + end + + context "when can't require plugin" do + specify do + expect { manager.plugin(DummyService, :not_existed_plugin) }.to raise_error do |error| + expect(error).to be_an_instance_of(ArgumentError) + expect(error.message).to include("Failed to load plugin 'not_existed_plugin': ") + end + end + end + + context "when can't resolve module" do + before { allow(described_class).to receive(:resolve_module).and_raise(NameError, "msg") } + + specify do + expect { manager.plugin(DummyService, :dummy) }.to raise_error do |error| + expect(error).to be_an_instance_of(ArgumentError) + expect(error.message).to include("Failed to load plugin 'dummy': msg") + end + end + end +end diff --git a/spec/result_spec.rb b/spec/result_spec.rb index 1589dc2..73920c1 100644 --- a/spec/result_spec.rb +++ b/spec/result_spec.rb @@ -4,41 +4,78 @@ describe Resol::Success do let(:result) { Resol::Success(:success_value) } - it { expect(result.success?).to be_truthy } - it { expect(result.failure?).to be_falsey } - it { expect(result.value_or(:other_value)).to eq(:success_value) } - it { expect(result.value!).to eq(:success_value) } - it { expect(result.error).to be_nil } - it { expect { result.or { raise "Some Error" } }.not_to raise_error } - - it do + specify { expect(result.success?).to be_truthy } + specify { expect(result.failure?).to be_falsey } + specify { expect(result.value_or(:other_value)).to eq(:success_value) } + specify { expect(result.value!).to eq(:success_value) } + specify { expect(result.error).to be_nil } + specify { expect { result.or { raise "Some Error" } }.not_to raise_error } + + specify do success_proc = instance_double(Proc) failure_proc = instance_double(Proc) allow(success_proc).to receive(:call).and_return("result") expect(result.either(success_proc, failure_proc)).to eq("result") end + + specify do + final = result.bind(&:to_s) + expect(final).to eq("success_value") + end + + specify do + final = result.bind do |val| + Resol::Success("success").bind do |to_exclude| + Resol::Success(val.to_s.gsub(to_exclude, "")) + end + end + + expect(final.success?).to eq(true) + expect(final.value!).to eq("_value") + end + + specify do + final = result.fmap(&:to_s) + + expect(final.success?).to eq(true) + expect(final.value!).to eq("success_value") + end end describe Resol::Failure do let(:result) { Resol::Failure(:failure_value) } - it { expect(result.success?).to be_falsey } - it { expect(result.failure?).to be_truthy } - it { expect(result.value_or(:other_value)).to eq(:other_value) } - it { expect(result.error).to eq(:failure_value) } - it { expect { result.or { raise "Some Error" } }.to raise_error("Some Error") } + specify { expect(result.success?).to be_falsey } + specify { expect(result.failure?).to be_truthy } + specify { expect(result.value_or(:other_value)).to eq(:other_value) } + specify { expect(result.error).to eq(:failure_value) } + specify { expect { result.or { raise "Some Error" } }.to raise_error("Some Error") } - it do + specify do expect { result.value! }.to raise_error(Resol::UnwrapError, "Failure result :failure_value") end - it do + specify do success_proc = instance_double(Proc) failure_proc = instance_double(Proc) allow(failure_proc).to receive(:call).and_return("result") expect(result.either(success_proc, failure_proc)).to eq("result") end + + specify do + final = result.bind(&:to_s) + + expect(final.failure?).to eq(true) + expect(final.error).to eq(:failure_value) + end + + specify do + final = result.fmap { |val| val + 1 } + + expect(final.failure?).to eq(true) + expect(final.error).to eq(:failure_value) + end end end diff --git a/spec/service_spec.rb b/spec/service_spec.rb index 1d1bab0..b476971 100644 --- a/spec/service_spec.rb +++ b/spec/service_spec.rb @@ -18,25 +18,25 @@ def transaction end end -class SuccessService < Resol::Service +class SuccessService < SmartService def call success!(:success_result) end end -class FailureService < Resol::Service +class FailureService < SmartService def call fail!(:failure_result, { data: 123 }) end end -class EmptyService < Resol::Service +class EmptyService < SmartService def call "some_string" end end -class AbstractService < Resol::Service +class AbstractService < SmartService end class InheritedService < AbstractService @@ -45,7 +45,7 @@ def call end end -class ServiceWithCall < Resol::Service +class ServiceWithCall < SmartService def call success!(:success_result) end @@ -54,7 +54,7 @@ def call class SubService < ServiceWithCall end -class ServiceWithCallbacks < Resol::Service +class ServiceWithCallbacks < SmartService before_call :define_instance_var def call @@ -78,19 +78,19 @@ def set_other_value end end -class ServiceWithTransaction < Resol::Service +class ServiceWithTransaction < SmartService def call DB.transaction { success! } end end -class ServiceWithFailInTransaction < Resol::Service +class ServiceWithFailInTransaction < SmartService def call DB.transaction { fail!(:failed) } end end -class HackyService < Resol::Service +class HackyService < SmartService param :count def call @@ -99,10 +99,95 @@ def call end end +class YieldingService < SmartService + def call + success!(yield) + end +end + +class PluginSuccessService < ReturnEngineService + def call + success!(:success_result) + end +end + +class PluginFailureService < ReturnEngineService + def call + fail!(:failure_result, { data: 123 }) + end +end + +class PluginEmptyService < ReturnEngineService + def call + "some_string" + end +end + +class PluginAbstractService < ReturnEngineService +end + +class PluginInheritedService < PluginAbstractService + def call + success!(:success_result) + end +end + +class PluginServiceWithCall < ReturnEngineService + def call + success!(:success_result) + end +end + +class PluginSubService < PluginServiceWithCall +end + +class PluginServiceWithCallbacks < ReturnEngineService + before_call :define_instance_var + + def call + success!(@some_var) + end + + private + + def define_instance_var + @some_var = "some_value" + end +end + +class PluginSubServiceWithCallbacks < PluginServiceWithCallbacks + before_call :set_other_value + + private + + def set_other_value + @some_var += "_postfix" + end +end + +class PluginServiceWithTransaction < ReturnEngineService + def call + DB.transaction { return success! } + end +end + +class PluginServiceWithFailInTransaction < ReturnEngineService + def call + DB.transaction { fail!(:failed) } + end +end + +class PluginHackyService < ReturnEngineService + param :count + + def call + return success! unless count.zero? + PluginHackyService.build(count + 1).call + end +end + RSpec.describe Resol::Service do context "with Catch return engine" do - before { Resol::Configuration.return_engine = Resol::ReturnEngine::Catch } - it "returns a success result" do expect(SuccessService.call!).to eq(:success_result) end @@ -164,45 +249,57 @@ def call expect { HackyService.call!(0) }.to raise_error(Resol::Service::InvalidCommandCall) end end - end - context "with Return return engine" do - before { Resol::Configuration.return_engine = Resol::ReturnEngine::Return } + context "when block passed to the service" do + it "yields block" do + result = YieldingService.call! { "kek" } + expect(result).to eq("kek") + end + end + end + context "with Return on success plugin" do it "returns a success result" do - expect(SuccessService.call!).to eq(:success_result) + expect(PluginSuccessService.call!).to eq(:success_result) end it "raises a failure result error" do - expect { FailureService.call! }.to raise_error do |error| - expect(error).to be_a(FailureService::Failure) + expect { PluginFailureService.call! }.to raise_error do |error| + expect(error).to be_a(PluginFailureService::Failure) expect(error.code).to eq(:failure_result) expect(error.data).to eq(data: 123) end end it "raises an InvalidCommandImplementation error" do - expect { EmptyService.call! }.to raise_error do |error| - expect(error).to be_a(EmptyService::InvalidCommandImplementation) + expect { PluginEmptyService.call! }.to raise_error do |error| + expect(error).to be_a(PluginEmptyService::InvalidCommandImplementation) expect(error.message).to eq( - "No `#success!` or `#fail!` called in `#call` method in EmptyService.", + "No `#success!` or `#fail!` called in `#call` method in PluginEmptyService.", ) end end it "properly works with inherited services" do - expect(InheritedService.call!).to eq(:success_result) - expect(SubService.call!).to eq(:success_result) + expect(PluginInheritedService.call!).to eq(:success_result) + expect(PluginSubService.call!).to eq(:success_result) end it "properly executes callbacks" do - expect(SubServiceWithCallbacks.call!).to eq("some_value_postfix") - expect(ServiceWithCallbacks.call!).to eq("some_value") + expect(PluginSubServiceWithCallbacks.call!).to eq("some_value_postfix") + expect(PluginServiceWithCallbacks.call!).to eq("some_value") + end + + it "doesn't rollback transaction" do + result = PluginServiceWithTransaction.call + expect(result.success?).to eq(true) + expect(result.value!).to eq(nil) + expect(DB.rollbacked).to eq(false) end context "when service failed" do it "rollbacks transaction" do - result = ServiceWithFailInTransaction.call + result = PluginServiceWithFailInTransaction.call expect(result.failure?).to eq(true) result.or do |error| expect(error.code).to eq(:failed) @@ -214,13 +311,47 @@ def call context "when using instance #call" do it "raises error" do - expect { SuccessService.build.call }.to raise_error(Resol::Service::InvalidCommandCall) + expect do + PluginSuccessService.build.call + end.to raise_error(Resol::Service::InvalidCommandCall) end end context "when using instance #call inside other service" do it "raises error" do - expect { HackyService.call!(0) }.to raise_error(Resol::Service::InvalidCommandCall) + expect { PluginHackyService.call!(0) }.to raise_error(Resol::Service::InvalidCommandCall) + end + end + end + + context "when install plugin on the child service class" do + let(:child_service) { Class.new(Resol::Service) } + + it "just skips installation" do + child_service.plugin(:dummy) + manager = child_service.send(:manager) + + expect(manager.send(:plugins)).to eq([]) + end + end + + context "when access plugin manager more then one time" do + let(:first_manager) { Resol::Service.send(:manager) } + let(:second_manager) { Resol::Service.send(:manager) } + + it "memoize manager instance" do + expect(first_manager).to eq(second_manager) + end + end + + context "when inherited from already injected service" do + let(:child_service) { Class.new(SmartService) } + let(:injecting_proc) { proc { use_initializer!(:dry) } } + + it "tries to inject initializer" do + expect { child_service.class_eval(&injecting_proc) }.to raise_error do |error| + expect(error).to be_instance_of(RuntimeError) + expect(error.message).to eq("parent or this class already injected") end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c458ad9..84f889e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -22,9 +22,33 @@ end end +require "dry/configurable/test_interface" require "resol" require "pry" +require "smart_core/initializer" +require "dry/initializer" + +require "resol/plugins/dummy" + +module Resol + enable_test_interface +end + +Resol.config.classes_allowed_to_patch = %w[Resol::Service ReturnEngineService] + +class SmartService < Resol::Service + use_initializer! :smart +end + +class ReturnEngineService < Resol::Service + BASE_CLASS = self + + use_initializer! :dry +end + +ReturnEngineService.plugin(:return_in_service) + RSpec.configure do |config| config.example_status_persistence_file_path = ".rspec_status" config.disable_monkey_patching! @@ -32,4 +56,6 @@ config.order = :random Kernel.srand config.seed + + config.before { Resol.reset_config } end