Eapi (Elastic API)
Ruby gem for building complex structures that will end up in hashes or arrays
Main features:
- property definition
- automatic fluent accessors
- list support
- validation
- rendering to
HashorArray - custom conversion of values
- raw
HashorArraysupport to skip type check validations - omit
nilvalues automatically - custom preparation of values before validation
WIP Warning
Until the release of the first stable version 1.0, new versions can contain breaking changes.
Usage
Eapi work by exposing a couple of modules to include in your classes.
-
Eapi::Itemmodule to create objects with multiple properties that will render into hashes. -
Eapi::Listmodule to create lists that will render into arrays.
# ITEM
class MyItem
include Eapi::Item
# defining some properties
property :something
property :other, type: Fixnum
property :third, multiple: true
end
i = MyItem.new something: 1
i.something # => 1
i.other(2).add_third(3)
i.render # => {something: 1, other: 2, third: [3]}
i.to_h # => {something: 1, other: 2, third: [3]}
# LIST
class MyList
include Eapi::List
elements required: true
end
l = MyList.new
l.add(1).add(2)
l.render # => [ 1, 2 ]
l.to_a # => [ 1, 2 ]This will provide:
- a DSL to define properties and elements validations and rules
- fluent accessor methods for each property
- a keyword arguments enabled
initializemethod - a shortcut for object creation sending messages to the
Eapimodule directly with the name of the class.
We'll se this in detail.
Common behaviour to Item and List
The following features are shared between Lists and Items.
Convert to hashes: render and perform_render
All Eapi objects respond to render and return by default a hash (for Item objects) or an array (for List objects), as it is the main purpose of this gem. It will execute any validation (see property definition), and if everything is ok, it will convert it to the simple structure.
Item objects will invoke render when receiving to_h, while List objects will do the same when receiving to_a.
Methods involved
Inside, render will call valid?, raise an error of type Eapi::Errors::InvalidElementError if something is not right, and if everything is ok it will call perform_render.
On Item objects, the perform_render method will create a hash with the properties as keys. Each value will be "converted" (see "Values conversion" section). On List objects, the perform_render will create an array, with all
Values conversion
By default, each property will be converted into a simple element (Array, Hash, or simple value).
- If a value responds to
render, it will call that method. That way, Eapi objects that are values of some properties or lists will be validated and rendered (converted) themselves (render / value conversion cascade). - If a value is an Array or a Set,
to_awill be invoked and all values will be converted in the same way. - If a value respond to
to_h, it will be called.
Custom value conversion
We can override the default value conversion using the convert_with option in the property definition. It will accept:
- a symbol (message to be sent to the value)
- callable object (lambda, proc...) that accepts either 1 parameter (the value) or 2 parameters (the value and the object)
class ExampleItem
include Eapi::Item
property :something, convert_with: :to_s
property :other, convert_with: ->(val) { "This is #{val}" }
property :third, convert_with: ->(val, obj) do
s = obj.something
c = obj.send :converted_value_for, :something
"I am #{val} with some #{s.inspect} as #{c.inspect}"
end
end
x = ExampleItem.new something: :x, other: 1, third: 'the third'
x.render # =>
# {
# # converted to string (`to_s` method called)
# something: 'x',
#
# # converted using the lambda
# other: 'This is 1',
#
# # converted using the lambda, with access to the context object
# third: "I am the third with some :x as \"x\""
# }On a List object, it will work as conversion for every element.
class ExampleList
include Eapi::List
elements convert_with: :to_s
end
x = ExampleItem.new.add(1).add(2)
x.render # => ["1", "2"]Prepare values before validation using prepare_with option
Same as convert_with but it is used before validation.
class ExampleItem
include Eapi::Item
property :something, prepare_with: to_i
property :normal,
validates :something, inclusion: { in: [1, 2, 3] }
validates :normal, inclusion: { in: [1, 2, 3] }
end
# the conversion happens before validation, so it passes
x = ExampleItem.new something: '1', normal: 2
x.valid? # => true
# the conversion happens on rendering, after validation, so it fails
x = ExampleItem.new something: 1, normal: '2'
x.valid? # => falseIn List it will work on elements.
class ExampleListConvertBeforeValidationEnabled
include Eapi::List
elements prepare_with: :to_i, validate_with: ->(record, attr, value) do
record.errors.add(attr, 'must pass my custom validation') unless value.kind_of?(Fixnum)
end
end
class ExampleListConvertBeforeValidationDisabled
include Eapi::List
elements validate_with: ->(record, attr, value) do
record.errors.add(attr, 'must pass my custom validation') unless value.kind_of?(Fixnum)
end
end
# the conversion happens before validation, so it passes
x = ExampleListConvertBeforeValidationEnabled.new.add('1')
x.valid? # => true
# the conversion happens on rendering, after validation, so it fails
x = ExampleListConvertBeforeValidationDisabled.new.add('1')
x.valid? # => falseIgnoring values
By default, any nil values will be omitted in the final structure by the perform_render method, in both Item and List.
With the ignore option in the definition, that behaviour can be modified.
-
ignore: false: that no value will be ignored -
ignore: :blank?: with a string or a symbol in theignoreoption, the value will be ignored if it respond truthy to the message represented by thatignoreoption. (ie:ignore: :blank?=> all values that return truthy tovalue.blank?will be ignored) -
ignore: Proc|Callable|Lambda: with a callable object in theignoreoption, the value will be ignored if the callable object respond truthy when invoked with the value as argument.
class ExampleItem
include Eapi::Item
# by default, ignored if value is `nil`
property :prop1
# ignored if value returns truthy to `.blank?`
property :prop2, ignore: :blank?
# never ignored
property :prop3, ignore: false
# ignored if the callable object returns true when `.call(value)` (in this case, if the value is equal to "i don't want you")
property :prop4, ignore: Proc.new {|val| val == "i don't want you"}
endExample
To demonstrate this behaviour we'll have an Eapi enabled class ExampleEapi and another ComplexValue class that responds to to_h. We'll set into the ExampleEapi object complex properties to demonstrate the conversion into a simple structure.
class ComplexValue
def to_h
{
a: Set.new(['hello', 'world', MyTestObject.new])
}
end
end
class ExampleEapi
include Eapi::Item
property :something, required: true
property :other
end
# TESTING `render`
list = Set.new [
OpenStruct.new(a: 1, 'b' => 2),
{c: 3, 'd' => 4},
nil
]
eapi = ExampleEapi.new something: list, other: ComplexValue.new
# same as eapi.to_h
eapi.render # =>
# {
# something: [
# {a: 1, b: 2},
# {c: 3, d: 4},
# ],
#
# other: {
# a: [
# 'hello',
# 'world',
# {a: 'hello'}
# ]
# }
# }Pose as other types
An Eapi class can poses as other types, for purposes of type checking in a property definition. We use the class method is for this.
the is? method is also available as an instance method.
Eapi also creates specific instance and class methods like is_a_some_type? or is_an_another_type?.
example:
class SuperTestKlass
include Eapi::Item
end
class TestKlass < SuperTestKlass
is :one_thing, :other_thing, OtherType
end
TestKlass.is? TestKlass # => true
TestKlass.is? 'TestKlass' # => true
TestKlass.is? :TestKlass # => true
TestKlass.is? SuperTestKlass # => true
TestKlass.is? 'SuperTestKlass' # => true
TestKlass.is? :SuperTestKlass # => true
TestKlass.is? :one_thing # => true
TestKlass.is? :other_thing # => true
TestKlass.is? :other_thing # => true
TestKlass.is? OtherType # => true
TestKlass.is? :OtherType # => true
TestKlass.is? SomethingElse # => false
TestKlass.is? :SomethingElse # => false
# also works on instance
obj = TestKlass.new
obj.is? TestKlass # => true
obj.is? :one_thing # => true
# specific type test methods
TestKlass.is_a_test_klass? # => true
TestKlass.is_an_one_thing? # => true
TestKlass.is_a_super_duper_thing? # => false
obj.is_a_test_klass? # => true
obj.is_an_one_thing? # => true
obj.is_a_super_duper_thing? # => falseObject creation shortcut: calling methods in Eapi
Calling a method with the desired class name in Eapi module will do the same as DesiredClass.new(...). The name can be the same as the class, or an underscorised version, or a simple underscored one.
The goal is to use Eapi.esr_search(name: 'Paco') as a shortcut to Esr::Search.new(name: 'Paco'). We can also use Eapi.Esr_Search(...) and other combinations.
To show this feature and all the combinations for method names, we'll use the 2 example classes that are used in the actual test rspec.
class MyTestKlassOutside
include Eapi::Item
property :something
end
module Somewhere
class TestKlassInModule
include Eapi::Item
property :something
end
endAs shown by rspec run:
initialise using method calls to Eapi
Eapi.MyTestKlassOutside(...)
calls MyTestKlassOutside.new
Eapi.my_test_klass_outside(...)
calls MyTestKlassOutside.new
Eapi.Somewhere__TestKlassInModule(...)
calls Somewhere::TestKlassInModule.new
Eapi.somewhere__test_klass_in_module(...)
calls Somewhere::TestKlassInModule.new
Eapi.Somewhere_TestKlassInModule(...)
calls Somewhere::TestKlassInModule.new
Eapi.somewhere_test_klass_in_module(...)
calls Somewhere::TestKlassInModule.new
Eapi::Item: Property based Item objects
initialize method
Eapi::Item will add a initialize method to your class that will accept a hash. It will recognise the defined properties in that hash and will set them.
important: For now any unrecognised property in the hash will be ignored. This may change in the future.
class MyTestKlass
include Eapi::Item
property :something
end
x = MyTestKlass.new something: 1
x.something # => 1Defining properties
We define properties in our class with the instruction property as shown:
class MyTestKlass
include Eapi::Item
property :one
property :two
endSetting properties on object creation
We can then assign the properties on object creation:
x = MyTestKlass.new one: 1, two: 2Getters
A getter method will be created for each property
x = MyTestKlass.new one: 1, two: 2
x.one # => 1
x.two # => 2Setters
Also, a setter will be created for each property
x = MyTestKlass.new one: 1, two: 2
x.one = :other
x.one # => :otherFluent setters (for method chaining)
Besides the normal setter, a fluent setter (set_my_prop) will be created for each property. self is returned on this setters, enabling Method Chaining.
x = MyTestKlass.new one: 1, two: 2
res = x.set_one(:other)
x.one # => :other
res.equal? x # => true
x.set_one(:hey).set_two(:you)
x.one # => :hey
x.two # => :youGetter method as fluent setter
The getter method also works as fluent setter. If we pass an argument to it, it will call the fluent setter
x = MyTestKlass.new
res = x.one :fluent
x.one # => :fluent
res.equal? x # => true
converted_or_default_value_for method (aka final_value_for)
It will return the converted value for the property. If that value is to be ignored (following the rules described with the ignore option) then it will return the default one (defined by the default option), or nil if there is no default value.
class TestKlassWithDefault
include Eapi::Item
property :something, ignore: :blank?, default: 123
end
x = TestKlassWithDefault.new
x.something ''
x.converted_or_default_value_for(:something) # => 123
x.final_value_for(:something) # => 123
x.something 'not blank'
x.converted_or_default_value_for(:something) # => 'not blank'
x.final_value_for(:something) # => 'not blank'
class TestKlassWithoutDefault
include Eapi::Item
property :something, ignore: :blank?
end
x = TestKlassWithoutDefault.new
x.something ''
x.converted_or_default_value_for(:something) # => nil
x.final_value_for(:something) # => nil
x.something 'not blank'
x.converted_or_default_value_for(:something) # => 'not blank'
x.final_value_for(:something) # => 'not blank'
yield_final_value_for method
It will yield the converted value for the property. If that value is to be ignored (following the rules described with the ignore option) then it will yield the default one (defined by the default option), or it won't yield anything if there is no default value.
class TestKlassWithDefault
include Eapi::Item
property :something, ignore: :blank?, default: 123
end
x = TestKlassWithDefault.new
x.something ''
check = :initial_check
x.yield_final_value_for(:something) { |v| check = v }
check # => 123
x.something 'not blank'
check = :initial_check
x.yield_final_value_for(:something) { |v| check = v }
check # => 'not blank'
class TestKlassWithoutDefault
include Eapi::Item
property :something, ignore: :blank?
end
x = TestKlassWithoutDefault.new
check = :initial_check
x.yield_final_value_for(:something) { |v| check = v }
check # => :initial_check
x.something 'not blank'
check = :initial_check
x.yield_final_value_for(:something) { |v| check = v }
check # => 'not blank'Property definition
When defining the property, we can specify some options to specify what values are expected in that property. This serves for validation and automatic initialisation.
It uses ActiveModel::Validations. When to_h is called in an Eapi object, the valid? method will be called and if the object is not valid an Eapi::Errors::InvalidElementError error will raise.
Validations from ActiveModel::Validations
All other ActiveModel::Validations can be used:
class TestKlass
include Eapi::Item
property :something
validates :something, numericality: true
end
eapi = TestKlass.new something: 'something'
eapi.valid? # => false
eapi.errors.full_messages # => ["Something is not a number"]
eapi.errors.messages # => {something: ["must is not a number"]}Mark a property as Required with required option
A required property will fail if the value is not present. It will use ActiveModel::Validations inside and will effectively do a validates_presence_of :property_name.
example:
class TestKlass
include Eapi::Item
property :something, required: true
end
eapi = TestKlass.new
eapi.valid? # => false
eapi.errors.full_messages # => ["Something can't be blank"]
eapi.errors.messages # => {something: ["can't be blank"]}Establish a default value for the property in case of to-be-ignored value with default option
A property whose value is ignored (according with the ignore option), will use the value specifie in the default option.
example:
class TestKlass
include Eapi::Item
property :something, ignored: :blank?, default: 123
end
eapi = TestKlass.new
# setting a value that will be ignored (in this example, an empty string, that will respond trythy to `.blank?`)
eapi.something ""
eapi.render # => {something: 123}Specify the property's Type with type option
If a property is defined to be of a specific type, the value will be validated to meet that criteria. It means that the value must be of the specified type. It will use value.kind_of?(type) (if type represents an actual class), and if that fails it will use value.is?(type) if defined.
example:
class TestKlass
include Eapi::Item
property :something, type: Hash
end
eapi = TestKlass.new something: 1
eapi.valid? # => false
eapi.errors.full_messages # => ["Something must be a Hash"]
eapi.errors.messages # => {something: ["must be a Hash"]}Custom validation with validate_with option
A more specific validation can be used using validate_with, that works the same way as ActiveModel::Validations.
example:
class TestKlass
include Eapi::Item
property :something, validate_with: ->(record, attr, value) do
record.errors.add(attr, "must pass my custom validation") unless value == :valid_val
end
end
eapi = TestKlass.new something: 1
eapi.valid? # => false
eapi.errors.full_messages # => ["Something must pass my custom validation"]
eapi.errors.messages # => {something: ["must pass my custom validation"]}List properties
A property can be defined as a multiple property. This will affect the methods defined in the class (it will create a fluent 'adder' method add_property_name and a fluent 'clearer' method clear_property_name), and also the automatic initialisation.
Define property as multiple with multiple option
A property marked as multiple will be initialised with an empty array. If no init_class is specified then it will use Array as a init_class, for purposes of the init_property_name method.
class TestKlass
include Eapi::Item
property :something, multiple: true
endFluent adder method add_property_name
For a property marked as multiple, an extra fluent method called add_property_name will be created. This work very similar to the fluent setter set_property_name but inside it will append the value (using the shovel method <<) instead of setting it.
If the property is nil when add_property_name is called, then it will call init_property_name before.
class TestKlass
include Eapi::Item
property :something, multiple: true
end
x = TestKlass.new
x.add_something(1).add_something(2)
x.something # => [1, 2]Fluent clearer method clear_property_name
For a property marked as multiple, an extra fluent method called clear_property_name will be created. This method will call clear into the existing property value if it is present and respond to it. If that is not the case, it will init the property again calling init_property_name.
class TestKlass
include Eapi::Item
property :something, multiple: true
end
x = TestKlass.new
x.add_something(1).add_something(2)
x.something # => [1, 2]
x.clear_something.something # => []Implicit multiple depending on init_class or type
Even without multiple option specified, if the init_class option is:
ArraySet- a class that responds to
is_multiple?with true
then the property is marked as multiple.
It will also work if the type option is given with a class or a class name that complies with the above restrictions.
example: (all TestKlass properties are marked as multiple)
class MyCustomList
def self.is_multiple?
true
end
def <<(val)
@list |= []
@list << val
end
end
class TestKlass
include Eapi::Item
property :p1, multiple: true
property :p2, init_class: Array
property :p3, init_class: "Set"
property :p4, type: Set
property :p5, type: "MyCustomList"
end
x = TestKlass.new
x.add_p1(1).add_p2(2).add_p3(3).add_p4(4)Element validation
Same as property validation, but for specific the elements in the list.
We can use element_type option in the definition, and it will check the type of each element in the list, same as type option does with the type of the property's value.
We can also specify validate_element_with option, and it will act the same as validate_with but for each element in the list.
class TestKlass
include Eapi::Item
property :something, multiple: true, element_type: Hash
property :other, multiple: true, validate_element_with: ->(record, attr, value) do
record.errors.add(attr, "element must pass my custom validation") unless value == :valid_val
end
end
eapi = TestKlass.new
eapi.add_something 1
eapi.valid? # => false
eapi.errors.full_messages # => ["Something element must be a Hash"]
eapi.errors.messages # => {something: ["must element be a Hash"]}
eapi.something [{a: :b}]
eapi.valid? # => true
eapi.add_other 1
eapi.valid? # => false
eapi.errors.full_messages # => ["Other element must pass my custom validation"]
eapi.errors.messages # => {other: ["element must pass my custom validation"]}
eapi.other [:valid_val]
eapi.valid? # => trueAutomatic property initialisation with init_class option
If a property is marked to be initialised using a specific class, then a init_property_name method is created that will set a new object of the given class in the property.
class TestKlass
include Eapi::Item
property :something, init_class: Hash
end
eapi = TestKlass.new
eapi.something # => nil
eapi.init_something
eapi.something # => {}A symbol or a string can also be specified as class name in init_class option, and it will be loaded on type check. This can be helpful to avoid loading problems. Using the same example as before:
class TestKlass
include Eapi::Item
property :something, type: "Hash"
end
eapi = TestKlass.new
eapi.something # => nil
eapi.init_something
eapi.something # => {}To trigger the error, the value must not be an instance of the given Type, and also must not respond true to value.is?(type)
Skip type validation with 'raw' values with allow_raw option
If we want to check for the type of the elements, but still want the flexibility of using raw Hash or Array in case we want something specific there, we can specify it with the allow_raw option.
With this, eapi will let you skip the type validation when the value is either a Hash or an Array, assuming that "you know what you are doing".
class ValueKlass
include Eapi::Item
property :value
end
class TestKlass
include Eapi::Item
property :something, type: ValueKlass, allow_raw: true
property :somelist, multiple: true, element_type: ValueKlass, allow_raw: true
end
class TestList
include Eapi::List
elements type: ValueKlass, allow_raw: true
end
i = TestKlass.new
i.something 1
i.valid? # => false
i.something ValueKlass.new
i.valid? # => true
i.something({some: :hash})
i.valid? # => true
i.add_somelist 1
i.valid? # => false
i.clear_somelist.add_somelist({a: :hash}).add_somelist([:an, :array])
i.valid? # => true
l = TestList.new
l.add 1
l.valid? # => false
i.clear.add(ValueKlass.new).add({a: :hash}).add([:an, :array])
l.valid? # => trueYou can also enable this option after defining the property, with the property_allow_raw and property_disallow_raw methods and check if it is enabled with property_allow_raw?. In Lists, the methods are elements_allow_raw, elements_disallow_raw and elements_allow_raw?.
class TestKlass
include Eapi::Item
property :something, type: ValueKlass
end
TestKlass.property_allow_raw?(:something) # => false
TestKlass.property_allow_raw(:something)
TestKlass.property_allow_raw?(:something) # => true
TestKlass.property_disallow_raw(:something)
TestKlass.property_allow_raw?(:something) # => false
class TestList
include Eapi::List
elements type: ValueKlass
end
TestList.elements_allow_raw? # => false
TestList.elements_allow_raw
TestList.elements_allow_raw? # => true
TestList.elements_disallow_raw
TestList.elements_allow_raw? # => falseDefinition
Unrecognised property definition options
If the definition contained any unrecognised options, it will still be stored. No error is reported yet, but this behaviour may change in the future.
See property definition with .definition_for class method
You can see (but not edit) the definition of a property calling the definition_for class method. It will also contain the unrecognised options.
class TestKlass
include Eapi::Item
property :something, type: Hash, unrecognised_option: 1
end
definition = TestKlass.definition_for :something # => { type: Hash, unrecognised_option: 1 }
# attempt to change the definition...
definition[:type] = Array
# ...has no effect
TestKlass.definition_for :something # => { type: Hash, unrecognised_option: 1 }
Eapi::List: list based objects
An Eapi List is to an Array as an Eapi Item is to a Hash.
It will render itself into an array of elements. It can store a list of elements that will be validated and rendered.
It works using an internal list of elements, to whom it delegates most of the behaviour. Its interface is compatible with an Array, including ActiveSupport methods.
important: Right now a List can also have properties like an Item, but this could change for a stable release.
accessor to internal element list: _list
The internal list of elements of an Eapi List object can be accessed using the _list method, that is always an Array.
Methods
fluent adder: add
Similar to the set_x methods for properties, this method will add an element to the internal list and return self.
elements definition: elements
Similar to the property macro to define a property and its requirements, List classes can set the definition to be used for its elements using the macro elements.
The options for that definition is:
-
required: it will provoke the list validation to fail if there is at least 1 element in the list -
unique: it will provoke the list validation to fail if there are duplicated elements in the list -
element_typeortype: it will provoke the list validation to fail if an element does not complies with the given type validation (see type validation onItem) -
validate_element_withorvalidate_with: it will execute the given callable object to validate each element, similar to thevalidate_element_withoption in the property definition.
example
class MyListKlass
include Eapi::List
elements unique: true
end
l = MyListKlass.new
# fluent adder
l.add(1).add(2).add(3)
# internal list accessor
l._list # => [1, 2, 3]
# render method (same as #to_a)
l.render # => [1, 2, 3]
l.valid? # => true
l.add(1)
l.valid? # => falseUsing Eapi in your own library
You can add the functionality of Eapi to your own library module, and use it instead of Eapi::Item or Eapi::List.
Method-call-initialise shortcut can ignore the base name:
module MyExtension
extend Eapi
end
class TestKlass
include MyExtension::Item
property :something
end
obj = MyExtension.test_klass something: 1
obj.something # => 1
# if the class is in the same module, it can be omitted when using the object creation shortcut
module MyExtension
class TestKlassInside
include MyExtension::Item
property :something
end
end
obj = MyExtension.my_extension_test_klass_inside something: 1
obj.something # => 1
obj = MyExtension.test_klass_inside something: 1
obj.something # => 1important note:
As it works now, the children of your extension will be also children of Eapi, so calling Eapi.your_klass and YourExtension.your_klass will do the same.
Installation
Add this line to your application's Gemfile:
gem 'eapi'
And then execute:
$ bundle
Or install it yourself as:
$ gem install eapi
Dependencies
Ruby version
Works with ruby 2.1, tested with MRI 2.1.1
Gem dependencies
This gem uses ActiveSupport (version 4) and also the ActiveModel Validations (version 4). It also uses fluent_accessors gem.
Extracted from the gemspec:
spec.add_dependency 'fluent_accessors', '~> 1'
spec.add_dependency 'activesupport', '~> 4'
spec.add_dependency 'activemodel', '~> 4'
TODO
-
typeoption in property definition to accept symbol -> if a class can be recognised by that name, it works ok. If not, it still uses that for type validation (usingis?) but it does not use that in theinit_method. -
typeoption to be divided ininit_type(must be a class or a class name) andcheck_type(class / class name or type validation usingis?)
Contributing
- Fork it ( https://github.com/eturino/eapi/fork )
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request