first_responder 0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.rspec +1 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +238 -0
- data/Rakefile +6 -0
- data/first_responder.gemspec +29 -0
- data/lib/first_responder.rb +148 -0
- data/lib/first_responder/version.rb +3 -0
- data/spec/dependencies_spec.rb +12 -0
- data/spec/first_responder_spec.rb +290 -0
- data/spec/spec_helper.rb +8 -0
- metadata +146 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 2d971be3ad0d3b952987a1f6786adb4594b0a595
|
4
|
+
data.tar.gz: af28852d98fc27fafcfd37ad1f0dcda83b37658f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: b6fd3b459e9f1cb29c9543985ba88dadd1fb469fcd56f9f420ce8a8a53e58e1bb3be7adacac0c6dd12a1277559ad6dc64fd1b94550847c6dcbd9b43436cd8ea1
|
7
|
+
data.tar.gz: a3d2149ba0f5ca18a8f98e48988c7015ce959689a75e1f51e3d50fe9a0ae3b492595d141b315affd133d4f89a25b74935de9b029bb54724ae85c3dec07f5cd59
|
data/.gitignore
ADDED
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--color
|
data/.ruby-gemset
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
sirius
|
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
ruby-2.0.0-p195
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Curtis Ekstrom
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,238 @@
|
|
1
|
+
# FirstResponder
|
2
|
+
|
3
|
+
A small library to coerce and validate API responses using PORO's.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'first_responder'
|
10
|
+
|
11
|
+
And then execute:
|
12
|
+
|
13
|
+
$ bundle
|
14
|
+
|
15
|
+
Or install it yourself as:
|
16
|
+
|
17
|
+
$ gem install first_responder
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
FirstResponder includes the veritable [virtus](https://github.com/solnic/virtus) and ActiveModel::Validations
|
22
|
+
libraries within classes to add attributes and validations to API response objects.
|
23
|
+
|
24
|
+
This allows validation of API reponses at a "model" level, be they responses from real-world production services or Mock API's.
|
25
|
+
|
26
|
+
Classes that include FirstResponder can be instantiated with either XML or JSON and can define the required attributes for that model.
|
27
|
+
|
28
|
+
## Examples
|
29
|
+
|
30
|
+
To use FirstResponder, simply include it in your class. Then specify your required attributes, as in this fictitious example:
|
31
|
+
|
32
|
+
```ruby
|
33
|
+
class TwitterResponse
|
34
|
+
include FirstResponder
|
35
|
+
requires :tweet, String
|
36
|
+
requires :date, DateTime
|
37
|
+
end
|
38
|
+
```
|
39
|
+
|
40
|
+
Then instantiate the class:
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
response = TwitterResponse.new(:json, '{"tweet": "This is a tweet."}')
|
44
|
+
|
45
|
+
response.valid?
|
46
|
+
=> false
|
47
|
+
|
48
|
+
response.date = "June 22nd, 2013"
|
49
|
+
response.valid?
|
50
|
+
=> true
|
51
|
+
|
52
|
+
```
|
53
|
+
As long as the response contains the required attributes, the instance will be considered valid.
|
54
|
+
|
55
|
+
FirstResponder also supports attributes referencing an Array of objects, allowing Virtus to coerce those objects:
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
class Foo
|
59
|
+
include Virtus
|
60
|
+
attribute :foo, String
|
61
|
+
end
|
62
|
+
|
63
|
+
class Biz
|
64
|
+
include FirstResponder
|
65
|
+
requires :foos, Array[Foo], at: ""
|
66
|
+
end
|
67
|
+
```
|
68
|
+
|
69
|
+
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`:
|
70
|
+
|
71
|
+
```ruby
|
72
|
+
json_array = '[ {"foo": "bar" }, { "foo": "bar"} ]'
|
73
|
+
biz = Biz.new(:json, json_array)
|
74
|
+
|
75
|
+
biz.foos
|
76
|
+
=> [#<Foo:0x007f876ac14be0 @foo="bar">, #<Foo:0x007f876ac1e938 @foo="bar">]
|
77
|
+
```
|
78
|
+
|
79
|
+
### Nested Keys
|
80
|
+
|
81
|
+
FirstResponder assumes that the attribute you're defining is an unnested hash key. The following example shows how to enable nested hash keys:
|
82
|
+
```ruby
|
83
|
+
class Magician
|
84
|
+
include FirstResponder
|
85
|
+
requires :surprise, String, at: "[:black][:hat]" # or using strings "['black']['hat']"
|
86
|
+
end
|
87
|
+
```
|
88
|
+
|
89
|
+
Then instantiate with JSON/XML as before:
|
90
|
+
```ruby
|
91
|
+
trick = '{"black": {"hat": "RABBIT!"}}'
|
92
|
+
magician = Magician.new(:json, trick)
|
93
|
+
```
|
94
|
+
And, as one might have seen coming:
|
95
|
+
```ruby
|
96
|
+
magician.surprise
|
97
|
+
=> "RABBIT!"
|
98
|
+
```
|
99
|
+
Were the black hat empty, the magician would, of course, not be valid ;)
|
100
|
+
The previous example also highlights a second hidden feature in the `at` parameter: aliasing.
|
101
|
+
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.
|
102
|
+
|
103
|
+
### The Root
|
104
|
+
|
105
|
+
But what if all of your desired information is nested deeply within XML/JSON, always under the same outer node?
|
106
|
+
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`:
|
107
|
+
|
108
|
+
```ruby
|
109
|
+
class Treasure
|
110
|
+
include Virtus
|
111
|
+
attribute :type, String
|
112
|
+
attribute :weight, Integer
|
113
|
+
attribute :unit, String
|
114
|
+
end
|
115
|
+
|
116
|
+
class TreasureHunt
|
117
|
+
include FirstResponder
|
118
|
+
root "[:ocean][:sea_floor][:treasure_chest][:hidden_compartment]"
|
119
|
+
requires :treasure, Treasure
|
120
|
+
end
|
121
|
+
```
|
122
|
+
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!
|
123
|
+
|
124
|
+
```ruby
|
125
|
+
response = '{"ocean":
|
126
|
+
{ "sea_floor":
|
127
|
+
{"treasure_chest":
|
128
|
+
{"hidden_compartment":
|
129
|
+
{ "treasure": { "type": "Gold", "weight": 1, "unit": "Ton" }}}}}}'
|
130
|
+
|
131
|
+
treasure_hunt = TreasureHunt.new(:json, response)
|
132
|
+
treasure_hunt.treasure
|
133
|
+
=> #<Treasure:0x007fe50c98c990 @type="Gold", @weight=1, @unit="Ton">
|
134
|
+
```
|
135
|
+
|
136
|
+
Treasure that.
|
137
|
+
|
138
|
+
### Nested Validations
|
139
|
+
FirstResponder will also detect problems lurking beneath the surface by automatically searching for and validating nested attributes.
|
140
|
+
Take the previous example of a `TreasureHunt` and `Treasure` classes, this time including FirstResponder and requiring the presence of certain attributes.
|
141
|
+
A `TreasureHunt`, after all, is only valid if the `Treasure` it finds is:
|
142
|
+
|
143
|
+
```ruby
|
144
|
+
class TreasureHunt
|
145
|
+
include FirstResponder
|
146
|
+
root "[:ocean][:sea_floor][:treasure_chest][:hidden_compartment]"
|
147
|
+
requires :treasure, Treasure
|
148
|
+
end
|
149
|
+
|
150
|
+
class Treasure
|
151
|
+
include FirstResponder
|
152
|
+
requires :type, String
|
153
|
+
requires :weight, Integer
|
154
|
+
requires :unit, String
|
155
|
+
end
|
156
|
+
```
|
157
|
+
We instantiate our `TreasureHunt` this time, however, with what appears to be a `Treasure`, but isn't:
|
158
|
+
|
159
|
+
```ruby
|
160
|
+
response = '{"ocean":
|
161
|
+
{ "sea_floor":
|
162
|
+
{"treasure_chest":
|
163
|
+
{"hidden_compartment":
|
164
|
+
{ "treasure": { "type": null, "weight": null, "unit": null}}}}}}'
|
165
|
+
|
166
|
+
treasure_hunt = TreasureHunt.new(:json, response)
|
167
|
+
treasure_hunt.treasure
|
168
|
+
```
|
169
|
+
Coercion still works, but the `Treasure` object that's been created is devoid of all value. It is itself, of course, invalid:
|
170
|
+
|
171
|
+
```ruby
|
172
|
+
treasure_hunt.treasure.valid?
|
173
|
+
=> false
|
174
|
+
```
|
175
|
+
|
176
|
+
But since FirstResponder knows that our `TreasureHunt` requires a `Treasure`, our `TreasureHunt` is also rendered invalid:
|
177
|
+
|
178
|
+
```ruby
|
179
|
+
treasure_hunt.valid?
|
180
|
+
=> false
|
181
|
+
```
|
182
|
+
|
183
|
+
### The Invalid Callback
|
184
|
+
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:
|
185
|
+
|
186
|
+
```ruby
|
187
|
+
class InvalidWithCallback
|
188
|
+
include FirstResponder
|
189
|
+
requires :important_attr, String
|
190
|
+
requires :another, String
|
191
|
+
when_invalid { |data, errors| puts data }
|
192
|
+
end
|
193
|
+
|
194
|
+
with_callback = InvalidWithCallback.new(:json, '{"foo":"bar"}')
|
195
|
+
with_callback.valid?
|
196
|
+
{"foo"=>"bar"}
|
197
|
+
=> false
|
198
|
+
```
|
199
|
+
|
200
|
+
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:
|
201
|
+
|
202
|
+
```ruby
|
203
|
+
with_callback.valid?(false)
|
204
|
+
=> false
|
205
|
+
|
206
|
+
with_callback.invalid?(false)
|
207
|
+
=> true
|
208
|
+
```
|
209
|
+
|
210
|
+
### ActiveModel::Validations
|
211
|
+
Because FirstResponder uses ActiveModel::Validations under the covers, you can use most of the API you already know to validate individual attributes.
|
212
|
+
Of course, this excludes those checks relying on persistence (i.e. uniqueness) or attempts to validate an object using Virtus coercion.
|
213
|
+
|
214
|
+
```ruby
|
215
|
+
class Baz
|
216
|
+
include FirstResponder
|
217
|
+
requires :foo, String, format: { with: /bar/ }
|
218
|
+
end
|
219
|
+
```
|
220
|
+
|
221
|
+
This should play nicely with the options one normally passes to Virtus attributes, but be advised that collisions are theoretically possible.
|
222
|
+
Should you run into an issue here, please don't hesitate to open up an issue.
|
223
|
+
|
224
|
+
For further validation examples, please see the Rails [Guides](http://guides.rubyonrails.org/active_record_validations.html) or ActiveModel::Validations API docs.
|
225
|
+
|
226
|
+
## TODO
|
227
|
+
|
228
|
+
1. Pinpoint errors in JSON/XML in exception (helps to debug API problems)
|
229
|
+
2. Raise when attribute not present in data on instantiation.
|
230
|
+
3. Clearly separate ActiveModel::Validation options from those passed to Virtus
|
231
|
+
|
232
|
+
## Contributing
|
233
|
+
|
234
|
+
1. Fork it
|
235
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
236
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
237
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
238
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'first_responder/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "first_responder"
|
8
|
+
spec.version = FirstResponder::VERSION
|
9
|
+
spec.authors = ["Curtis Ekstrom"]
|
10
|
+
spec.email = ["curtis@wellmatchhealth.com"]
|
11
|
+
spec.description = %q{A small library to coerce and validate API responses using PORO's.}
|
12
|
+
spec.summary = %q{FirstResponder classes wrap API responses and define the attributes required of those responses.}
|
13
|
+
spec.homepage = "https://github.com/clekstro/first_responder"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files`.split($/)
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_dependency "virtus"
|
22
|
+
spec.add_dependency "activemodel"
|
23
|
+
|
24
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
25
|
+
spec.add_development_dependency "rake"
|
26
|
+
spec.add_development_dependency "rspec"
|
27
|
+
spec.add_development_dependency "pry"
|
28
|
+
|
29
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
require "first_responder/version"
|
2
|
+
require "virtus"
|
3
|
+
require "active_model"
|
4
|
+
require "active_support/core_ext/hash"
|
5
|
+
require "active_support/core_ext/hash/indifferent_access"
|
6
|
+
require "active_support/inflector"
|
7
|
+
require "json"
|
8
|
+
|
9
|
+
module FirstResponder
|
10
|
+
VALID_FORMATS = [:json, :xml]
|
11
|
+
|
12
|
+
module InstanceMethods
|
13
|
+
|
14
|
+
# Every instance must instantiate itself with a format and corresponding
|
15
|
+
# data. Given that information, formats are validated, data is parsed using
|
16
|
+
# that format, after which the attributes defined on the class are set to
|
17
|
+
# the hash value at the defined location.
|
18
|
+
|
19
|
+
def initialize(fmt=nil, data)
|
20
|
+
@format = ensure_format(fmt) if fmt
|
21
|
+
@data = data.is_a?(Hash) ? data : deserialize(data, fmt)
|
22
|
+
map_attrs
|
23
|
+
end
|
24
|
+
|
25
|
+
def valid?(execute=true)
|
26
|
+
return true if all_attributes_valid?
|
27
|
+
proc_on_invalid.call(@data, errors) if execute
|
28
|
+
return no_nesting? ? super : false
|
29
|
+
end
|
30
|
+
|
31
|
+
def invalid?(execute=true)
|
32
|
+
!valid?(execute)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def no_nesting?
|
38
|
+
nested_validations.empty?
|
39
|
+
end
|
40
|
+
|
41
|
+
def map_attrs
|
42
|
+
required_attributes.each do |attr_hash|
|
43
|
+
attr = attr_hash.keys.first
|
44
|
+
value = extract_attribute_value(attr_hash, attr)
|
45
|
+
send("#{attr}=", value)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def ensure_format(fmt)
|
50
|
+
raise UnknownFormatError unless VALID_FORMATS.include?(fmt)
|
51
|
+
end
|
52
|
+
|
53
|
+
def all_attributes_valid?
|
54
|
+
nested_validations.any? &&
|
55
|
+
nested_validations.all? { |attr| eval("#{attr}.valid?") }
|
56
|
+
end
|
57
|
+
|
58
|
+
def required_attributes
|
59
|
+
self.class.required_attributes
|
60
|
+
end
|
61
|
+
|
62
|
+
def nested_validations
|
63
|
+
self.class.nested_validations
|
64
|
+
end
|
65
|
+
|
66
|
+
def proc_on_invalid
|
67
|
+
self.class.proc_on_invalid || Proc.new {}
|
68
|
+
end
|
69
|
+
|
70
|
+
def deserialize(data, format)
|
71
|
+
raise MissingDataError if data == ''
|
72
|
+
return JSON.parse(data) if format == :json
|
73
|
+
Hash.from_xml(data) if format == :xml
|
74
|
+
end
|
75
|
+
|
76
|
+
# Currently have to use eval to access @data at nested array object
|
77
|
+
# attr_hash[attr] is String at this point:
|
78
|
+
# "['foo']['bar']['baz']"
|
79
|
+
|
80
|
+
def extract_attribute_value(attr_hash, attr)
|
81
|
+
return @data if @data.is_a?(Array)
|
82
|
+
attr_location = (attr_hash[attr] || "['#{attr.to_s}']")
|
83
|
+
hash_location = self.class.first_responder_root + attr_location
|
84
|
+
eval("@data.with_indifferent_access#{hash_location}")
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
module ClassMethods
|
89
|
+
def required_attributes
|
90
|
+
@required_attributes ||= []
|
91
|
+
end
|
92
|
+
|
93
|
+
def nested_validations
|
94
|
+
@nested_validations ||= []
|
95
|
+
end
|
96
|
+
|
97
|
+
def first_responder_root
|
98
|
+
@first_responder_root ||= ""
|
99
|
+
end
|
100
|
+
|
101
|
+
def root(node)
|
102
|
+
@first_responder_root = node
|
103
|
+
end
|
104
|
+
|
105
|
+
def proc_on_invalid
|
106
|
+
@proc_on_invalid
|
107
|
+
end
|
108
|
+
|
109
|
+
def when_invalid(&blk)
|
110
|
+
@proc_on_invalid = blk
|
111
|
+
end
|
112
|
+
|
113
|
+
def default_validations
|
114
|
+
{ presence: true }
|
115
|
+
end
|
116
|
+
|
117
|
+
def requires(attr, type, opts={})
|
118
|
+
add_to_required(attr, opts)
|
119
|
+
add_to_nested(attr, type)
|
120
|
+
validates attr, default_validations.merge(opts)
|
121
|
+
attribute attr, type, opts
|
122
|
+
end
|
123
|
+
|
124
|
+
def add_to_required(attr, opts)
|
125
|
+
first_responder_opts = opts.extract!(:at)[:at]
|
126
|
+
required_attributes << Hash[attr, first_responder_opts]
|
127
|
+
end
|
128
|
+
|
129
|
+
def add_to_nested(attr, type)
|
130
|
+
return if type.is_a? Array
|
131
|
+
nested_validations << attr if type.ancestors.include?(FirstResponder)
|
132
|
+
end
|
133
|
+
|
134
|
+
end
|
135
|
+
|
136
|
+
module Exceptions
|
137
|
+
class UnknownFormatError < Exception; end
|
138
|
+
class MissingDataError < Exception; end
|
139
|
+
end
|
140
|
+
|
141
|
+
def self.included(base)
|
142
|
+
base.send(:include, Virtus)
|
143
|
+
base.send(:include, ActiveModel::Validations)
|
144
|
+
base.send(:include, InstanceMethods)
|
145
|
+
base.extend(ClassMethods)
|
146
|
+
base.extend(Exceptions)
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,290 @@
|
|
1
|
+
require 'first_responder'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
describe FirstResponder do
|
5
|
+
let(:valid_json) { '{"foo":"bar"}' }
|
6
|
+
let(:incomplete_json) { '{"foo": null}' }
|
7
|
+
let(:valid_xml) { "<foo>bar</foo>" }
|
8
|
+
let(:incomplete_xml) { "<foo></foo>" }
|
9
|
+
let(:nested_json) { '{"foo":{"bar":{"baz": "boo"}}}' }
|
10
|
+
let(:nested_xml) { '<foo><bar><baz>boo</baz></bar></foo>' }
|
11
|
+
|
12
|
+
describe ".initialize" do
|
13
|
+
let(:klass) { Class.new { include FirstResponder } }
|
14
|
+
|
15
|
+
context "with unknown format" do
|
16
|
+
it "raises UnknownSerializationFormat error" do
|
17
|
+
expect{ klass.new(:wrong, '') }.to raise_error
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
context "valid formats" do
|
22
|
+
context "json" do
|
23
|
+
context "and valid data" do
|
24
|
+
it "initializes successfully" do
|
25
|
+
expect{ klass.new(:json, valid_json) }.not_to raise_error
|
26
|
+
end
|
27
|
+
end
|
28
|
+
context "and blank data" do
|
29
|
+
it "blows up" do
|
30
|
+
expect{ klass.new(:json, '') }.to raise_error
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
context "xml" do
|
35
|
+
context "and valid data" do
|
36
|
+
it "initializes successfully" do
|
37
|
+
expect{ klass.new(:xml, valid_xml) }.not_to raise_error
|
38
|
+
end
|
39
|
+
end
|
40
|
+
context "and blank data" do
|
41
|
+
it "blows up" do
|
42
|
+
expect{ klass.new(:xml, '') }.to raise_error
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
describe "#requires" do
|
50
|
+
let(:klass) {
|
51
|
+
Class.new do
|
52
|
+
include FirstResponder
|
53
|
+
requires :foo, String
|
54
|
+
def self.model_name; ActiveModel::Name.new(self, nil, "temp"); end
|
55
|
+
end
|
56
|
+
}
|
57
|
+
|
58
|
+
context "json attribute" do
|
59
|
+
context "with data" do
|
60
|
+
subject { klass.new(:json, valid_json) }
|
61
|
+
it{ should respond_to(:foo) }
|
62
|
+
its(:foo) { should == "bar" }
|
63
|
+
it{ should be_valid }
|
64
|
+
end
|
65
|
+
context "without data" do
|
66
|
+
subject { klass.new(:json, incomplete_json) }
|
67
|
+
it{ should_not be_valid }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
context "xml attribute" do
|
72
|
+
context "with data" do
|
73
|
+
subject { klass.new(:xml, valid_xml) }
|
74
|
+
it{ should respond_to(:foo) }
|
75
|
+
its(:foo) { should == "bar" }
|
76
|
+
it{ should be_valid }
|
77
|
+
end
|
78
|
+
context "without data" do
|
79
|
+
subject { klass.new(:xml, incomplete_xml) }
|
80
|
+
it{ should_not be_valid }
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
describe "extracts passed options" do
|
85
|
+
context "with string attrs" do
|
86
|
+
let(:klass) {
|
87
|
+
Class.new do
|
88
|
+
include FirstResponder
|
89
|
+
requires :foo, String, at: "['foo']['bar']['baz']"
|
90
|
+
end
|
91
|
+
}
|
92
|
+
|
93
|
+
context "(json)" do
|
94
|
+
subject { klass.new(:json, nested_json) }
|
95
|
+
its(:foo) { should == "boo" }
|
96
|
+
end
|
97
|
+
context "(xml)" do
|
98
|
+
subject { klass.new(:xml, nested_xml) }
|
99
|
+
its(:foo) { should == "boo" }
|
100
|
+
end
|
101
|
+
end
|
102
|
+
context "with symbol attrs" do
|
103
|
+
let(:klass) {
|
104
|
+
Class.new do
|
105
|
+
include FirstResponder
|
106
|
+
requires :foo, String, at: "[:foo][:bar][:baz]"
|
107
|
+
end
|
108
|
+
}
|
109
|
+
|
110
|
+
context "(json)" do
|
111
|
+
subject { klass.new(:json, nested_json) }
|
112
|
+
its(:foo) { should == "boo" }
|
113
|
+
end
|
114
|
+
context "(xml)" do
|
115
|
+
subject { klass.new(:xml, nested_xml) }
|
116
|
+
its(:foo) { should == "boo" }
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
describe ".root" do
|
123
|
+
let(:klass) {
|
124
|
+
Class.new do
|
125
|
+
include FirstResponder
|
126
|
+
root "['foo']['bar']"
|
127
|
+
requires :foo, String, at: "['baz']"
|
128
|
+
end
|
129
|
+
}
|
130
|
+
|
131
|
+
context "correctly filters beginning at defined root node" do
|
132
|
+
context "(json)" do
|
133
|
+
subject { klass.new(:json, nested_json) }
|
134
|
+
its(:foo) { should == 'boo' }
|
135
|
+
end
|
136
|
+
context "(xml)" do
|
137
|
+
subject { klass.new(:xml, nested_xml) }
|
138
|
+
its(:foo) { should == 'boo' }
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
describe "nested first_responder objects" do
|
144
|
+
let(:klass) {
|
145
|
+
Class.new do
|
146
|
+
include FirstResponder
|
147
|
+
root "['ocean']['sea_floor']['treasure_chest']['hidden_compartment']"
|
148
|
+
requires :treasure, Treasure
|
149
|
+
end
|
150
|
+
}
|
151
|
+
let(:with_treasure) { '{"ocean": { "sea_floor": {"treasure_chest": {"hidden_compartment": { "treasure": { "type": "Gold", "weight": 1, "unit": "Ton" }}}}}}' }
|
152
|
+
|
153
|
+
let(:without_treasure) { '{"ocean": { "sea_floor": {"treasure_chest": {"hidden_compartment": { "treasure": { "type": null, "weight": null, "unit": null}}}}}}' }
|
154
|
+
|
155
|
+
describe "maintains Virtus coercion abilities" do
|
156
|
+
|
157
|
+
before do
|
158
|
+
class Treasure
|
159
|
+
include Virtus
|
160
|
+
attribute :type, String
|
161
|
+
attribute :weight, Integer
|
162
|
+
attribute :unit, String
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
subject { klass.new(:json, with_treasure).treasure }
|
167
|
+
it{ should be_a(Treasure) }
|
168
|
+
end
|
169
|
+
|
170
|
+
describe "validates nested first_responder objects" do
|
171
|
+
before do
|
172
|
+
class Treasure
|
173
|
+
include FirstResponder
|
174
|
+
requires :type, String
|
175
|
+
requires :weight, Integer
|
176
|
+
requires :unit, String
|
177
|
+
|
178
|
+
when_invalid {}
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
describe ".nested_validations" do
|
183
|
+
it "returns required attributes that must themselves be validated" do
|
184
|
+
klass.nested_validations.should == [:treasure]
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
describe "#valid?" do
|
189
|
+
context "with invalid nested object" do
|
190
|
+
subject { klass.new(:json, without_treasure).valid? }
|
191
|
+
it{ should be_false }
|
192
|
+
|
193
|
+
context "with proc_on_invalid defined" do
|
194
|
+
subject { klass.new(:json, without_treasure) }
|
195
|
+
|
196
|
+
context "by default" do
|
197
|
+
it "calls the proc" do
|
198
|
+
new_proc = stub(:proc)
|
199
|
+
subject.stub(:proc_on_invalid).and_return(new_proc)
|
200
|
+
new_proc.should_receive(:call)
|
201
|
+
subject.valid?
|
202
|
+
end
|
203
|
+
end
|
204
|
+
context "with false passed" do
|
205
|
+
it "does not call the proc" do
|
206
|
+
new_proc = stub(:proc)
|
207
|
+
subject.stub(:proc_on_invalid).and_return(new_proc)
|
208
|
+
new_proc.should_not_receive(:call)
|
209
|
+
subject.valid?(false)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
context "with proc_on_invalid absent" do
|
215
|
+
before do
|
216
|
+
class Treasure
|
217
|
+
include FirstResponder
|
218
|
+
requires :type, String
|
219
|
+
requires :weight, String
|
220
|
+
requires :unit, String
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
subject { klass.new(:json, with_treasure) }
|
225
|
+
it "does nothing" do
|
226
|
+
new_proc = stub(:proc)
|
227
|
+
subject.stub(:proc_on_invalid).and_return(new_proc)
|
228
|
+
new_proc.should_not_receive(:call)
|
229
|
+
subject.invalid?
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
context "with valid nested object" do
|
235
|
+
subject { klass.new(:json, with_treasure).valid? }
|
236
|
+
it{ should be_true }
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
describe "#invalid?" do
|
241
|
+
subject { klass.new(:json, with_treasure) }
|
242
|
+
its(:invalid?) { should be_false }
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
describe "supports ActiveModel::Validation parameters" do
|
247
|
+
context "format" do
|
248
|
+
|
249
|
+
let(:valid) {
|
250
|
+
class Valid
|
251
|
+
include FirstResponder
|
252
|
+
requires :foo, String, format: { with: /bar/ }
|
253
|
+
end
|
254
|
+
}
|
255
|
+
|
256
|
+
let(:invalid) {
|
257
|
+
class Invalid
|
258
|
+
include FirstResponder
|
259
|
+
requires :foo, String, format: { with: /baz/ }
|
260
|
+
end
|
261
|
+
}
|
262
|
+
|
263
|
+
it "is invalid with non-matching format" do
|
264
|
+
valid.new(:json, valid_json).should be_valid
|
265
|
+
invalid.new(:json, valid_json).should_not be_valid
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
describe "edge cases" do
|
272
|
+
let(:json_array) { '[ { "foo": "bar" }, { "foo": "baz"} ]' }
|
273
|
+
before do
|
274
|
+
class Foo
|
275
|
+
include Virtus
|
276
|
+
attribute :foo, String
|
277
|
+
end
|
278
|
+
|
279
|
+
class JsonArrayTest
|
280
|
+
include FirstResponder
|
281
|
+
requires :foos, Array[Foo], at: ""
|
282
|
+
end
|
283
|
+
end
|
284
|
+
subject { JsonArrayTest.new(:json, json_array).foos }
|
285
|
+
it { should be_a_kind_of Array }
|
286
|
+
it "coerces to Foo" do
|
287
|
+
subject.first.should be_a Foo
|
288
|
+
end
|
289
|
+
end
|
290
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,146 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: first_responder
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: '0.2'
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Curtis Ekstrom
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2013-07-20 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: virtus
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - '>='
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - '>='
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: activemodel
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '>='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '>='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: bundler
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ~>
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.3'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ~>
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.3'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rake
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - '>='
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - '>='
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - '>='
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: pry
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - '>='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - '>='
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
description: A small library to coerce and validate API responses using PORO's.
|
98
|
+
email:
|
99
|
+
- curtis@wellmatchhealth.com
|
100
|
+
executables: []
|
101
|
+
extensions: []
|
102
|
+
extra_rdoc_files: []
|
103
|
+
files:
|
104
|
+
- .gitignore
|
105
|
+
- .rspec
|
106
|
+
- .ruby-gemset
|
107
|
+
- .ruby-version
|
108
|
+
- Gemfile
|
109
|
+
- LICENSE.txt
|
110
|
+
- README.md
|
111
|
+
- Rakefile
|
112
|
+
- first_responder.gemspec
|
113
|
+
- lib/first_responder.rb
|
114
|
+
- lib/first_responder/version.rb
|
115
|
+
- spec/dependencies_spec.rb
|
116
|
+
- spec/first_responder_spec.rb
|
117
|
+
- spec/spec_helper.rb
|
118
|
+
homepage: https://github.com/clekstro/first_responder
|
119
|
+
licenses:
|
120
|
+
- MIT
|
121
|
+
metadata: {}
|
122
|
+
post_install_message:
|
123
|
+
rdoc_options: []
|
124
|
+
require_paths:
|
125
|
+
- lib
|
126
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
127
|
+
requirements:
|
128
|
+
- - '>='
|
129
|
+
- !ruby/object:Gem::Version
|
130
|
+
version: '0'
|
131
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
132
|
+
requirements:
|
133
|
+
- - '>='
|
134
|
+
- !ruby/object:Gem::Version
|
135
|
+
version: '0'
|
136
|
+
requirements: []
|
137
|
+
rubyforge_project:
|
138
|
+
rubygems_version: 2.0.3
|
139
|
+
signing_key:
|
140
|
+
specification_version: 4
|
141
|
+
summary: FirstResponder classes wrap API responses and define the attributes required
|
142
|
+
of those responses.
|
143
|
+
test_files:
|
144
|
+
- spec/dependencies_spec.rb
|
145
|
+
- spec/first_responder_spec.rb
|
146
|
+
- spec/spec_helper.rb
|