FirstResponder
A small library to coerce and validate API responses using PORO's.
Installation
Add this line to your application's Gemfile:
gem 'first_responder'
And then execute:
$ bundle
Or install it yourself as:
$ gem install first_responder
Usage
FirstResponder includes the veritable virtus and ActiveModel::Validations libraries within classes to add attributes and validations to API response objects.
This allows validation of API reponses at a "model" level, be they responses from real-world production services or Mock API's.
Classes that include FirstResponder can be instantiated with either XML or JSON and can define the required attributes for that model.
Examples
To use FirstResponder, simply include it in your class. Then specify your required attributes, as in this fictitious example:
class TwitterResponse
include FirstResponder
requires :tweet, String
requires :date, DateTime
end
Then instantiate the class:
response = TwitterResponse.new(:json, '{"tweet": "This is a tweet."}')
response.valid?
=> false
response.date = "June 22nd, 2013"
response.valid?
=> true
As long as the response contains the required attributes, the instance will be considered valid.
FirstResponder also supports attributes referencing an Array of objects, allowing Virtus to coerce those objects:
class Foo
include Virtus
attribute :foo, String
end
class Biz
include FirstResponder
requires :foos, Array[Foo], at: ""
end
We can pass an array of objects -- even at the root level in the case of JSON -- and our Biz
class will have its collection of Foos
:
json_array = '[ {"foo": "bar" }, { "foo": "bar"} ]'
biz = Biz.new(:json, json_array)
biz.foos
=> [#<Foo:0x007f876ac14be0 @foo="bar">, #<Foo:0x007f876ac1e938 @foo="bar">]
Nested Keys
FirstResponder assumes that the attribute you're defining is an unnested hash key. The following example shows how to enable nested hash keys:
class Magician
include FirstResponder
requires :surprise, String, at: "[:black][:hat]" # or using strings "['black']['hat']"
end
Then instantiate with JSON/XML as before:
trick = '{"black": {"hat": "RABBIT!"}}'
magician = Magician.new(:json, trick)
And, as one might have seen coming:
magician.surprise
=> "RABBIT!"
Were the black hat empty, the magician would, of course, not be valid ;)
The previous example also highlights a second hidden feature in the at
parameter: aliasing.
If you want to refer to a JSON/XML node by a different name, simply require the attribute as you wish it to be called, pointing to its hash location.
The Root
But what if all of your desired information is nested deeply within XML/JSON, always under the same outer node?
Because we're all lazy and efficient, FirstResponder offers the ability to define a root element, which serves as the jumping off point for all other attributes using at
:
class Treasure
include Virtus
attribute :type, String
attribute :weight, Integer
attribute :unit, String
end
class TreasureHunt
include FirstResponder
root "[:ocean][:sea_floor][:treasure_chest][:hidden_compartment]"
requires :treasure, Treasure
end
So when we get back our sunken treasure response, and it contains multiple attributes we don't really care about, the code above allows us to skip straight to the good stuff!
response = '{"ocean":
{ "sea_floor":
{"treasure_chest":
{"hidden_compartment":
{ "treasure": { "type": "Gold", "weight": 1, "unit": "Ton" }}}}}}'
treasure_hunt = TreasureHunt.new(:json, response)
treasure_hunt.treasure
=> #<Treasure:0x007fe50c98c990 @type="Gold", @weight=1, @unit="Ton">
Treasure that.
Nested Validations
FirstResponder will also detect problems lurking beneath the surface by automatically searching for and validating nested attributes.
Take the previous example of a TreasureHunt
and Treasure
classes, this time including FirstResponder and requiring the presence of certain attributes.
A TreasureHunt
, after all, is only valid if the Treasure
it finds is:
class TreasureHunt
include FirstResponder
root "[:ocean][:sea_floor][:treasure_chest][:hidden_compartment]"
requires :treasure, Treasure
end
class Treasure
include FirstResponder
requires :type, String
requires :weight, Integer
requires :unit, String
end
We instantiate our TreasureHunt
this time, however, with what appears to be a Treasure
, but isn't:
response = '{"ocean":
{ "sea_floor":
{"treasure_chest":
{"hidden_compartment":
{ "treasure": { "type": null, "weight": null, "unit": null}}}}}}'
treasure_hunt = TreasureHunt.new(:json, response)
treasure_hunt.treasure
Coercion still works, but the Treasure
object that's been created is devoid of all value. It is itself, of course, invalid:
treasure_hunt.treasure.valid?
=> false
But since FirstResponder knows that our TreasureHunt
requires a Treasure
, our TreasureHunt
is also rendered invalid:
treasure_hunt.valid?
=> false
The Invalid Callback
FirstResponder also allows an object to execute arbitrary code when the object isn't valid. It is defined on the class and triggered when #invalid?
is true or #valid?
is false:
class InvalidWithCallback
include FirstResponder
requires :important_attr, String
requires :another, String
when_invalid { |data, errors| puts data }
end
with_callback = InvalidWithCallback.new(:json, '{"foo":"bar"}')
with_callback.valid?
{"foo"=>"bar"}
=> false
As you can tell from the example above, the code will be executed by default whenever valid?
is called before the boolean value is returned. Should you desire a return value without executing the callback in a specific intsance, you can supply false
to the valid?
and invalid?
methods:
with_callback.valid?(false)
=> false
with_callback.invalid?(false)
=> true
ActiveModel::Validations
Because FirstResponder uses ActiveModel::Validations under the covers, you can use most of the API you already know to validate individual attributes. Of course, this excludes those checks relying on persistence (i.e. uniqueness) or attempts to validate an object using Virtus coercion.
class Baz
include FirstResponder
requires :foo, String, format: { with: /bar/ }
end
This should play nicely with the options one normally passes to Virtus attributes, but be advised that collisions are theoretically possible. Should you run into an issue here, please don't hesitate to open up an issue.
For further validation examples, please see the Rails Guides or ActiveModel::Validations API docs.
TODO
- Pinpoint errors in JSON/XML in exception (helps to debug API problems)
- Raise when attribute not present in data on instantiation.
- Clearly separate ActiveModel::Validation options from those passed to Virtus
Contributing
- Fork it
- 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 new Pull Request