Skip to content

Commit

Permalink
Nightly housekeepings
Browse files Browse the repository at this point in the history
  • Loading branch information
alessandro-fazzi committed Aug 11, 2024
1 parent 9c69e75 commit fb442af
Show file tree
Hide file tree
Showing 7 changed files with 366 additions and 161 deletions.
140 changes: 137 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,144 @@ Install the gem and add to the application's Gemfile by executing:

## Usage

> [!NOTE]
> I won't write usage instruction for a POC
Tests should demonstrate usage. Some interesting spotlights follows.

### Validation

> [!TIP]
> In the `examples/` folder the are some runnable examples of validated interactors.
2 plugins are shipped to validate the Context. You have to manually
`require` them and add dependencies to your bundle:

```bash
bundle add activemodel

require "i_do_not_need_interactor/contract/active_model"
```

or

```bash
bundle add dry-validation

require "i_do_not_need_interactor/contract/dry_validation"
```

Here's an example

```ruby
class InteractorWithActiveModelContract
include Interactor
include Interactor::Contract::ActiveModel

def call(ctx); end

contract do
attribute :test
validates :test, presence: true
end
end
```
Inside the `contract` block you have access to `ActiveModel::Attributes` and
`ActiveModel::Validations` methods and you're also free to overdo: the block is
evaluated in the context of a class. I advise to keep things simple; `attribute`
and `validates` should be all you need in order to do basic validation.
`contract` will dynamically create a class, will create the object initializing
it with the context's key/value pairs as attributes, then it will be validated.
If the context is invalid it's errors messages will be copied into context's
`errors`, thus making it a failed one.
Using `dry-validation` is more straightforward under the hood, even if the DSL
takes the interface equivalent:
```ruby
class InteractorWithDryValidationContract
include Interactor
include Interactor::Contract::DryValidation

Tests should demonstrate the use of the toy.
def call(ctx); end

contract do
params do
required(:test)
end
end
end
```
The `contract` block will dynamically create a `Dry::Validation::Contract` class,
spawn an object from it then will `.call` the object passing in the context.
`contract`'s block will be evaluated inside the `Dry::Validation::Contract` so
you should have access to all its goodies.
You can also do manual validation w/o the need to add additional dependencies.
Interactors are validated if they respond to `#validate` (accepting context as sole
argument):

```ruby
class InteractorWithManualValidation
include Interactor
def call(ctx); end
def validate(ctx)
ctx.errors << "A validation error"
end
end
```

> [!IMPORTANT]
> Validation is done before interactor execution but only if the received context
> is `#success?`.
>
> If the validation fails the interactor is not executed.

### Composition

While other libraries introduce concepts to "chain" more interactor together, this
POC relies on Ruby's own functional composition.
```ruby
(
->(number) { number += 1 } >>
->(number) { number += 1 } >>
->(number) { number += 1 }
).call(0)
# => 3
```
Including `Interactor` module will make the descendant respond to `#>>` method like
a callable object handling the context. Moreover any callable object accepting a sole argument can be added in the composition chain.
> [!IMPORTANT]
> When using an arbitrary callable object, be sure to always return the context at the
> end of its execution
```ruby
log_the_failure = lambda do |ctx|
return ctx if ctx.success?
App.logger.debug("This was the context: #{ctx}. Failed interactor was: #{ctx.failed}")
ctx
end
(
InteractorA >>
InteractorSum >>
log_the_failure
).call(b: 2)
```
> [!WARNING]
> As you noticed in the last snippet custom callable objects are responsible to determine
> if they should or should not execute given current context's state.
> You're on your own. But it's just ruby.

## Development

Expand Down
96 changes: 28 additions & 68 deletions lib/i_do_not_need_interactor.rb
Original file line number Diff line number Diff line change
@@ -1,102 +1,62 @@
# frozen_string_literal: true

require_relative "i_do_not_need_interactor/version"
require "i_do_not_need_interactor/version"
require "i_do_not_need_interactor/context"

module IDoNotNeedInteractor # rubocop:disable Style/Documentation
class Error < StandardError; end

def self.included(descendant)
class << descendant
def pipe
new.method(:maybe_call)
def >>(other)
method(:call).public_send(:>>, other)
end

