/light_service-validated_context

LightService plugin adding DSL for expected/promised keys validation

Primary LanguageRubyMIT LicenseMIT

LightService - ValidatedContext

Ruby

This gem patches light-service gem implementing validated keys for expects and promises action's macros.

This gem is a plugin to light-service, thus it depends on it (~> 0.18.0).

What do I mean with validation

  • type check
  • type coercion
  • mandatory/optional presence
  • default value

Stability, affordability

This plugin uses monkey patching in order to alter the behaviour of light-service. AFAIK this is the only way to achieve the goal. Because of this fact I consider light_service-validated_context more of an experiment/POC.

Goals

  • implement an advanced and flexible interface to declare, type-check, coerce and describe action's arguments without reinventing the wheel (the wheel we use under the wood is dry-types)
  • testing DX and interfaces
  • study what parts of code are involved into this area of light-service's code base

Installation

Add this line to your application's Gemfile:

gem 'light_service-validated_context'

And then execute:

$ bundle install

Would you need to manualy require the gem, here's the syntax:

require 'light_service/validated_context'

Usage

The plugin enables you to pass VK (ValidatedKeys) objects as arguments to built-ins expects and promises macros.

This is how you'd usually write an Action in LightService:

class ActionOne
  extend LightService::Action

  expects :age
  promises :text

  executed do |context|
    validate_age!(context)

    # Do something...

    context.text = 'Long live and prosperity'
  end

  def self.validate_age!(context)
    context.fail_and_return!(':age must be an Integer') unless context.age.is_a? Integer
    context.fail_and_return!('Sorry, you are too young m8') if (context.age <= 30)
  end
end

and this is how light_service-validated_context enables you to write

class ActionOne
  extend LightService::Action

  expects VK.new(:age, Types::Coercible::Integer.constrained(gt: 30))
  promises VK.new(:text, Types::Strict::String.constrained(max_size: 10).default('Long live and prosperity'))

  executed do |context|
    # Do something
  end
end

and you'll get validations for free

ActionOne.execute(age: '19')
# [App::ActionOne][:age] "19" violates constraints (gt?(30, 19) failed) (LightService::ExpectedKeysNotInContextError)
ActionOne.execute(age: 37)
# LightService::Context({:age=>37, :text=>"Long live and prosperity"}, success: true, message: '', error_code: nil, skip_remaining: false, aliases: {})
ActionOne.execute(age: 37, text: 'Too long too pass the constrain')
# [App::ActionOne][:text] "Too long too pass the constrain" violates constraints (max_size?(24, "Too long too pass the constrain") failed) (LightService::PromisedKeysNotInContextError)

Since all the validation and coercion logic is delegated to dry-types, you can read more about what you can achieve at https://dry-rb.org/gems/dry-types/main/custom-types/

VK objects needs to be created with 2 positional arguments:

  • key name as a symbol
  • A type declaration from dry-types (Tyeps namespace is already setup for you)

VK and ValidatedKey (equivalent) are short aliases for LightService::Context::ValidatedKey. They are created only if not already defined in the global space. You're free to use the namespaced form to avoid name collisions.

You can find more usage example in spec/support/test_doubles.rb

Custom validation error message

You can set a custom validation error message when instantiating a VK object

VK.new(:my_integer, Types::Strict::Integer, message: 'Custom validation message for :my_integer key')

Messages translated via I18n are supported too, following standard light-service's configuration

VK.new(:my_integer, Types::Strict::Integer, message: :my_integer_error_message)

Raise vs fail

By default, following original light-service implementation, a validation error will raise a LightService::ExpectedKeysNotInContextError or LightService::PromisedKeysNotInContextError.

NOTE: I know that raised exceptions do not express the concept of "invalid", but I opted to preserve the original one in order to make this plugin more droppable-in as possible, thus w/o breaking code relying on, for example, rescueing those specific excpetions.

May you prefere to fail the action, populating outcome's message with error message, just do extend LightService::Context::FailOnValidationError into you action:

class ActionFailInsteadOfRaise
  extend LightService::Action
  extend LightService::Context::FailOnValidationError

  expects VK.new(:foo, Types::String)

  executed do |context|
    # do something
  end
end

result = ActionFailInsteadOfRaise.execute(foo: 12)
result.message # Here you'll find the validation(s) message(s)

Custom types

As documented in dry-types doc, you can be more expressive defining custom types; you can define them reopening the already defined LightService::Types module (or simply Types in the global namespace if it does not conflict with your domain's namespace), e.g.:

module LightService::Types
  MyExpressiveThing = Hash.schema(
    name: String,
    age: Coercible::Integer,
    foo: Symbol.constrained(included_in: %i[bar baz])
  )
end

class ActionOne
  extend LightService::Action
  extend LightService::Context::FailOnValidationError

  expects VK.new(:foo, Types::MyBusinessHash)

  executed do |context|
    # do something...
  end
end

result = App::ActionOne.execute(foo: {
  name: 'Alessandro',
  age: '37',
  foo: :bar
})

Custom types will be reusable, more expressive and moreover will clean your action up a bit.

Why validation matters?

In OO programming there's a rule that says to never instantiate an invalid object.

If you cannot trust the state, given the state is internal or delegated to a context object, you'll have to do a bunch of validation-oriented logical branches into your logic. E.g.:

class HugAFriend
  extend LightService::Action

  expects :friend

  executed do |context|
    context.friend.hug if context.friend.respond_to?(:hug)
  end
end

The if in this uber-trivial example exists just due to untrusted state.

Let's re-imagine the code given an executed block that totally trusts the context:

class HugAFriend
  extend LightService::Action

  expects VK.new(:friend, Types.Instance(Friend))
  # Or a less usual approach could be to trust duck typing
  # expects VK.new(:friend, Types::Interface(:hug))
  # Actually not all friends do appreciate hugs nor other forms of physical contact :P

  executed do |context|
    context.friend.hug
  end
end

Comparison with similar gems

A brief comparison about what similar gems offer to work with validation.

This is a comparison table I've done using my own limited experience w/ other solutions and/or reading projects' READMEs. Don't take my word for it. And if I was wrong understanding some features, feel free to drop me a line on Mastodon @alessandrofazzi@mastodon.uno

Feature adomokos/light-service sunny/actor collectiveidea/interactor AaronLasseigne/active_interaction pioneerskies/light_service-validated_context/
presence ⚠️ Only input, not output
static default
dynamic default
raise or fail control
type check
data structure type check
optional ⚠️ through default ✅ through allow_nil (which defaults to true 🤔 ❓) ⚠️ through default
1st party code ⚠️ ActiveModel::Validation ❌ Dry::Types

NOTE: in active_interaction the fact that validation code isn't first party isn't an issue, since the gem is a Rails-only gem and validation is delegated to Rails, thus no additional dependencies are required. light_service-validated_context depends on additional gems from the dry-rb ecosystem

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/pioneerskies/light_service-validated_context. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the LightService::ValidatedContext project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.