requisite 0.1.0

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: 50d02a760bbc8b06f7add5b13fa19f6b3e6d6e84
4
+ data.tar.gz: ff9ab4dfe47ce3ebf3972bb71f3baf8b29341dd8
5
+ SHA512:
6
+ metadata.gz: f23920831c22a63ecb735413e0277e67111bfbfbb4b6ddf25e187c6f4163a3150b11b5e5cdaada1da94f9e59c0b8e77b9b0ac53ef63696c129d332f604920448
7
+ data.tar.gz: 79d2835433fde444c7329a1bbc05847a980fa7d9a93087296fec6ba47574ed9d4a6ff169b825db59028cd5e05036a6811be2be28a10de0d65722e0817f379c7b
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ *.gem
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "http://rubygems.org"
2
+
3
+ gemspec
data/LICENCE.txt ADDED
@@ -0,0 +1,190 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ Copyright 2014 Intercom.io
179
+
180
+ Licensed under the Apache License, Version 2.0 (the "License");
181
+ you may not use this file except in compliance with the License.
182
+ You may obtain a copy of the License at
183
+
184
+ http://www.apache.org/licenses/LICENSE-2.0
185
+
186
+ Unless required by applicable law or agreed to in writing, software
187
+ distributed under the License is distributed on an "AS IS" BASIS,
188
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
189
+ See the License for the specific language governing permissions and
190
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # Requisite
2
+
3
+ Requisite is an elegant way of strongly defining request and response models for serialization. How nice would it be if you could do:
4
+
5
+ ```ruby
6
+ def create
7
+ api_user = ApiRequestUser.new(params)
8
+ user = User.create(api_user.to_hash)
9
+ render json: ApiResponseUser.new(user).to_json
10
+ end
11
+ ```
12
+
13
+ Without worrying about strong parameters, type safety and keeping a consistent API?
14
+
15
+ ## Usage
16
+
17
+ ```ruby
18
+ require 'requisite'
19
+ ```
20
+
21
+ ## ApiModel
22
+
23
+ ApiModels are the primary way of using Requisite, they represent a model defined as part of an API. Attributes can be listed within a `serialized_attributes` block, with the format `<attribute-type> <attribute-name> <options>`.
24
+
25
+ | method | behaviour |
26
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
27
+ | attribute | The attribute with the given name will be looked up on the model, nil if not found. If a method with the same name exists on the UserResponse object it will be called for a value instead. Can take several options. Aliased to `a`. |
28
+ | attribute! | as attribute, but raises an error if not found on model. Aliased to `a!`. |
29
+
30
+ ApiModels can be constructed from other objects, or from Hashes (like those you might find in _params_). The helper method `attribute_from_model(:attribute_name)` gives access that will work with either.
31
+
32
+ These objects have methods to access, and can be serialized back to a Hash (post-transformation; with non-listed parameters removed), or directly to json.
33
+
34
+ ```ruby
35
+ class UserApiModel < Requisite::ApiModel
36
+ serialized_attributes do
37
+ attribute! :id
38
+ attribute! :username
39
+ attribute :real_name
40
+ end
41
+
42
+ # method with the name of of an attribute will be called to calculate the mapped value
43
+ def real_name
44
+ "#{attribute_from_model(:first_name)} #{attribute_from_model(:last_name)}"
45
+ end
46
+ end
47
+
48
+ current_user = User.new(:id => 5, :first_name => 'Jamie', :last_name => 'Osler', :username => 'josler')
49
+ user = UserApiModel.new(current_user)
50
+ user.username
51
+ # => 'josler'
52
+ user.real_name
53
+ # => 'Jamie Osler'
54
+ user.to_hash
55
+ # => { :id => 5, :real_name => 'Jamie Osler', :username => 'josler' }
56
+ user.to_json
57
+ # => "{\"id\":5,\"real_name\":\"Jamie Osler\",\"username\":\"josler\"}"
58
+ ```
59
+
60
+ `nil` values are not returned in the response.
61
+
62
+ Errors are thrown when a required attribute is not present:
63
+
64
+ ```ruby
65
+ UserApiModel.new({:id => 5, :first_name => 'Jamie', :last_name => 'Osler'}).to_hash
66
+ # => Requisite::NotImplementedError: 'username' not found on model
67
+ ```
68
+
69
+ #### Options
70
+
71
+ There are several options that can be used with ApiModel attributes:
72
+
73
+ | option | behaviour |
74
+ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
75
+ | default | `value` will be used as a default if the attribute is not found. Not available for `attribute!` |
76
+ | stringify | `.to_s` will be called on `value` |
77
+ | rename | The returned value will be sourced from the model's `value` attribute |
78
+ | type | Raises error if value does not match given type. Works on the model's value prior to stringification and renaming. Nils are excluded. |
79
+ | scalar_hash | Attribute is a hash with only scalar values permitted - Numeric, String, TrueClass and FalseClass types. |
80
+ | typed_hash | Attribute is a typed hash, with `value` a hash specifying a mapping of sub-attribute to types. |
81
+ | typed_array | Attribute is a typed array, with `value` specifying the type of elements within the array |
82
+
83
+ They can also be combined.
84
+
85
+ Example:
86
+
87
+ ```ruby
88
+ class UserApiModel < Requisite::ApiModel
89
+ serialized_attributes do
90
+ attribute :id, stringify: true
91
+ attribute :custom_attributes, rename: :custom_data
92
+ attribute :is_awesome, default: true
93
+ attribute :awesome_score, rename: :score, stringify: true, default: 9001
94
+ attribute :age, type: Fixnum,
95
+ attribute :tired, type: Requisite::Boolean
96
+ end
97
+ end
98
+
99
+ current_user = User.new(:id => 5, :custom_data => [ {:number_events => 4} ], :age => 26)
100
+ UserApiModel.new(current_user).to_json
101
+ # => "{\"id\":\"5\",\"custom_attributes\":[{\"number_events\":4}],\"is_awesome\":true,\"awesome_score\":\"9001\",\"age\":26}"
102
+ ```
103
+
104
+ The `Requisite::Boolean` type will match `TrueClass` and `FalseClass`.
105
+
106
+ #### Nested Structure Support
107
+
108
+ Nested structure support only applies one level deep; beyond that we recommend you use a nested ApiModel that's well structured.
109
+
110
+ ##### Hashes
111
+
112
+ ApiModels support nested hashes in two forms; specifying that a Hash should contain only Scalar (Numeric, String and Boolean) values, or a nested hash of a typed attributes.
113
+
114
+ With scalar hashes, any scalar value is permitted:
115
+
116
+ ```ruby
117
+ class UserApiModel < Requisite::ApiModel
118
+ serialized_attributes do
119
+ attribute :data, scalar_hash: true
120
+ end
121
+ end
122
+
123
+ UserApiModel.new(:data => {:is_awesome => true, :score => 9001, :name => 'Jamie'}).to_hash
124
+ # => { :data => {:is_awesome => true, :score => 9001, :name => 'Jamie'} }
125
+ ```
126
+
127
+ Non-scalar values will raise a `Requisite::BadTypeError`. Empty scalar hash attributes are returned as `{}`.
128
+
129
+ With typed hashes, only values specified with a type are permitted:
130
+
131
+ ```ruby
132
+ class UserApiModel < Requisite::ApiModel
133
+ serialized_attributes do
134
+ attribute :data, typed_hash: { is_awesome: Requisite::Boolean, score: Fixnum, name: String }
135
+ end
136
+ end
137
+
138
+ UserApiModel.new(:data => {:is_awesome => true, :score => 9001, :name => 'Jamie'}).to_hash
139
+ # => { :data => {:is_awesome => true, :score => 9001, :name => 'Jamie'} }
140
+ ```
141
+
142
+ Note that setting the type to the provided `Requisite::Boolean` permits `TrueClass` and `FalseClass` values.
143
+
144
+ Fields within a fixed hash that are not listed as permitted will be omitted (even with attribute! their presence will not raise an error).
145
+
146
+ Fields with the wrong data type will result in a `Requisite::BadTypeError` being raised. Empty typed hash attributes are returned as `{}`.
147
+
148
+ ##### Arrays
149
+
150
+ Typed arrays are supported; arrays must be all of one type:
151
+
152
+ ```ruby
153
+ class UserApiModel < Requisite::ApiModel
154
+ serialized_attributes do
155
+ attribute :ids, typed_array: String
156
+ end
157
+ end
158
+
159
+ UserApiModel.new(:ids => ['x123D', 'u71d', '96yD']).to_hash
160
+ # => { :ids => ['x123D', 'u71d', '96yD'] }
161
+ ```
162
+
163
+ Array values not corresponding to the correct type will raise a `Requisite::BadTypeError`. Empty Array attributes will be returned as `[]`.
164
+
165
+ #### Advanced Nested Structures
166
+
167
+ To work with advanced nested structures, we recommend you create a method with the attribute name that will be called, and use another ApiModel to perform validation, for example:
168
+
169
+ ```ruby
170
+ class ApiUser < Requisite::ApiModel
171
+ serialized_attributes do
172
+ attribute :id, type: String
173
+ attribute :company
174
+ end
175
+
176
+ # ApiCompany object handles its' own validation
177
+ def company
178
+ ApiCompany.new(attribute_from_model(:company)).to_hash
179
+ end
180
+ end
181
+ ```
182
+
183
+ #### Preprocess Request
184
+
185
+ A `preprocess_model` method can be defined to carry out any required steps before the model is processed, e.g.:
186
+
187
+ ```ruby
188
+ class ApiUser < Requisite::ApiModel
189
+ serialized_attributes do
190
+ attribute :id, type: String
191
+ attribute :email, type: String
192
+ end
193
+
194
+ # preprocess to check we have an identifier for the user
195
+ def preprocess_model
196
+ identifier = attribute_from_model(:id)
197
+ identifier ||= attribute_from_model(:email)
198
+ raise IdentifierNotFoundError unless identifier
199
+ end
200
+ end
201
+ ```
202
+
203
+ #### Thanks
204
+
205
+ Strongly inspired by the work done in the [mutations gem](https://github.com/cypriss/mutations), and with [restpack_serializer](https://github.com/RestPack/restpack_serializer), as well as some of the patterns laid out in Robert Martin's demonstrations of [clean architecture](http://blog.8thlight.com/uncle-bob/2012/08/13/the-clean-architecture.html).
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+ require "bundler"
4
+
5
+ Bundler.require
6
+
7
+ Rake::TestTask.new('test') do |test|
8
+ test.libs << 'lib'
9
+ test.libs << 'test'
10
+
11
+ test.test_files = FileList['test/**/*_test.rb']
12
+ test.warning = false
13
+ test.verbose = true
14
+ end
data/lib/requisite.rb ADDED
@@ -0,0 +1,6 @@
1
+ require 'requisite/version'
2
+ require 'requisite/boundary_object'
3
+ require 'requisite/boolean'
4
+ require 'requisite/api_model'
5
+ require 'requisite/bad_request_error'
6
+ require 'requisite/bad_type_error'
@@ -0,0 +1,98 @@
1
+ require 'json'
2
+
3
+ module Requisite
4
+ class ApiModel < BoundaryObject
5
+ attr_reader :model
6
+
7
+ def initialize(model={})
8
+ @model = model.kind_of?(Hash) ? Hash[model.map{ |k, v| [k.to_sym, v] }] : model
9
+ end
10
+
11
+ def convert(name)
12
+ attribute_from_model(name)
13
+ end
14
+
15
+ def convert!(name)
16
+ attribute_from_model(name) || (raise NotImplementedError.new("'#{name}' not found on model"))
17
+ end
18
+
19
+ def attribute_from_model(name)
20
+ if @model.kind_of?(Hash)
21
+ @model[name]
22
+ else
23
+ @model.send(name) if @model.respond_to?(name)
24
+ end
25
+ end
26
+
27
+ def merge_attribute_if_exists!(to_merge, attribute_name)
28
+ attribute_from_model(attribute_name) ? to_merge.merge!(attribute_from_model(attribute_name)) : to_merge
29
+ end
30
+
31
+ def to_hash
32
+ preprocess_model
33
+ {}.tap do |result|
34
+ self.class.attribute_keys.each do |meth|
35
+ value = self.send(meth)
36
+ result.merge!({meth => value}) unless value.nil?
37
+ end
38
+ end
39
+ end
40
+
41
+ def to_json
42
+ to_hash.to_json
43
+ end
44
+
45
+ def parse_typed_hash(name, hash)
46
+ {}.tap do |result|
47
+ passed_hash = attribute_from_model(name)
48
+ hash.each do |key, value|
49
+ next unless passed_hash && passed_hash[key]
50
+ raise_bad_type_if_type_mismatch(passed_hash[key], value)
51
+ result[key] = passed_hash[key]
52
+ end
53
+ end
54
+ end
55
+
56
+ def parse_scalar_hash(name)
57
+ {}.tap do |result|
58
+ passed_hash = attribute_from_model(name) || {}
59
+ passed_hash.each do |key, value|
60
+ raise BadTypeError.new(value, 'Numeric, String or Boolean') unless (value.kind_of?(Numeric) || value.kind_of?(String) || value.kind_of?(TrueClass) || value.kind_of?(FalseClass))
61
+ result[key] = value
62
+ end
63
+ end
64
+ end
65
+
66
+ def parse_typed_array(name, type)
67
+ [].tap do |result|
68
+ passed_array = attribute_from_model(name) || []
69
+ passed_array.each do |value|
70
+ raise_bad_type_if_type_mismatch(value, type)
71
+ result << value
72
+ end
73
+ end
74
+ end
75
+
76
+ def with_type!(desired_type)
77
+ yield.tap do |value|
78
+ raise_bad_type_if_type_mismatch(value, desired_type) if value
79
+ end
80
+ end
81
+
82
+ def first_attribute_from_model(*attributes)
83
+ attributes.each do |attribute|
84
+ value = attribute_from_model(attribute)
85
+ if value && !(value.kind_of?(Hash) && value.empty?)
86
+ return value
87
+ end
88
+ end
89
+ nil
90
+ end
91
+
92
+ private
93
+
94
+ def preprocess_model
95
+ # noop
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,9 @@
1
+ module Requisite
2
+ class BadRequestError < StandardError
3
+ attr_accessor :message
4
+
5
+ def initialize(message = nil)
6
+ @message = message || self.class.to_s
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Requisite
2
+ class BadTypeError < StandardError
3
+ attr_accessor :message
4
+
5
+ def initialize(value, desired_class)
6
+ @message = "Value: #{value} not of type #{desired_class}"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,4 @@
1
+ module Requisite
2
+ class Boolean
3
+ end
4
+ end
@@ -0,0 +1,60 @@
1
+ module Requisite
2
+ class BoundaryObject
3
+ class << self
4
+ def attribute(name, options={})
5
+ attribute_keys << name
6
+ define_method(name) do
7
+ resolved_name = options[:rename] || name
8
+ result = self.send(:convert, resolved_name)
9
+ result = self.send(:parse_typed_hash, resolved_name, options[:typed_hash]) if options[:typed_hash]
10
+ result = self.send(:parse_scalar_hash, resolved_name) if options[:scalar_hash]
11
+ result = self.send(:parse_typed_array, resolved_name, options[:typed_array]) if options[:typed_array]
12
+ result = options[:default] if (options[:default] && empty_result?(result))
13
+ raise_bad_type_if_type_mismatch(result, options[:type]) if options[:type] && result
14
+ result = result.to_s if options[:stringify]
15
+ result
16
+ end
17
+ end
18
+
19
+ def attribute!(name, options={})
20
+ attribute_keys << name
21
+ define_method(name) do
22
+ resolved_name = options[:rename] || name
23
+ result = self.send(:convert!, resolved_name)
24
+ result = self.send(:parse_typed_hash, resolved_name, options[:typed_hash]) if options[:typed_hash]
25
+ result = self.send(:parse_scalar_hash, resolved_name) if options[:scalar_hash]
26
+ result = self.send(:parse_typed_array, resolved_name, options[:typed_array]) if options[:typed_array]
27
+ result = result.to_s if options[:stringify]
28
+ raise_bad_type_if_type_mismatch(result, options[:type]) if options[:type]
29
+ result
30
+ end
31
+ end
32
+
33
+ def serialized_attributes(&block)
34
+ @attribute_keys = []
35
+ instance_eval(&block)
36
+ end
37
+
38
+ def attribute_keys
39
+ @attribute_keys
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ self.singleton_class.send(:alias_method, :a, :attribute)
46
+ self.singleton_class.send(:alias_method, :a!, :attribute!)
47
+
48
+ def raise_bad_type_if_type_mismatch(value, desired_type)
49
+ raise BadTypeError.new(value, desired_type) unless (value.kind_of?(desired_type)) || ((value.kind_of?(TrueClass) || value.kind_of?(TrueClass)) && desired_type == Requisite::Boolean)
50
+ end
51
+
52
+ def raise_not_implemented_for_attribute(name)
53
+ raise NotImplementedError.new("'#{name}' method not implemented")
54
+ end
55
+
56
+ def empty_result?(result)
57
+ result.nil? || result == {}
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,3 @@
1
+ module Requisite
2
+ VERSION = '0.1.0'
3
+ end
data/requisite.gemspec ADDED
@@ -0,0 +1,20 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'requisite/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = 'requisite'
8
+ gem.version = Requisite::VERSION
9
+ gem.authors = ['James Osler']
10
+ gem.email = ['jamie@intercom.io']
11
+ gem.summary = 'Strongly defined models for HTTP APIs'
12
+ gem.description = %q{ Requisite is an elegant way of strongly defining request and response models for serialization }
13
+ gem.homepage = 'https://www.intercom.io'
14
+ gem.license = 'Apache License Version 2.0'
15
+ gem.add_development_dependency "minitest"
16
+ gem.files = `git ls-files`.split($/)
17
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
18
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
19
+ gem.require_paths = ['lib']
20
+ end
@@ -0,0 +1,37 @@
1
+ require 'test_helper'
2
+
3
+ ## Example Object
4
+ class ApiEvent < Requisite::ApiModel
5
+ serialized_attributes do
6
+ attribute! :event_name, type: String
7
+ attribute :id, type: String
8
+ attribute :user_id, type: String
9
+ attribute :email, type: String
10
+ attribute :metadata, scalar_hash: true
11
+ end
12
+ end
13
+
14
+ module Requisite
15
+ describe ApiEvent do
16
+ it 'accepts an event' do
17
+ event_request_params = {
18
+ :event_name => 'bought',
19
+ :user_id => 'abcdef',
20
+ :metadata => {
21
+ :item => 'CD',
22
+ :price => 20.01
23
+ },
24
+ :junk => 'data'
25
+ }
26
+ event = ApiEvent.new(event_request_params)
27
+ event.to_hash.must_equal({
28
+ :event_name => 'bought',
29
+ :user_id => 'abcdef',
30
+ :metadata => {
31
+ :item => 'CD',
32
+ :price => 20.01
33
+ }
34
+ })
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,292 @@
1
+ require 'test_helper'
2
+
3
+ module Requisite
4
+ describe ApiModel do
5
+ it 'creates methods from serialized_attributes block' do
6
+ ApiModel.serialized_attributes { attribute :a; attribute :b }
7
+ response = ApiModel.new
8
+ def response.a; 'A'; end
9
+ def response.b; 2; end
10
+ response.to_hash.must_equal( :a => 'A', :b => 2 )
11
+ response.must_respond_to :a
12
+ response.must_respond_to :b
13
+ end
14
+
15
+ it 'attribute provides a default implementation of calling a hash model' do
16
+ ApiModel.serialized_attributes { attribute :c }
17
+ mock = {:c => 'C'}
18
+ response = ApiModel.new(mock)
19
+ response.to_hash.must_equal( :c => 'C' )
20
+ end
21
+
22
+ let(:params_hash) { {:c => 'C', :num => 12} }
23
+
24
+ it 'attribute provides a default implementation of calling a model' do
25
+ ApiModel.serialized_attributes { attribute :c }
26
+ response = ApiModel.new(params_hash)
27
+ response.to_hash.must_equal(:c => 'C')
28
+ end
29
+
30
+ it 'attribute can work with a default' do
31
+ ApiModel.serialized_attributes { attribute :c, default: 'see' }
32
+ response = ApiModel.new
33
+ response.to_hash.must_equal(:c => 'see')
34
+ end
35
+
36
+ it 'ignores default if value given' do
37
+ ApiModel.serialized_attributes { attribute :num, default: 0 }
38
+ response = ApiModel.new(params_hash)
39
+ response.to_hash.must_equal(:num => 12)
40
+ end
41
+
42
+ it 'attribute can be set to stringify fields' do
43
+ ApiModel.serialized_attributes { attribute :num, stringify: true }
44
+ response = ApiModel.new(params_hash)
45
+ response.to_hash.must_equal(:num => '12')
46
+ end
47
+
48
+ it 'attribute can be set to rename fields' do
49
+ ApiModel.serialized_attributes { attribute :my_num, rename: :num }
50
+ response = ApiModel.new(params_hash)
51
+ response.to_hash.must_equal(:my_num => 12)
52
+ end
53
+
54
+ it 'attribute can assert type of a field' do
55
+ ApiModel.serialized_attributes { attribute :num, type: String }
56
+ response = ApiModel.new(params_hash)
57
+ proc { response.to_hash }.must_raise(BadTypeError)
58
+ end
59
+
60
+ it 'with_type! helper raises on mismatched type' do
61
+ model = ApiModel.new()
62
+ proc { model.with_type!(String) { 1 + 2 }}.must_raise(Requisite::BadTypeError)
63
+ end
64
+
65
+ it 'first_attribute_from_model helper finds first matching attriubute' do
66
+ model = ApiModel.new(:oh => 12, :a => nil, :b => 'B', :c => 'C')
67
+ model.first_attribute_from_model(:a, :b, :c).must_equal('B')
68
+ end
69
+
70
+ it 'attribute can assert type of a boolean field' do
71
+ ApiModel.serialized_attributes { attribute :truthy_val, type: Requisite::Boolean }
72
+ response = ApiModel.new(:truthy_val => false)
73
+ response.to_hash.must_equal(:truthy_val => false)
74
+ end
75
+
76
+ it 'attribute does not include values of nil' do
77
+ ApiModel.serialized_attributes { attribute :num, type: String }
78
+ response = ApiModel.new({:num => nil})
79
+ response.to_hash.must_equal({})
80
+ end
81
+
82
+ it 'attribute can be stringified and renamed with default fields' do
83
+ ApiModel.serialized_attributes { attribute :my_num, rename: :num, stringify: true, default: 22 }
84
+ response = ApiModel.new
85
+ response.to_hash.must_equal(:my_num => '22')
86
+ end
87
+
88
+ it 'attribute can be stringified after type check' do
89
+ ApiModel.serialized_attributes { attribute :num, stringify: true, type: Fixnum }
90
+ response = ApiModel.new(params_hash)
91
+ response.to_hash.must_equal(:num => '12')
92
+ end
93
+
94
+ it 'attribute type checks after rename' do
95
+ ApiModel.serialized_attributes { attribute :my_num, rename: :num, type: String }
96
+ response = ApiModel.new(params_hash)
97
+ proc { response.to_hash }.must_raise(BadTypeError)
98
+ end
99
+
100
+ it 'attribute can be stringified, renamed, defaulted and have type checking on a field' do
101
+ ApiModel.serialized_attributes { attribute :my_num, rename: :num, stringify: true, default: 22, type: String }
102
+ response = ApiModel.new
103
+ proc { response.to_hash }.must_raise(BadTypeError)
104
+ end
105
+
106
+ let(:invalid_params_hash) { {:d => nil} }
107
+
108
+ it "attribute! raises an error if not found on model" do
109
+ ApiModel.serialized_attributes { attribute! :d }
110
+ response = ApiModel.new(invalid_params_hash)
111
+ proc { response.to_hash }.must_raise(NotImplementedError, "'d' not found on model")
112
+ end
113
+
114
+ it 'attribute! can be set to stringify fields' do
115
+ ApiModel.serialized_attributes { attribute! :num, stringify: true }
116
+ response = ApiModel.new(params_hash)
117
+ response.to_hash.must_equal(:num => '12')
118
+ end
119
+
120
+ it 'attribute! can be set to rename fields' do
121
+ ApiModel.serialized_attributes { attribute! :my_num, rename: :num }
122
+ response = ApiModel.new(params_hash)
123
+ response.to_hash.must_equal(:my_num => 12)
124
+ end
125
+
126
+ it 'sets the model from a hash' do
127
+ ApiModel.serialized_attributes { }
128
+ response = ApiModel.new(params_hash)
129
+ response.model.must_equal(params_hash)
130
+ end
131
+
132
+ it 'sets the model from an object' do
133
+ mc = MockClass.new
134
+ mc.a = 'a'
135
+ mc.b = 2
136
+ ApiModel.serialized_attributes { attribute :a }
137
+ response = ApiModel.new(mc)
138
+ response.model.must_equal(mc)
139
+ response.to_hash.must_equal(:a => 'a')
140
+ end
141
+
142
+ it 'has alias a for attribute' do
143
+ ApiModel.serialized_attributes { a :num }
144
+ response = ApiModel.new(params_hash)
145
+ response.to_hash.must_equal(:num => 12)
146
+ end
147
+
148
+ it 'has alias a! for attribute!' do
149
+ ApiModel.serialized_attributes { a! :num }
150
+ response = ApiModel.new(params_hash)
151
+ response.to_hash.must_equal(:num => 12)
152
+ end
153
+
154
+ it 'can convert to json' do
155
+ ApiModel.serialized_attributes { a! :num }
156
+ response = ApiModel.new(params_hash)
157
+ response.to_json.must_equal("{\"num\":12}")
158
+ end
159
+
160
+ it 'drops non-listed parameters' do
161
+ ApiModel.serialized_attributes { attribute :num }
162
+ response = ApiModel.new({num: 12, other: 'value'})
163
+ response.to_hash.must_equal(:num => 12)
164
+ end
165
+
166
+ describe 'with nested structures' do
167
+
168
+ describe 'with typed arrays' do
169
+ it 'allows arrays of one type' do
170
+ ApiModel.serialized_attributes { attribute :ids, typed_array: Fixnum }
171
+ response = ApiModel.new({ids: [1, 2, 3]})
172
+ response.to_hash.must_equal(:ids => [1, 2, 3])
173
+ end
174
+
175
+ it 'raises errors when array has a wrongly typed value' do
176
+ ApiModel.serialized_attributes { attribute :ids, typed_array: Requisite::Boolean }
177
+ response = ApiModel.new({ids: [true, 'value', false]})
178
+ Proc.new {response.to_hash}.must_raise(BadTypeError)
179
+ end
180
+ end
181
+
182
+ describe 'with typed nested hashes' do
183
+ it 'drops non listed parameters in nested hashes' do
184
+ ApiModel.serialized_attributes { attribute :data, typed_hash: { num: Numeric, bool: Requisite::Boolean } }
185
+ response = ApiModel.new({data: { num: 12, value: 'x', bool: true }})
186
+ response.to_hash.must_equal(:data => { :num => 12, :bool => true })
187
+ end
188
+
189
+ it 'can stringify nested hashes' do
190
+ ApiModel.serialized_attributes { attribute :data, typed_hash: { num: Numeric }, stringify: true }
191
+ response = ApiModel.new({data: { num: 12, value: 'x' }})
192
+ response.to_hash.must_equal(:data => "{:num=>12}")
193
+ end
194
+
195
+ it 'raises an error when nested hash values of the wrong type' do
196
+ ApiModel.serialized_attributes { attribute :data, typed_hash: { num: Numeric } }
197
+ Proc.new {ApiModel.new({data: { num: '12'}}).to_hash}.must_raise(BadTypeError)
198
+ end
199
+
200
+ it 'can rename param and work with nested hashes' do
201
+ ApiModel.serialized_attributes { attribute :my_data, typed_hash: { num: Numeric }, rename: :data }
202
+ response = ApiModel.new({data: { num: 12, value: 'x' }})
203
+ response.to_hash.must_equal(:my_data => { :num => 12 })
204
+ end
205
+
206
+ it 'can set a default value for a nested hash' do
207
+ ApiModel.serialized_attributes { attribute :data, typed_hash: { num: Numeric }, default: { num: 4 } }
208
+ response = ApiModel.new({data: { value: 'x' }})
209
+ response.to_hash.must_equal(:data => { :num => 4 })
210
+ end
211
+
212
+ it 'drops non listed fields with attribute!' do
213
+ ApiModel.serialized_attributes { attribute! :data, typed_hash: { num: Numeric } }
214
+ response = ApiModel.new({data: { num: 12, value: 'x' }})
215
+ response.to_hash.must_equal(:data => { :num => 12 })
216
+ end
217
+
218
+ it 'attribute! does not raise an error with missing values in hash' do
219
+ ApiModel.serialized_attributes { attribute! :data, typed_hash: { num: Numeric } }
220
+ response = ApiModel.new({data: { value: 'x' }})
221
+ response.to_hash.must_equal(:data => { })
222
+ end
223
+ end
224
+
225
+ describe 'with scalar only nested hashes' do
226
+ it 'should parse scalar hashes permitting anything scalar' do
227
+ ApiModel.serialized_attributes { attribute :data, scalar_hash: true }
228
+ response = ApiModel.new({data: { num: 12, value: 'x', :truthy => false }})
229
+ response.to_hash.must_equal(:data => { :num => 12, :value => 'x', :truthy => false })
230
+ end
231
+
232
+ it 'should parse a renamed scalar hash' do
233
+ ApiModel.serialized_attributes { attribute :my_data, scalar_hash: true, rename: :data }
234
+ response = ApiModel.new({data: { num: 12, value: 'x' }})
235
+ response.to_hash.must_equal(:my_data => { :num => 12, :value => 'x' })
236
+ end
237
+
238
+ it 'should stringify a scalar hash' do
239
+ ApiModel.serialized_attributes { attribute :data, scalar_hash: true, stringify: true }
240
+ response = ApiModel.new({data: { num: 12, value: 'x' }})
241
+ response.to_hash.must_equal(:data => "{:num=>12, :value=>\"x\"}")
242
+ end
243
+
244
+ it 'should parse scalar hashes permitting anything scalar with object' do
245
+ mc = MockClass.new
246
+ mc.a = 'a'
247
+ mc.b = { num: 12, value: 'x' }
248
+ ApiModel.serialized_attributes { attribute :b, scalar_hash: true }
249
+ response = ApiModel.new(mc)
250
+ response.to_hash.must_equal(:b => { :num => 12, :value => 'x' })
251
+ end
252
+
253
+ it 'should fail to parse scalar hashes when non scalar values present' do
254
+ ApiModel.serialized_attributes { attribute :data, scalar_hash: true }
255
+ Proc.new { ApiModel.new({data: { num: 12, value: { nested: 'value' } }}).to_hash}.must_raise(BadTypeError)
256
+ Proc.new { ApiModel.new({data: { num: 12, value: ['array value'] }}).to_hash}.must_raise(BadTypeError)
257
+ end
258
+
259
+ it 'should fail to parse scalar hashes permitting anything scalar with object' do
260
+ mc = MockClass.new
261
+ mc.a = 'a'
262
+ mc.b = { value: { nested: 'value' } }
263
+ ApiModel.serialized_attributes { attribute :b, scalar_hash: true }
264
+ response = ApiModel.new(mc)
265
+ Proc.new { response.to_hash }.must_raise(BadTypeError)
266
+ end
267
+
268
+ it 'can set a default value for a scalar hash' do
269
+ ApiModel.serialized_attributes { attribute :data, scalar_hash: true, default: { num: 9, value: 'y' } }
270
+ response = ApiModel.new({data: { }})
271
+ response.to_hash.must_equal(:data => { :num => 9, :value => 'y' })
272
+ end
273
+
274
+ it 'doesnt raise with attribute! when an empty hash passed' do
275
+ ApiModel.serialized_attributes { attribute! :data, scalar_hash: true }
276
+ response = ApiModel.new({data: {}})
277
+ response.to_hash.must_equal(:data => {})
278
+ end
279
+
280
+ it 'raises with attribute! when nil is passed' do
281
+ ApiModel.serialized_attributes { attribute! :data, scalar_hash: true }
282
+ response = ApiModel.new({data: nil})
283
+ Proc.new {response.to_hash}.must_raise(NotImplementedError)
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
289
+
290
+ class MockClass
291
+ attr_accessor :a, :b
292
+ end
@@ -0,0 +1,75 @@
1
+ require 'test_helper'
2
+
3
+ # Example Object
4
+ class ApiUser < Requisite::ApiModel
5
+ serialized_attributes do
6
+ attribute :id, type: String
7
+ attribute :user_id
8
+ attribute :email, type: String
9
+ attribute :name, type: String
10
+ attribute :created_at, type: Fixnum
11
+ attribute :last_seen_user_agent, type: String
12
+ attribute :last_request_at, type: Fixnum
13
+ attribute :unsubscribed_from_emails, type: Requisite::Boolean
14
+ attribute :update_last_request_at, type: Requisite::Boolean
15
+ attribute :new_session, type: Requisite::Boolean
16
+ attribute :custom_data, scalar_hash: true, rename: :custom_attributes
17
+ attribute :company
18
+ attribute :companies
19
+ end
20
+
21
+ # Ensure that at least one identifier is passed
22
+ def preprocess_model
23
+ identifier = attribute_from_model(:id)
24
+ identifier ||= attribute_from_model(:user_id)
25
+ identifier ||= attribute_from_model(:email)
26
+ raise StandardError unless identifier
27
+ end
28
+
29
+ # We want to accept someone sending `created_at` or `created` as parameters
30
+ def created_at
31
+ with_type!(Fixnum) { attribute_from_model(:created_at) || attribute_from_model(:created) }
32
+ end
33
+ end
34
+
35
+ module Requisite
36
+ describe ApiUser do
37
+ it 'accepts a user' do
38
+ user_request_params = {
39
+ :user_id => 'abcdef',
40
+ :name => 'Bob',
41
+ :created => 1414173164,
42
+ :new_session => true,
43
+ :custom_attributes => {
44
+ :is_cool => true,
45
+ :logins => 77
46
+ },
47
+ :junk => 'data'
48
+ }
49
+ user = ApiUser.new(user_request_params)
50
+ user.to_hash.must_equal({
51
+ :user_id => 'abcdef',
52
+ :name => 'Bob',
53
+ :created_at => 1414173164,
54
+ :new_session => true,
55
+ :custom_data => {
56
+ :is_cool => true,
57
+ :logins => 77
58
+ }
59
+ })
60
+ user.name.must_equal('Bob')
61
+ end
62
+
63
+ it 'raises an error without an identifier' do
64
+ user_request_params = { :name => 'Bob' }
65
+ user = ApiUser.new(user_request_params)
66
+ proc { user.to_hash }.must_raise(StandardError)
67
+ end
68
+
69
+ it 'raises an error when created or created_at is not of the right type' do
70
+ user_request_params = { :user_id => 'abcdef', :created => 'Thursday' }
71
+ user = ApiUser.new(user_request_params)
72
+ proc { user.to_hash }.must_raise(Requisite::BadTypeError)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,4 @@
1
+ require 'requisite'
2
+ require 'minitest/autorun'
3
+ require 'minitest/mock'
4
+ require 'minitest/spec'
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: requisite
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - James Osler
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-11-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '>='
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '>='
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: ' Requisite is an elegant way of strongly defining request and response
28
+ models for serialization '
29
+ email:
30
+ - jamie@intercom.io
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - .gitignore
36
+ - Gemfile
37
+ - LICENCE.txt
38
+ - README.md
39
+ - Rakefile
40
+ - lib/requisite.rb
41
+ - lib/requisite/api_model.rb
42
+ - lib/requisite/bad_request_error.rb
43
+ - lib/requisite/bad_type_error.rb
44
+ - lib/requisite/boolean.rb
45
+ - lib/requisite/boundary_object.rb
46
+ - lib/requisite/version.rb
47
+ - requisite.gemspec
48
+ - test/requisite/api_event_test.rb
49
+ - test/requisite/api_model_test.rb
50
+ - test/requisite/api_user_test.rb
51
+ - test/test_helper.rb
52
+ homepage: https://www.intercom.io
53
+ licenses:
54
+ - Apache License Version 2.0
55
+ metadata: {}
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - '>='
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubyforge_project:
72
+ rubygems_version: 2.0.14
73
+ signing_key:
74
+ specification_version: 4
75
+ summary: Strongly defined models for HTTP APIs
76
+ test_files:
77
+ - test/requisite/api_event_test.rb
78
+ - test/requisite/api_model_test.rb
79
+ - test/requisite/api_user_test.rb
80
+ - test/test_helper.rb
81
+ has_rdoc: