diff --git a/.rubocop.yml b/.rubocop.yml index 258f9d5..a513808 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,3 +4,6 @@ inherit_gem: AllCops: DisplayCopNames: true TargetRubyVersion: 2.5 + +Naming/MemoizedInstanceVariableName: + Enabled: false diff --git a/README.md b/README.md index cd53c2a..9a6b1b4 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ rescue Polist::Service::Failure => error end ``` -Note that `.run` and `.call` are just shortcuts for `MyService.new(...).run` and `MyService.new(...).call` with the only difference that they always return the service instance instead of the result of `#run` or `#call`. Unlike `#call` though, `#run` is not intended to be owerwritten in subclasses. +Note that `.run` and `.call` are just shortcuts for `MyService.new(...).run` and `MyService.new(...).call` with the only difference that they always return the service instance instead of the result of `#run` or `#call`. Unlike `#call` though, `#run` is not intended to be overwritten in subclasses. ### Using Form objects @@ -169,6 +169,40 @@ c.x # => 15 c.y # => nil ``` +### Using Middlewares + +If you have some common things to be done in more than one service, you can define a middleware and register it inside the said services. +Every middleware takes the service into it's constructor and executes `#call`. Thus every middleware has to implement `#call` method and has a `#service` attribute reader. +Middlewares delegate `#success!`, `#fail!`, `#error!`, `#form`, `#form_attributes` to the service class they are registered in. +Every middleware should be a subclass of `Polist::Service::Middleware`. Middlewares are run before the service itself is run. + +To register a middleware one should use `.register_middleware` class method on a service. More than one middleware can be registered for one service. + +For example: +```ruby +class MyMiddleware < Polist::Service::Middleware + def call + fail!(code: :not_cool) if service.fail_on_middleware? + end +end + +class MyService < Polist::Service + register_middleware MyMiddleware + + def call + success!(code: :cool) + end + + def fail_on_middleware? + true + end +end + +service = MyService.run +service.success? #=> false +service.response #=> { code: :not_cool } +``` + ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/umbrellio/polist. diff --git a/lib/polist.rb b/lib/polist.rb index 1da7677..d5de3d3 100644 --- a/lib/polist.rb +++ b/lib/polist.rb @@ -2,5 +2,6 @@ require "polist/builder" require "polist/service" +require "polist/service/middleware" require "polist/struct" require "polist/version" diff --git a/lib/polist/service.rb b/lib/polist/service.rb index d7ab06c..4ba268a 100644 --- a/lib/polist/service.rb +++ b/lib/polist/service.rb @@ -20,10 +20,20 @@ class Form include ActiveModel::Validations end + module MiddlewareCaller + def call + call_middlewares + super + end + end + + MiddlewareError = Class.new(StandardError) + attr_accessor :params def self.inherited(klass) klass.const_set(:Failure, Class.new(klass::Failure)) + klass.prepend MiddlewareCaller end def self.build(*args) @@ -44,6 +54,23 @@ def self.param(*names) end end + def self.__polist_middlewares__ + @__polist_middlewares__ ||= [] + end + + def self.register_middleware(klass) + unless klass < Polist::Service::Middleware + raise MiddlewareError, + "Middleware #{klass} should be a subclass of Polist::Service::Middleware" + end + + __polist_middlewares__ << klass + end + + def self.__clear_middlewares__ + @__polist_middlewares__ = [] + end + def initialize(params = {}) self.params = params end @@ -76,6 +103,12 @@ def validate! private + def call_middlewares + self.class.__polist_middlewares__.each do |middleware| + middleware.new(self).call + end + end + def form @form ||= self.class::Form.new(form_attributes.to_snake_keys) end diff --git a/lib/polist/service/middleware.rb b/lib/polist/service/middleware.rb new file mode 100644 index 0000000..5440ae3 --- /dev/null +++ b/lib/polist/service/middleware.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Polist::Service::Middleware + def initialize(service) + @service = service + end + + # Should be implemented in subclasses + def call; end + + private + + attr_reader :service + + %i[fail! error! success! form form_attributes].each do |service_method| + define_method(service_method) do |*args| + service.send(service_method, *args) + end + end +end diff --git a/spec/polist/service/middleware_spec.rb b/spec/polist/service/middleware_spec.rb new file mode 100644 index 0000000..3351489 --- /dev/null +++ b/spec/polist/service/middleware_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +class FirstMiddleware < Polist::Service::Middleware + def call; end +end + +class SecondMiddleware < Polist::Service::Middleware + def call; end +end + +class ServiceWithMiddlewares < Polist::Service + def call + success!(success: true) + end +end + +RSpec.describe Polist::Service::Middleware do + before { ServiceWithMiddlewares.__clear_middlewares__ } + + context "middlewares do nothing" do + before do + ServiceWithMiddlewares.register_middleware FirstMiddleware + ServiceWithMiddlewares.register_middleware SecondMiddleware + end + + shared_examples "middlewares are called" do |service_call| + specify do + expect_any_instance_of(FirstMiddleware).to receive(:call) + expect_any_instance_of(SecondMiddleware).to receive(:call) + + service = service_call.() + expect(service.success?).to eq(true) + expect(service.failure?).to eq(false) + expect(service.response).to eq(success: true) + end + end + + it_behaves_like "middlewares are called", -> { ServiceWithMiddlewares.run } + it_behaves_like "middlewares are called", -> { ServiceWithMiddlewares.call } + end + + describe "middlewares affect the response of the service" do + describe "#run" do + specify "#response is mutated and error is rescued" do + middleware = Class.new(Polist::Service::Middleware) do + def call + fail!(failed: true) + end + end + + ServiceWithMiddlewares.register_middleware middleware + + expect { ServiceWithMiddlewares.run }.not_to raise_error + + service = ServiceWithMiddlewares.run + expect(service.success?).to eq(false) + expect(service.failure?).to eq(true) + expect(service.response).to eq(failed: true) + end + end + + describe "#call" do + specify "error is raised" do + middleware = Class.new(Polist::Service::Middleware) do + def call + fail!(failed: true) + end + end + + ServiceWithMiddlewares.register_middleware middleware + + expect { ServiceWithMiddlewares.call } + .to raise_error(ServiceWithMiddlewares::Failure, "{:failed=>true}") + end + end + end + + describe "middlewares delegate methods to service" do + shared_examples "no errors are raised" do |service_call| + specify do + middleware = Class.new(Polist::Service::Middleware) do + def call + form + form_attributes + success! + end + end + + ServiceWithMiddlewares.register_middleware middleware + + expect { service_call.() }.not_to raise_error + end + end + + it_behaves_like "no errors are raised", -> { ServiceWithMiddlewares.run } + it_behaves_like "no errors are raised", -> { ServiceWithMiddlewares.call } + end +end diff --git a/spec/polist/service_spec.rb b/spec/polist/service_spec.rb index 61389e2..1bc3a3d 100644 --- a/spec/polist/service_spec.rb +++ b/spec/polist/service_spec.rb @@ -133,4 +133,25 @@ def call expect(service.response).to eq(error: "failure") end end + + describe ".register_middleware" do + let(:first_middleware) { Class.new(Polist::Service::Middleware) } + let(:second_middleware) { Class.new(Polist::Service::Middleware) } + + before do + BasicService.register_middleware(first_middleware) + BasicService.register_middleware(second_middleware) + end + + it "stores middlewares in the service class" do + expect(BasicService.__polist_middlewares__) + .to contain_exactly(first_middleware, second_middleware) + end + + it "raises error if middleware is not a subclass of Polist::Service::Middleware" do + expect { BasicService.register_middleware(String) } + .to raise_error(Polist::Service::MiddlewareError, + "Middleware String should be a subclass of Polist::Service::Middleware") + end + end end