Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Proposal] Fully configurable non dry-matchers endpoint #7

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
29 changes: 29 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
AllCops:
TargetRubyVersion: 2.3.1

Style/StringLiterals:
EnforcedStyle: double_quotes
SupportedStyles:
- single_quotes
- double_quotes

Metrics/LineLength:
Max: 80

Metrics/BlockLength:
CountComments: false # count full line comments?
Max: 25
ExcludedMethods: [feature, describe, shared_examples_for]

Style/FrozenStringLiteralComment:
Enabled: false

Documentation:
Enabled: false

NumericLiterals:
Exclude:
- 'config/initializers/app_version.rb'

LambdaCall:
Enabled: false
10 changes: 3 additions & 7 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
source 'https://rubygems.org'
source "https://rubygems.org"

# Specify your gem's dependencies in trailblazer.gemspec
gemspec

gem "multi_json"

gem "dry-validation"
gem "minitest-line"

gem "multi_json"
gem "trailblazer", path: "../trailblazer"
# gem "trailblazer-operation", path: "../operation"

gem "dry-validation"
2 changes: 0 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ PATH
remote: .
specs:
trailblazer-endpoint (0.0.1)
dry-matcher

GEM
remote: https://rubygems.org/
Expand Down Expand Up @@ -38,7 +37,6 @@ GEM
dry-container (~> 0.2, >= 0.2.6)
dry-core (~> 0.1)
dry-equalizer (~> 0.2)
dry-matcher (0.5.0)
dry-types (0.9.0)
concurrent-ruby (~> 1.0)
dry-configurable (~> 0.1)
Expand Down
144 changes: 144 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,150 @@

Decouple finding out *what happened* from *what to do*.

## Motivation

Trailblazer brings a lot of clarity to your controller logic,
pushing you to create operations that have a clear workflow. Each operation
returns a result object with enough information in it to evaluate what to do.
The problem now lies on the code duplication that one is forced to write to
evaluate a set of possible cases, generally solved the same way for each
controller.

E.g. an unauthenticated request should always be resolved in the same way
(exclude special cases)

From this idea, Endpoint gem came to life. Wrapping some of the most common
cases with a common solution for them. This allows you to don't have to worry
with the returning values of each of your operations.

Naturally, not everyone has common cases and, in the light of Trailblazer
flexibility, you can override all the behavior at any level of the app.

## Usage

### If your operation does not have a representer specified

Consider the following controller

```ruby
class Api::V1::MyMagicController < Api::V1::BaseController
def index
result = V1::MyMagic::Index.(params, current_user: current_user)
response = Trailblazer::Endpoint.(result, V1::MyMagic::Representer::Index)
render json: response[:data], status: response[:status]
end
end
```

As you can see, the controller calls your operation that will return a result
object. This object is then passed to `Trailblazer::Endpoint` that will inspect
the result object and decide what to put in the response. Typically, response
object will have a param `data` and a `status`.

E.g. if the operation is `successful`, then `data` will have the representation
of the `model` while `status` will have `:ok`.

### If your operation has a representer specified

Consider the following controller

```ruby
class Api::V1::MyMagicController < Api::V1::BaseController
def index
result = V1::MyMagic::Index.(params, current_user: current_user)
response = Trailblazer::Endpoint.(result)
render json: response[:data], status: response[:status]
end
end
```

The main difference is that Endpoint during the inspection will fetch
the representer class automatically. This way you don't need to pass a
representer to the Endpoint. All the remaining logic is still valid.

## How to override the default matchers?

As promised in the motivation, if you feel the need to override a specific
matcher, you can do so both globally (good for when your whole application
needs this logic) or locally (good if a specific endpoint is expected to behave
differently).


Consider the following controller

```ruby
class Api::V1::MyMagicController < Api::V1::BaseController
def index
op_res = V1::MyMagic::Index.(params, current_user: current_user)
success_proc = ->(result, _representer) do
{ "data": result["response"], "status": :ok }
end
response = Trailblazer::Endpoint.(op_res, nil, success: { resolve: success_proc })
render json: response[:data], status: response[:status]
end
end
```

In this particular case, the default behavior for a successful operation is not
what we want to use. So we can write our own resolution and pass it as part
of the overrides. Because we pass it to the endpoint as the `resolve` proc for
the matcher `success`, the existing `success` rule will still be evaluated and
in case it returns true, our `success_proc` will be invoked instead.

Likewise we can override the matching logic.

Consider the following controller

```ruby
class Api::V1::MyMagicController < Api::V1::BaseController
def index
op_res = V1::MyMagic::Index.(params, current_user: current_user)
success_proc = ->(result) { result.success? && result['models'].count > 0 }
response = Trailblazer::Endpoint.(op_res, nil, success: { rule: success_proc })
render json: response[:data], status: response[:status]
end
end
```

You can easily understand that we are now overriding the rule proc with one of
our custom made rules. The resolution would still be the default one.

## Completely custom solution

As you can imagine we can't think of all possible solutions for every single
use case. So this gem still gives you the flexibility of creating your own
matchers and resolutions. Any custom matcher will be evaluated before the
default ones. If you provide a custom matcher with an existing name, you'll be
overriding the whole matcher with your own solution.

Consider the following controller

```ruby
class Api::V1::MyMagicController < Api::V1::BaseController
def index
op_res = V1::MyMagic::Index.(params, current_user: current_user)
success = { success: {
rule: ->(result) { result.success? && result['models'].count > 0 },
resolve: ->(result, _representer) { { data: "more than 0", status: :ok } }
}
}
super_special = {
super_special: {
rule: ->(result) { result.success? && result['models'].count > 100 },
resolve: ->(result, _representer) { { data: "more than 100", status: :ok } }
}
}
response = Trailblazer::Endpoint.(op_res, nil, { super_special: super_special, success: success } )
render json: response[:data], status: response[:status]
end
end
```

