first_responder 0.2

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
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
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in sirius.gemspec
4
+ gemspec
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,6 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -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,3 @@
1
+ module FirstResponder
2
+ VERSION = "0.2"
3
+ end
@@ -0,0 +1,12 @@
1
+ require 'first_responder'
2
+
3
+ describe FirstResponder do
4
+ context "dependencies" do
5
+ it 'requires virtus' do
6
+ Virtus.should be
7
+ end
8
+ it 'requires active_model' do
9
+ ActiveModel.should be
10
+ end
11
+ end
12
+ 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
@@ -0,0 +1,8 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+
4
+ require 'first_responder'
5
+
6
+ RSpec.configure do |config|
7
+ config.fail_fast = true
8
+ end
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