def call(ctx = Context.new)
ctx = Context.new(**ctx) unless ctx.is_a?(Context)
def call(ctx = {})
new.maybe_call(ctx)
ctx
end
end
end

attr_accessor :failed

def initialize
@failed = false
end

def maybe_call(ctx = Context.new)
return ctx if ctx.failure?
ctx = Context.new(**ctx) unless ctx.is_a?(Context)

if respond_to?(:validate)
validate(ctx)
return ctx if ctx.failure?
end
return ctx if ctx.failure?
return ctx if run_validation(ctx).failure?

call(ctx)
ctx.register(self)
actually_call(ctx)

ctx._executed.reverse_each { _1.rollback(ctx) } if ctx.failure?
trigger_callback(ctx) if ctx.failure?

ctx
end

def rollback(ctx); end

class Context < Hash # rubocop:disable Style/Documentation
attr_reader :errors, :_executed

def initialize(**initial_values)
super()
@errors = []
@_executed = []
merge! initial_values
end
private

def register(object)
@_executed << object
end
def run_validation(ctx)
return ctx unless respond_to?(:validate)

def success? = errors.empty?
validate(ctx)

def failure? = !success?
ctx
end

module Contract
module ActiveModel # rubocop:disable Style/Documentation
def self.included(descendant) # rubocop:disable Metrics/MethodLength
class << descendant
def contract(&block)
@contract = Class.new do
include ::ActiveModel::API
include ::ActiveModel::Attributes
include ::ActiveModel::Validations

def self.model_name
::ActiveModel::Name.new(self, nil, "temp")
end
end
@contract.class_exec(&block)
end
end
end

def validate(ctx)
self.class.instance_variable_get(:@contract).new(**ctx).validate!
rescue ::ActiveModel::ValidationError => e
ctx.errors << e.message
end
end

module DryValidation # rubocop:disable Style/Documentation
def self.included(descendant)
class << descendant
def contract(&block)
@contract = Class.new(::Dry::Validation::Contract)
@contract.class_exec(&block)
end
end
end
def actually_call(ctx)
call(ctx)
ctx.register(self)
self.failed = ctx.failure?
end

def validate(ctx)
validation_result = self.class.instance_variable_get(:@contract).new.call(ctx)
ctx.errors << validation_result.errors.to_h if validation_result.errors.any?
end
end
def trigger_callback(ctx)
ctx._executed.reverse_each { _1.rollback(ctx) }
end
end

Expand Down
24 changes: 24 additions & 0 deletions lib/i_do_not_need_interactor/context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module IDoNotNeedInteractor
class Context < Hash # rubocop:disable Style/Documentation
attr_reader :errors, :_executed

def initialize(**initial_values)
super()
@errors = []
@_executed = []
merge! initial_values
end

def register(object)
@_executed << object
end

def success? = errors.empty?

def failure? = !success?

def failed = _executed.find { _1.failed == true }
end
end
32 changes: 32 additions & 0 deletions lib/i_do_not_need_interactor/contract/active_model.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

require "active_model"

module IDoNotNeedInteractor
module Contract
module ActiveModel # rubocop:disable Style/Documentation
def self.included(descendant) # rubocop:disable Metrics/MethodLength
class << descendant
def contract(&block)
@contract = Class.new do
include ::ActiveModel::API
include ::ActiveModel::Attributes
include ::ActiveModel::Validations

def self.model_name
::ActiveModel::Name.new(self, nil, "temp")
end
end
@contract.class_exec(&block)
end
end
end

def validate(ctx)
self.class.instance_variable_get(:@contract).new(**ctx).validate!
rescue ::ActiveModel::ValidationError => e
ctx.errors << e.message
end
end
end
end
23 changes: 23 additions & 0 deletions lib/i_do_not_need_interactor/contract/dry_validation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

require "dry-validation"

module IDoNotNeedInteractor
module Contract
module DryValidation # rubocop:disable Style/Documentation
def self.included(descendant)
class << descendant
def contract(&block)
@contract = Class.new(::Dry::Validation::Contract)
@contract.class_exec(&block)
end
end
end

def validate(ctx)
validation_result = self.class.instance_variable_get(:@contract).new.call(ctx)
ctx.errors << validation_result.errors.to_h if validation_result.errors.any?
end
end
end
end
Loading

0 comments on commit fb442af

Please sign in to comment.