From dfd9df46f45237b348883968cce718bfb6387b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Busqu=C3=A9?= Date: Thu, 11 Jul 2019 13:16:50 +0200 Subject: [PATCH] Allow composition of WebPipe WebPipe representation as a proc is the composition of all its plug operations. Thus, it is itself an operation taking a `Conn` and returning a `Conn`. It can be leveraged to be plugged as any other operation and compose WebPipe's: ```ruby class HtmlApp include WebPipe plug :html private def html(conn) conn.set_response_header('Content-Type', 'text/html') end end class App include WebPipe plug :html, &HtmlApp.new plug :body private def body(conn) conn.set_response_body('Hello, world!') end end ``` --- README.md | 34 ++++++- lib/web_pipe/conn_support/composition.rb | 89 +++++++++++++++++++ lib/web_pipe/dsl/class_context.rb | 4 +- lib/web_pipe/dsl/dsl_context.rb | 11 ++- lib/web_pipe/dsl/instance_methods.rb | 46 +++++++++- spec/integration/composition_spec.rb | 37 ++++++++ .../web_pipe/conn_support/composition_spec.rb | 44 +++++++++ 7 files changed, 257 insertions(+), 8 deletions(-) create mode 100644 lib/web_pipe/conn_support/composition.rb create mode 100644 spec/integration/composition_spec.rb create mode 100644 spec/unit/web_pipe/conn_support/composition_spec.rb diff --git a/README.md b/README.md index 4d0d8dd..a8b3c56 100644 --- a/README.md +++ b/README.md @@ -181,13 +181,45 @@ class App #### Proc (or anything responding to `#call`) Operations can also be defined inline as anything that responds to -`#call`, like a `Proc`: +`#call`, like a `Proc`, or also like a block: ```ruby class App include WebPipe plug :hello, ->(conn) { conn } + plug(:hello2) { |conn| conn } +end +``` + +The representation of a `WebPipe` as a Proc is itself an operation +accepting a `Conn` and returning a `Conn`: the composition of all its +plugs. Therefore, it can be plugged to any other `WebPipe`: + +```ruby +class HtmlApp + include WebPipe + + plug :html + + private + + def html(conn) + conn.set_response_header('Content-Type', 'text/html') + end +end + +class App + include WebPipe + + plug :html, &HtmlApp.new + plug :body + + private + + def body(conn) + conn.set_response_body('Hello, world!') + end end ``` diff --git a/lib/web_pipe/conn_support/composition.rb b/lib/web_pipe/conn_support/composition.rb new file mode 100644 index 0000000..3f777f4 --- /dev/null +++ b/lib/web_pipe/conn_support/composition.rb @@ -0,0 +1,89 @@ +require 'dry/initializer' +require 'dry/monads/result' +require 'web_pipe/types' +require 'web_pipe/conn' +require 'dry/monads/result/extensions/either' + +Dry::Monads::Result.load_extensions(:either) + +module WebPipe + module ConnSupport + # Composition of a pipe of {Operation} on a {Conn}. + # + # It represents the composition of a series of functions which + # take a {Conn} as argument and return a {Conn}. + # + # However, {Conn} can itself be of two different types (subclasses + # of it): a {Conn::Clean} or a {Conn::Dirty}. On execution time, + # the composition is stopped whenever the stack is emptied or a + # {Conn::Dirty} is returned in any of the steps. + class Composition + # Type for an operation. + # + # It should be anything callable expecting a {Conn} and + # returning a {Conn}. + Operation = Types.Interface(:call) + + # Error raised when an {Operation} returns something that is not + # a {Conn}. + class InvalidOperationResult < RuntimeError + # @param returned [Any] What was returned from the {Operation} + def initialize(returned) + super( + <<~eos + An operation returned +#{returned.inspect}+. To be valid, + an operation must return whether a + WebPipe::Conn::Clean or a WebPipe::Conn::Dirty. + eos + ) + end + end + + include Dry::Monads::Result::Mixin + + include Dry::Initializer.define -> do + # @!attribute [r] operations + # @return [Array] + param :operations, type: Types.Array(Operation) + end + + # @param conn [Conn] + # @return [Conn] + # @raise InvalidOperationResult when an operation does not + # return a {Conn} + def call(conn) + extract_result( + apply_operations( + conn + ) + ) + end + + private + + def apply_operations(conn) + operations.reduce(Success(conn)) do |new_conn, operation| + new_conn.bind { |c| apply_operation(c, operation) } + end + end + + def apply_operation(conn, operation) + result = operation.(conn) + case result + when Conn::Clean + Success(result) + when Conn::Dirty + Failure(result) + else + raise InvalidOperationResult.new(result) + end + end + + def extract_result(result) + extract_proc = :itself.to_proc + + result.either(extract_proc, extract_proc) + end + end + end +end \ No newline at end of file diff --git a/lib/web_pipe/dsl/class_context.rb b/lib/web_pipe/dsl/class_context.rb index 3f63406..643f89a 100644 --- a/lib/web_pipe/dsl/class_context.rb +++ b/lib/web_pipe/dsl/class_context.rb @@ -51,8 +51,8 @@ def define_container def define_dsl DSL_METHODS.each do |method| module_exec(dsl_context) do |dsl_context| - define_method(method) do |*args| - dsl_context.method(method).(*args) + define_method(method) do |*args, &block| + dsl_context.method(method).(*args, &block) end end end diff --git a/lib/web_pipe/dsl/dsl_context.rb b/lib/web_pipe/dsl/dsl_context.rb index 43f9ba1..6cbe352 100644 --- a/lib/web_pipe/dsl/dsl_context.rb +++ b/lib/web_pipe/dsl/dsl_context.rb @@ -41,12 +41,17 @@ def use(middleware, *options) # Creates and adds a plug to the stack. # + # The spec can be given as a {Plug::Spec} or as a block, which + # is captured into a {Proc} (one of the options for a + # {Plug::Spec}. + # # @param name [Plug::Name[]] - # @param with [Plug::Spec[]] + # @param spec [Plug::Spec[]] + # @param block_spec [Proc] # # @return [Array] - def plug(name, with = nil) - plugs << Plug.new(name, with) + def plug(name, spec = nil, &block_spec) + plugs << Plug.new(name, spec || block_spec) end end end diff --git a/lib/web_pipe/dsl/instance_methods.rb b/lib/web_pipe/dsl/instance_methods.rb index df63181..146c61f 100644 --- a/lib/web_pipe/dsl/instance_methods.rb +++ b/lib/web_pipe/dsl/instance_methods.rb @@ -4,6 +4,7 @@ require 'web_pipe/app' require 'web_pipe/plug' require 'web_pipe/rack/app_with_middlewares' +require 'web_pipe/conn_support/composition' module WebPipe module DSL @@ -35,14 +36,17 @@ module InstanceMethods type: Injections end - # @return [Rack::AppWithMiddlewares] + # @return [Rack::AppWithMiddlewares[]] attr_reader :rack_app + # @return [ConnSupport::Composition::Operation[]] + attr_reader :operations + def initialize(*args) super middlewares = self.class.middlewares container = self.class.container - operations = Plug.inject_and_resolve(self.class.plugs, injections, container, self) + @operations = Plug.inject_and_resolve(self.class.plugs, injections, container, self) app = App.new(operations) @rack_app = Rack::AppWithMiddlewares.new(middlewares, app) end @@ -55,6 +59,44 @@ def initialize(*args) def call(env) rack_app.call(env) end + + # Proc for the composition of all operations. + # + # This can be used to plug a {WebPipe} itself as an operation. + # + # @example + # class HtmlApp + # include WebPipe + # + # plug :html + # + # private + # + # def html(conn) + # conn.set_response_header('Content-Type', 'text/html') + # end + # end + # + # class App + # include WebPipe + # + # plug :html, &HtmlApp.new + # plug :body + # + # private + # + # def body(conn) + # conn.set_response_body('Hello, world!') + # end + # end + # + # @see ConnSupport::Composition + def to_proc + ConnSupport::Composition. + new(operations). + method(:call). + to_proc + end end end end \ No newline at end of file diff --git a/spec/integration/composition_spec.rb b/spec/integration/composition_spec.rb new file mode 100644 index 0000000..75f1607 --- /dev/null +++ b/spec/integration/composition_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' +require 'support/env' + +RSpec.describe "Composition of WebPipe's" do + let(:pipe) do + Class.new do + include WebPipe + + class One + include WebPipe + + plug :one + + private + + def one(conn) + conn.set_response_body('One') + end + end + + plug :one, &One.new + plug :two + + private + + def two(conn) + conn.set_response_body( + conn.response_body[0] + 'Two' + ) + end + end.new + end + + it 'plugging a WebPipe composes its plugs' do + expect(pipe.call(DEFAULT_ENV).last).to eq(['OneTwo']) + end +end \ No newline at end of file diff --git a/spec/unit/web_pipe/conn_support/composition_spec.rb b/spec/unit/web_pipe/conn_support/composition_spec.rb new file mode 100644 index 0000000..cbdb2f0 --- /dev/null +++ b/spec/unit/web_pipe/conn_support/composition_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' +require 'support/env' +require 'web_pipe/conn_support/composition' +require 'web_pipe/conn_support/builder' + +RSpec.describe WebPipe::ConnSupport::Composition do + let(:conn) { WebPipe::ConnSupport::Builder.call(DEFAULT_ENV) } + + describe '#call' do + it 'chains operations on Conn' do + op_1 = ->(conn) { conn.set_status(200) } + op_2 = ->(conn) { conn.set_response_body('foo') } + + app = described_class.new([op_1, op_2]) + + expect(app.call(conn)).to eq( + op_2.(op_1.(conn)) + ) + end + + it 'stops chain propagation once a conn is tainted' do + op_1 = ->(conn) { conn.set_status(200) } + op_2 = ->(conn) { conn.set_response_body('foo') } + op_3 = ->(conn) { conn.taint } + op_4 = ->(conn) { conn.set_response_body('bar') } + + app = described_class.new([op_1, op_2, op_3, op_4]) + + expect(app.call(conn)).to eq( + op_3.(op_2.(op_1.(conn))) + ) + end + + it 'raises InvalidOperationReturn when one operation does not return a Conn' do + op = ->(_conn) { :foo } + + app = described_class.new([op]) + + expect { + app.call(conn) + }.to raise_error(WebPipe::ConnSupport::Composition::InvalidOperationResult) + end + end +end \ No newline at end of file