In this more complex example, we are creating a custom `super_special` matcher
and overriding the `success`. Please note that the order is important and as
mentioned before, custom matchers will be evaluated before the default ones.
This applies for the overridden ones as well.

t test/controllers/songs_controller_test.rb --backtrace

## TODO
Expand Down
148 changes: 106 additions & 42 deletions lib/trailblazer/endpoint.rb
Original file line number Diff line number Diff line change
@@ -1,53 +1,117 @@
require "dry/matcher"

module Trailblazer
class Endpoint
# this is totally WIP as we need to find best practices.
# also, i want this to be easily extendable.
Matcher = Dry::Matcher.new(
present: Dry::Matcher::Case.new( # DISCUSS: the "present" flag needs some discussion.
match: ->(result) { result.success? && result["present"] },
resolve: ->(result) { result }),
success: Dry::Matcher::Case.new(
match: ->(result) { result.success? },
resolve: ->(result) { result }),
created: Dry::Matcher::Case.new(
match: ->(result) { result.success? && result["model.action"] == :new }, # the "model.action" doesn't mean you need Model.
resolve: ->(result) { result }),
not_found: Dry::Matcher::Case.new(
match: ->(result) { result.failure? && result["result.model"] && result["result.model"].failure? },
resolve: ->(result) { result }),
# TODO: we could add unauthorized here.
unauthenticated: Dry::Matcher::Case.new(
match: ->(result) { result.failure? && result["result.policy.default"].failure? }, # FIXME: we might need a &. here ;)
resolve: ->(result) { result }),
invalid: Dry::Matcher::Case.new(
match: ->(result) { result.failure? && result["result.contract.default"] && result["result.contract.default"].failure? },
resolve: ->(result) { result })
)

# `call`s the operation.
def self.call(operation_class, handlers, *args, &block)
result = operation_class.(*args)
new.(result, handlers, &block)
DEFAULT_MATCHERS = {
created: {
rule: ->(result) { result.success? && result["model.action"] == :new },
resolve: lambda do |result, representer|
{ "data": representer.new(result[:model]), "status": :created }
end
},
deleted: {
rule: ->(result) { result.success? && result["model.action"] == :destroy },
resolve: lambda do |result, _representer|
{ "data": { id: result[:model].id }, "status": :ok }
end
},
found: {
rule: ->(result) { result.success? && result["model.action"] == :find_by },
resolve: lambda do |result, representer|
{ "data": representer.new(result[:model]), "status": :ok }
end
},
success: {
rule: ->(result) { result.success? },
resolve: lambda do |result, representer|
data = if representer
representer.new(result[:results])
else
result[:results]
end
{ "data": data, "status": :ok }
end
},
unauthenticated: {
rule: ->(result) { result.policy_error? },
resolve: ->(_result, _representer) { { "data": {}, "status": :unauthorized } }
},
not_found: {
rule: ->(result) { result.failure? && result["result.model"]&.failure? },
resolve: lambda do |result, _representer|
{
"data": { errors: result["result.model.errors"] },
"status": :unprocessable_entity
}
end
},
invalid: {
rule: ->(result) { result.failure? },
resolve: lambda do |result, _representer|
{
"data": { errors: result.errors || result[:errors] },
"status": :unprocessable_entity
}
end
},
fallback: {
rule: ->(_result) { true },
resolve: lambda do |_result, _representer|
{ "data": { errors: "Can't process the result" },
"status": :unprocessable_entity }
end
}
}.freeze

# NOTE: options expects a TRB Operation result
# it might have a representer, else will assume the default name
def self.call(operation_result, representer_class = nil, overrides = {})
endpoint_opts = { result: operation_result, representer: representer_class }
new.(endpoint_opts, overrides)
end

def call(result, handlers=nil, &block)
matcher.(result, &block) and return if block_given? # evaluate user blocks first.
matcher.(result, &handlers) # then, generic Rails handlers in controller context.
def call(options, overrides)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In examples and tests you have one more argument

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, the tests are not yet updated to match the final implementation. They were following my mid way solution. There is definitely a little bit of work to be done here for the tests and readme. Was trying to get the general feeling for the actual solution.

overrides.each do |rule_key, rule_description|
rule = rule_description[:rule] || DEFAULT_MATCHERS[rule_key][:rule]
resolve = rule_description[:resolve] || DEFAULT_MATCHERS[rule_key][:resolve]

if rule.nil? || resolve.nil?
puts "Matcher is not properly set. #{rule_key} will be ignored"
next
end

return resolve.(options[:result], options[:representer]) if rule.(options[:result])
end
matching_rules(overrides).each do |_rule_key, rule_description|
if rule_description[:rule].(options[:result])
return rule_description[:resolve].(options[:result], options[:representer])
end
end
end

def matcher
Matcher
def matching_rules(overrides)
DEFAULT_MATCHERS.except(*overrides.keys)
end
end

class Operation
class Result
def errors
return self["result.policy.failure"] if policy_error?
return self["contract.default"].errors.full_messages if contract_error?
return self["result.model.errors"] if model_error?

self[:errors]
end

def policy_error?
self["result.policy.failure"].present?
end

def model_error?
self["result.model.errors"].present?
end

module Controller
# endpoint(Create) do |m|
# m.not_found { |result| .. }
# end
def endpoint(operation_class, options={}, &block)
handlers = Handlers::Rails.new(self, options).()
Endpoint.(operation_class, handlers, *options[:args], &block)
def contract_error?
self["contract.default"].present?
end
end
end
Expand Down
1 change: 1 addition & 0 deletions test/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.byebug_history
Loading