Skip to content

Commit

Permalink
Allow composition of WebPipe
Browse files Browse the repository at this point in the history
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
```
  • Loading branch information
waiting-for-dev committed Jul 11, 2019
1 parent e3f1a18 commit dfd9df4
Show file tree
Hide file tree
Showing 7 changed files with 257 additions and 8 deletions.
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
89 changes: 89 additions & 0 deletions lib/web_pipe/conn_support/composition.rb
Original file line number Diff line number Diff line change
@@ -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<Operation[]>]
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
4 changes: 2 additions & 2 deletions lib/web_pipe/dsl/class_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions lib/web_pipe/dsl/dsl_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<Plug>]
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
Expand Down
46 changes: 44 additions & 2 deletions lib/web_pipe/dsl/instance_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
37 changes: 37 additions & 0 deletions spec/integration/composition_spec.rb
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions spec/unit/web_pipe/conn_support/composition_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit dfd9df4

Please sign in to comment.