schemad 1.0.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: 42533ea7f5721acf5f2bcc3c10a10322b61d6e76
4
+ data.tar.gz: d24a50de59c6ce8d81d5c5466f19b993e05bfe06
5
+ SHA512:
6
+ metadata.gz: 9cced2f7ca522ac55286b5c34e7ab1539a342c16c97831cbeb838987f1885790dd81d2bc73a80f4783d166ef5bbc0258a5fc61cf8fab9c97befe6c812dd9f1a6
7
+ data.tar.gz: c828d9d9c9c0ab5e7df1d50613ec45c63f7d13f91ac5cb7db404a896f2158b63a9270580ba3b41707c3a46020ae586d898c53e462a3b69a97db3a3c02b3a2998
data/.gitignore ADDED
@@ -0,0 +1,22 @@
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
18
+ *.bundle
19
+ *.so
20
+ *.o
21
+ *.a
22
+ mkmf.log
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in schemad.gemspec
4
+ gemspec
5
+
6
+ group :test do
7
+ gem 'rspec-given'
8
+ gem 'flexmock'
9
+ gem 'timecop'
10
+
11
+ gem 'pry'
12
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Luke van der Hoeven
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,327 @@
1
+ # Schemad
2
+
3
+ Schemad is a simple metagem to aid integrating legacy or third-party datasets into other projects. It's especially geared towards unifying multiple datasets into consistent data structures for ease of and consistency in use.
4
+
5
+ This gem has two main parts: Normalizers and Entities.
6
+
7
+ ## Normalizers
8
+
9
+ Normalizers are the translators between different datasets. They take misshaped data and help mold it into a consistent form before turning them into objects for general use.
10
+
11
+ For example, let's say I want to pull commit data from [GitHub](https://github.com) and [BitBucket](https://bitbucket.org) and do something with the two datasets. Let's look at the API for both and the kind of data they return for a commit object.
12
+
13
+ ### GitHub Commit API
14
+ > [Source](https://developer.github.com/v3/git/commits/)
15
+
16
+ ```json
17
+ {
18
+ "sha": "7638417db6d59f3c431d3e1f261cc637155684cd",
19
+ "url": "https://api.github.com/repos/octocat/Hello-World/git/commits/7638417db6d59f3c431d3e1f261cc637155684cd",
20
+ "author": {
21
+ "date": "2010-04-10T14:10:01-07:00",
22
+ "name": "Scott Chacon",
23
+ "email": "schacon@gmail.com"
24
+ },
25
+ "committer": {
26
+ "date": "2010-04-10T14:10:01-07:00",
27
+ "name": "Scott Chacon",
28
+ "email": "schacon@gmail.com"
29
+ },
30
+ "message": "added readme, because im a good github citizen\n",
31
+ "tree": {
32
+ "url": "https://api.github.com/repos/octocat/Hello-World/git/trees/691272480426f78a0138979dd3ce63b77f706feb",
33
+ "sha": "691272480426f78a0138979dd3ce63b77f706feb"
34
+ },
35
+ "parents": [
36
+ {
37
+ "url": "https://api.github.com/repos/octocat/Hello-World/git/commits/1acc419d4d6a9ce985db7be48c6349a0475975b5",
38
+ "sha": "1acc419d4d6a9ce985db7be48c6349a0475975b5"
39
+ }
40
+ ]
41
+ }
42
+ ```
43
+
44
+ ### BitBucket Commit API
45
+ > [Source](https://confluence.atlassian.com/display/BITBUCKET/commits+or+commit+Resource#commitsorcommitResource-GETanindividualcommit)
46
+
47
+ ```json
48
+ {
49
+ hash: "61d9e64348f9da407e62f64726337fd3bb24b466",
50
+ links: {
51
+ self: {
52
+ href: "https://api.bitbucket.org/2.0/repositories/atlassian/atlassian-rest/commit/61d9e64348f9da407e62f64726337fd3bb24b466"
53
+ },
54
+ comments: {
55
+ href: "https://api.bitbucket.org/2.0/repositories/atlassian/atlassian-rest/commit/61d9e64348f9da407e62f64726337fd3bb24b466/comments"
56
+ },
57
+ patch: {
58
+ href: "https://api.bitbucket.org/2.0/repositories/atlassian/atlassian-rest/patch/61d9e64348f9da407e62f64726337fd3bb24b466"
59
+ },
60
+ html: {
61
+ href: "https://api.bitbucket.org/atlassian/atlassian-rest/commits/61d9e64348f9da407e62f64726337fd3bb24b466"
62
+ },
63
+ diff: {
64
+ href: "https://api.bitbucket.org/2.0/repositories/atlassian/atlassian-rest/diff/61d9e64348f9da407e62f64726337fd3bb24b466"
65
+ },
66
+ approve: {
67
+ href: "https://api.bitbucket.org/2.0/repositories/atlassian/atlassian-rest/commit/61d9e64348f9da407e62f64726337fd3bb24b466/approve"
68
+ }
69
+ },
70
+ repository: {
71
+ links: {
72
+ self: {
73
+ href: "https://api.bitbucket.org/2.0/repositories/atlassian/atlassian-rest"
74
+ },
75
+ avatar: {
76
+ href: "https://d3oaxc4q5k2d6q.cloudfront.net/m/bf1e763db20f/img/language-avatars/java_16.png"
77
+ }
78
+ },
79
+ full_name: "atlassian/atlassian-rest",
80
+ name: "atlassian-rest"
81
+ },
82
+ author: {
83
+ raw: "Joseph Walton <jwalton@atlassian.com>",
84
+ user: {
85
+ username: "jwalton",
86
+ display_name: "Joseph Walton",
87
+ links: {
88
+ self: {
89
+ href: "https://api.bitbucket.org/2.0/users/jwalton"
90
+ },
91
+ avatar: {
92
+ href: "https://secure.gravatar.com/avatar/8e6e91101e3ed8a332dbebfdf59a3cef?d=https%3A%2F%2Fd3oaxc4q5k2d6q.cloudfront.net%2Fm%2Fbf1e763db20f%2Fimg%2Fdefault_avatar%2F32%2Fuser_blue.png&s=32"
93
+ }
94
+ }
95
+ }
96
+ },
97
+ participants: [{
98
+ role: "PARTICIPANT",
99
+ user: {
100
+ username: "evzijst",
101
+ display_name: "Erik van Zijst",
102
+ links: {
103
+ self: {
104
+ href: "https://api.bitbucket.org/2.0/users/evzijst"
105
+ },
106
+ avatar: {
107
+ href: "https://secure.gravatar.com/avatar/f6bcbb4e3f665e74455bd8c0b4b3afba?d=https%3A%2F%2Fd3oaxc4q5k2d6q.cloudfront.net%2Fm%2Fbf1e763db20f%2Fimg%2Fdefault_avatar%2F32%2Fuser_blue.png&s=32"
108
+ }
109
+ }
110
+ },
111
+ approved: false
112
+ }],
113
+ parents: [{
114
+ hash: "59721f593b020123a75424285845325126f56e2e",
115
+ links: {
116
+ self: {
117
+ href: "https://api.bitbucket.org/2.0/repositories/atlassian/atlassian-rest/commit/59721f593b020123a75424285845325126f56e2e"
118
+ }
119
+ }
120
+ }, {
121
+ hash: "56c49d8b2ae3a094fa7ba5a1251d6dd2c7c66993",
122
+ links: {
123
+ self: {
124
+ href: "https://api.bitbucket.org/2.0/repositories/atlassian/atlassian-rest/commit/56c49d8b2ae3a094fa7ba5a1251d6dd2c7c66993"
125
+ }
126
+ }
127
+ }],
128
+ date: "2013-10-21T07:21:51+00:00",
129
+ message: "Merge remote-tracking branch 'origin/rest-2.8.x' "
130
+ }
131
+ ```
132
+
133
+ Obviously, mining the two datasets for a bunch of commits will require a lot of parsing to unify the two data structures presented here.
134
+
135
+ In step the Normalizers. For this example ee'd create two separate normalizers for these datasets, one for GH, one for BB:
136
+
137
+ ```ruby
138
+ class GitHubNormalizer < Schemad::Normalizer
139
+ normalize :id, key: :sha
140
+ normalize :url, key: :url
141
+ normalize :committer, key: "committer/name"
142
+ normalize :created_date, key: "committer/date"
143
+ normalize :comment, key: :message
144
+ end
145
+ ```
146
+
147
+ We could obviously also include additional data if we wanted. Notice you can use either symbols or strings as keys. If you want to do a deep traversal (more than one level), you will need to use strings with a "/" delimited path.
148
+
149
+ Now for BitBucket:
150
+
151
+ ```ruby
152
+ class BitBucketNormalizer < Schemad::Normalizer
153
+ normalize :id, key: :hash
154
+ normalize :url, key: "links/self/href"
155
+ normalize :committer, key: "author/user/display_name"
156
+ normalize :created_date, key: :date
157
+ normalize :comment, key: :message
158
+ end
159
+ ```
160
+
161
+ Sweet. So now when we get the json from the API, all we need to do with these classes is:
162
+
163
+ ```ruby
164
+ raw_json = some_http_get("https://api.github.com/path/to/my/commit")
165
+ github_data = JSON.parse(raw_json)
166
+
167
+ parsed = GitHubNormalizer.new.normalize(github_data)
168
+ ```
169
+
170
+ And we should then have a plain hash much like the following:
171
+
172
+ ```ruby
173
+ {
174
+ id: "7638417db6d59f3c431d3e1f261cc637155684cd",
175
+ url: "https://api.github.com/repos/octocat/Hello-World/git/commits/7638417db6d59f3c431d3e1f261cc637155684cd",
176
+ committer: "Scott Chacon",
177
+ created_date: "2010-04-10T14:10:01-07:00",
178
+ comment: "added readme, because im a good github citizen\n"
179
+ }
180
+ ```
181
+
182
+ Our BitBucket normalizer would work the same way, we'd just use it to run through data harvested by our BitBucket requests.
183
+
184
+ Two things of mention: First, the normalizers can also be passed a block to perform additional data manipulation. For example, assume we wanted to harvest an email field. GitHub provides this to us directly to use:
185
+
186
+ ```ruby
187
+ class GitHubNormalizer < Schemad::Normalizer
188
+ # ... other normalizers
189
+ normalize :email, key: "committer/email"
190
+ end
191
+ ```
192
+
193
+ However, BitBucket does not directly. It's wrapped within the "raw" field in the author hash. So we can provide additional modifiers to get this:
194
+
195
+ ```ruby
196
+ class BitBucketNormalizer < Schemad::Normalizer
197
+ # ... other normalizers
198
+ normalize :email, key: "author/raw" do |value|
199
+ value.match(/\A[\w|\s]+<(.+)>\z/).captures.first
200
+ end
201
+ end
202
+ ```
203
+
204
+ Now the normalizer will use the raw field and pick out the email using a regex matcher. Now you might be tempted to use the normalizer blocks to manipulate the fields into ruby types, as the normalizers do not attempt to parse the data types. You'll notice in the above examples that date strings are left as strings through the normalization. This brings us to our second thing to note: Normalizers _only parse data into consistent structures_. They are not responsible for type casting. This is intentional and brings us to the role of the Entity.
205
+
206
+ ## Entities
207
+
208
+ Entities provide consistent [value objects](http://martinfowler.com/bliki/ValueObject.html) that allow for easily transporting the data to functionality that uses the data. Entities are very limited in functionality and are mainly meant to provide a more ruby-ish means of accessing the data. We _could_ pass around the normalized hashes, but typically, we rubyists like having method access to our data:
209
+
210
+ ```ruby
211
+ commit.comment # "added readme, because im a good github citizen\n"
212
+ commit.id # "7638417db6d59f3c431d3e1f261cc637155684cd"
213
+ comment.created_date # A time object!
214
+ ```
215
+
216
+ So this is what Entities provide.
217
+
218
+ ```ruby
219
+ class Commit < Schemad::Entity
220
+ attribute :id
221
+ attribute :committer
222
+ attribute :comment
223
+ attribute :created_date, type: :date_time
224
+ attribute :email
225
+ attribute :url
226
+ end
227
+ ```
228
+
229
+ Note that the default attribute type (if not provided) is a string (`:string`). Currently supported types are
230
+
231
+ - :string
232
+ - :time, :date, :date_time (all the same in our case)
233
+ - :integer
234
+ - :boolean
235
+
236
+ New types are easy to create, more on this in a moment.
237
+
238
+ To instantiate these new class, we use the `from_data` method to ensure parsing with the output from the normalizer step above:
239
+
240
+ ```ruby
241
+ raw_json = some_http_get("https://api.github.com/path/to/my/commit")
242
+ github_data = JSON.parse(raw_json)
243
+
244
+ parsed = GitHubNormalizer.new.normalize(github_data)
245
+
246
+ commit = Commit.from_data(parsed)
247
+
248
+ commit.comment # "added readme, because im a good github citizen\n"
249
+ commit.id # "7638417db6d59f3c431d3e1f261cc637155684cd"
250
+ comment.created_date # A time object!
251
+ ```
252
+
253
+ You don't have to use the normalizers to use the `from_data` method. It can be any consistently formatted hash. The keys **must** be accessible by symbol however (use a hash with all symbols as keys or an ActiveSupport/Hashie/other [HashWithIndifferentAccess](http://api.rubyonrails.org/classes/ActiveSupport/HashWithIndifferentAccess.html) implementation).
254
+
255
+ In fact, both normalizer and entity can be used independent of one another if one or the other isn't required for your use. Just include the library you want:
256
+
257
+ ```ruby
258
+ require 'schemad/type_handler'
259
+ require 'schemad/normalizer'
260
+ require 'schemad/entity'
261
+
262
+ # or to get all...
263
+ require 'schemad'
264
+ ```
265
+
266
+ ## Type Handlers
267
+
268
+ On a side note, we have a number of very simple type handlers, you can see them all in the various type definitions [type_handler](https://github.com/plukevdh/schemad/tree/master/lib/schemad/types).
269
+
270
+ It is _also_ possible to use these however you want in your own classes, but there are far more complete and complex type handlers elsewhere. If you find these types unsatisfactory or wish to use additional types, you can easily define your own.
271
+
272
+ ```ruby
273
+ class YouMomHandler < Schemad::AbstractHandler
274
+ handle :your_mom
275
+
276
+ def parse(value)
277
+ "Your Mom"
278
+ end
279
+ end
280
+
281
+ # register this handler
282
+ Schemad::TypeHandler.register YourMomHandler
283
+
284
+ # alternatively...
285
+ YourMomHandler.register_with Schemad::TypeHandler
286
+ ```
287
+
288
+ Now when you want to have your entity turn anything into your mother, simply add the type `:your_mom` to the attribute definition.
289
+
290
+ ```ruby
291
+ class Commit < Schemad::Entity
292
+ # ...
293
+ attribute :comment, type: :your_mom
294
+ end
295
+
296
+ commit = Commit.from_data(parsed)
297
+
298
+ commit.comment # "Your Mom"
299
+ ```
300
+
301
+ ## Notes
302
+
303
+ [Hashie](https://github.com/intridea/hashie) is probably a better idea than this gem. But I couldn't find a decent way to combine the [DeepFetch](https://github.com/intridea/hashie#deepfetch) functionality with the [Dash](https://github.com/intridea/hashie#dash)/[Trash](https://github.com/intridea/hashie#trash) functionality. This is also likely much ligher weight and therefore about half as meta.
304
+
305
+ Be warned, this is a lot of crazy metacode and it's _mostly_ recommended you don't use this for real. Mostly. But it is awesome.
306
+
307
+ ## Installation
308
+
309
+ Add this line to your application's Gemfile:
310
+
311
+ gem 'schemad'
312
+
313
+ And then execute:
314
+
315
+ $ bundle
316
+
317
+ Or install it yourself as:
318
+
319
+ $ gem install schemad
320
+
321
+ ## Contributing
322
+
323
+ 1. Fork it ( https://github.com/[my-github-username]/schemad/fork )
324
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
325
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
326
+ 4. Push to the branch (`git push origin my-new-feature`)
327
+ 5. Create a new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
7
+
8
+
@@ -0,0 +1,16 @@
1
+ module Schemad
2
+ class AbstractHandler
3
+ def self.register_with(with)
4
+ with.register self
5
+ end
6
+
7
+ def self.handle(*types)
8
+ @types ||= []
9
+ @types.concat types
10
+ end
11
+
12
+ def self.handles
13
+ @types
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,74 @@
1
+ require 'schemad/extensions'
2
+ require 'schemad/type_handler'
3
+
4
+ module Schemad
5
+ class Entity
6
+ extend Schemad::Extensions
7
+
8
+ def self.inherited(subclass)
9
+ subclass.instance_variable_set(:@attributes, [])
10
+ end
11
+
12
+ def self.attribute(name, args={}, &block)
13
+ attr_accessor name
14
+
15
+ define_parser_for(name, args, &block)
16
+
17
+ @attributes << name
18
+ end
19
+
20
+ # expect data hash to have symbol keys at this point
21
+ # normalizer should standardize this
22
+ def self.from_data(data)
23
+ obj = new
24
+
25
+ @attributes.each do |key|
26
+ value = obj.send "parse_#{key}", data
27
+ obj.send "#{key}=", value
28
+ end
29
+
30
+ obj
31
+ end
32
+
33
+ def attribute_names
34
+ self.class.instance_variable_get(:@attributes)
35
+ end
36
+
37
+ def to_hash
38
+ hash = {}
39
+ attribute_names.each do |key|
40
+ hash[key] = send key
41
+ end
42
+
43
+ hash
44
+ end
45
+ alias_method :attributes, :to_hash
46
+
47
+ private
48
+
49
+ def self.define_parser_for(name, args, &block)
50
+ define_method "parse_#{name}" do |data|
51
+ value = data[name]
52
+ value ||= get_default(args[:default])
53
+
54
+ self.send "#{name}=", coerce_to_type(value, args[:type])
55
+ end
56
+
57
+ define_method "#{name}?" do
58
+ !!send(name)
59
+ end if args[:type] == :boolean
60
+ end
61
+
62
+ def get_default(default_provider)
63
+ return default_provider unless default_provider.is_a? Proc
64
+ default_provider.call
65
+ end
66
+
67
+ def coerce_to_type(value, type)
68
+ type ||= :string
69
+
70
+ handler = TypeHandler.new type
71
+ handler.parse(value)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,39 @@
1
+ require 'active_support/inflector'
2
+ require 'active_support/concern'
3
+ require 'active_support/core_ext/hash'
4
+ require 'active_support/hash_with_indifferent_access'
5
+
6
+ module Schemad
7
+ module Extensions
8
+ extend ActiveSupport::Concern
9
+
10
+ module ClassMethods
11
+ def base_class_name
12
+ name.demodulize
13
+ end
14
+ end
15
+
16
+ def base_class_name
17
+ self.class.base_class_name
18
+ end
19
+
20
+ private
21
+ # TODO: decide if I still want this method around. modifies repos
22
+ # def add_behavior(repo, additions)
23
+ # repo.extend additions
24
+ # end
25
+
26
+ def constantize(string)
27
+ string.to_s.constantize
28
+ end
29
+
30
+ def classify(string)
31
+ string = "nil" if string.nil?
32
+ string.to_s.classify
33
+ end
34
+
35
+ def indifferent_hash(hash)
36
+ ActiveSupport::HashWithIndifferentAccess.new hash
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,93 @@
1
+ require 'schemad/extensions'
2
+
3
+ module Schemad
4
+ class Normalizer
5
+ include Schemad::Extensions
6
+
7
+ DELIMITER = "/"
8
+
9
+ InvalidPath = Class.new(Exception)
10
+
11
+ def self.inherited(subclass)
12
+ subclass.instance_variable_set(:@normalizers, {})
13
+ subclass.instance_variable_set(:@allowed_attributes, [])
14
+ end
15
+
16
+ def self.include_fields(fields)
17
+ @allowed_attributes.concat fields
18
+ end
19
+
20
+ def self.normalize(name, args={}, &block)
21
+ lookup = args[:key] || name
22
+ method_name = normalizer_method_name(name)
23
+
24
+ @normalizers[lookup] = name
25
+ @allowed_attributes << lookup
26
+
27
+ define_method method_name do |data|
28
+ value = find_value lookup, data
29
+ return value unless block_given?
30
+
31
+ yield value
32
+ end
33
+ end
34
+
35
+ def normalize(data)
36
+ normalized = {}
37
+
38
+ allowed_attributes.each do |key|
39
+ to_key = normalizers[key]
40
+
41
+ if to_key
42
+ normalized[to_key] = self.send normalizer_method_name(to_key), data
43
+ else
44
+ normalized[key_from_path(key)] = find_value(key, data)
45
+ end
46
+ end
47
+
48
+ normalized
49
+ end
50
+
51
+ def normalizers
52
+ self.class.instance_variable_get(:@normalizers)
53
+ end
54
+
55
+ def allowed_attributes
56
+ self.class.instance_variable_get(:@allowed_attributes)
57
+ end
58
+
59
+ private
60
+ def path_steps(key)
61
+ key.to_s.split(DELIMITER)
62
+ end
63
+
64
+ def key_from_path(path)
65
+ path_steps(path).last.to_sym
66
+ end
67
+
68
+ def find_value(key, data)
69
+ begin
70
+ search_data path_steps(key), indifferent_hash(data)
71
+ rescue InvalidPath => e
72
+ # rethrow with more info
73
+ raise e, "Can't find value for \"#{key}\""
74
+ end
75
+ end
76
+
77
+ def search_data(steps, data)
78
+ step = steps.shift
79
+ return data unless step
80
+ raise InvalidPath if data.nil?
81
+
82
+ search_data steps, data[step]
83
+ end
84
+
85
+ def self.normalizer_method_name(field)
86
+ "normalize_#{field}".to_sym
87
+ end
88
+
89
+ def normalizer_method_name(field)
90
+ self.class.normalizer_method_name(field)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,34 @@
1
+ require 'time'
2
+ require 'schemad/extensions'
3
+
4
+ module Schemad
5
+ class TypeHandler
6
+ include Extensions
7
+ extend Forwardable
8
+
9
+ UnknownHandler = Class.new(Exception)
10
+
11
+ def self.register(handler)
12
+ @types ||= {}
13
+
14
+ handler.handles.each do |type|
15
+ @types[type] = handler
16
+ end
17
+ end
18
+
19
+ def initialize(type=:string)
20
+ handler = handlers[type]
21
+
22
+ raise UnknownHandler, "No known handlers for #{classify(type)}" if handler.nil?
23
+
24
+ @handler = handler.new
25
+ end
26
+
27
+ def_delegators :@handler, :parse
28
+
29
+ private
30
+ def handlers
31
+ self.class.instance_variable_get(:@types) || {}
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,13 @@
1
+ module Schemad
2
+ class BooleanHandler < AbstractHandler
3
+ VALID_TRUTHS = ["true", true, "t", "T", "1", 1, "TRUE"]
4
+
5
+ handle :boolean, :bool
6
+
7
+ def parse(value)
8
+ VALID_TRUTHS.include? value
9
+ end
10
+ end
11
+ end
12
+
13
+ Schemad::BooleanHandler.register_with Schemad::TypeHandler
@@ -0,0 +1,16 @@
1
+ module Schemad
2
+ class IntegerHandler < AbstractHandler
3
+ handle :integer
4
+
5
+ def parse(value)
6
+ case value
7
+ when TrueClass, FalseClass
8
+ value ? 1 : 0
9
+ else
10
+ value.to_i rescue nil
11
+ end
12
+ end
13
+ end
14
+ end
15
+
16
+ Schemad::IntegerHandler.register_with Schemad::TypeHandler
@@ -0,0 +1,11 @@
1
+ module Schemad
2
+ class StringHandler < AbstractHandler
3
+ handle :string
4
+
5
+ def parse(value)
6
+ value.to_s
7
+ end
8
+ end
9
+ end
10
+
11
+ Schemad::StringHandler.register_with Schemad::TypeHandler
@@ -0,0 +1,19 @@
1
+ module Schemad
2
+ class TimeHandler < AbstractHandler
3
+ handle :time, :date, :date_time
4
+
5
+ def parse(value)
6
+ return value.to_time if value.respond_to?(:to_time)
7
+
8
+ begin
9
+ Time.at(value)
10
+ rescue TypeError => e
11
+ Time.parse(value)
12
+ rescue ArgumentError => e
13
+ nil
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ Schemad::TimeHandler.register_with Schemad::TypeHandler
@@ -0,0 +1,3 @@
1
+ module Schemad
2
+ VERSION = "1.0.0"
3
+ end
data/lib/schemad.rb ADDED
@@ -0,0 +1,7 @@
1
+ require "schemad/version"
2
+
3
+ require 'schemad/type_handler'
4
+ require 'schemad/normalizer'
5
+ require 'schemad/entity'
6
+
7
+ module Schemad ; end
data/schemad.gemspec 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 'schemad/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "schemad"
8
+ spec.version = Schemad::VERSION
9
+ spec.authors = ["Luke van der Hoeven"]
10
+ spec.email = ["hungerandthirst@gmail.com"]
11
+ spec.summary = %q{Simple schema DSL for services}
12
+ spec.description = %q{
13
+ This gem allows easy attribute definition, type casting and special handling
14
+ of data returned from a service of some kind. It's meant to be incredibly general
15
+ purpose. Maybe this is a bad idea...
16
+ }
17
+ spec.homepage = ""
18
+ spec.license = "MIT"
19
+
20
+ spec.files = `git ls-files -z`.split("\x0")
21
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
22
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
23
+ spec.require_paths = ["lib"]
24
+
25
+ spec.add_dependency "activesupport", "~> 4.1"
26
+
27
+ spec.add_development_dependency "bundler", "~> 1.6"
28
+ spec.add_development_dependency "rake"
29
+ end
@@ -0,0 +1,56 @@
1
+ require 'spec_helper'
2
+ require 'timecop'
3
+ require 'schemad/entity'
4
+
5
+ require_relative 'fixtures/demo_class'
6
+
7
+ describe Schemad::Entity do
8
+ before { Timecop.freeze }
9
+ after { Timecop.return }
10
+
11
+ Given(:normal_data) {{
12
+ world: "coordinates",
13
+ cool: true,
14
+ roads: 5,
15
+ beasts: "1337"
16
+ }}
17
+
18
+ context "#from_data" do
19
+ Given(:ent) { Ent.from_data(normal_data) }
20
+
21
+ Then { ent.attribute_names.should == [:forest, :roads, :beasts, :world, :cool, :created]}
22
+
23
+ context "defaults or nil get used when no data" do
24
+
25
+ Then { ent.forest.should == "Green" }
26
+ And { ent.cool.should be_true }
27
+ And { ent.created.should eq(Time.now) }
28
+ And { ent.roads.should == 5 }
29
+ And { ent.world.should == "coordinates" }
30
+ end
31
+
32
+ context "parses types" do
33
+ Then { ent.beasts.should == 1337 }
34
+ end
35
+
36
+ context "defines #? method for bools" do
37
+ Then { ent.should be_cool }
38
+ end
39
+
40
+ context "assumes string if no type given" do
41
+ Then { ent.world.should be_a(String) }
42
+ end
43
+
44
+ context "can get all params as a hash" do
45
+ When(:hash) { ent.to_hash }
46
+ Then { hash.should == {
47
+ forest: "Green",
48
+ roads: 5,
49
+ beasts: 1337,
50
+ world: "coordinates",
51
+ cool: true,
52
+ created: Time.now
53
+ }}
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,41 @@
1
+ require 'spec_helper'
2
+ require 'schemad/extensions'
3
+
4
+ class Demo
5
+ class Foo
6
+ class Bar
7
+ include Schemad::Extensions
8
+ end
9
+ end
10
+ end
11
+
12
+ describe Schemad::Extensions do
13
+ Given(:ext) { Demo::Foo::Bar.new }
14
+
15
+ context "base class name getter" do
16
+ When(:class_base) { Demo::Foo::Bar.base_class_name }
17
+ Then { class_base.should == "Bar" }
18
+ end
19
+
20
+ context "instance method for base class" do
21
+ When(:base) { ext.base_class_name }
22
+ Then { base.should == "Bar" }
23
+ end
24
+
25
+ context "constantizer" do
26
+ When(:const) { ext.send :constantize, "Demo::Foo::Bar" }
27
+ Then { const.should == Demo::Foo::Bar }
28
+ end
29
+
30
+ context "classifier" do
31
+ When(:classy) { ext.send :classify, "demo_foo_bar" }
32
+ Then { classy.should == "DemoFooBar" }
33
+ end
34
+
35
+ context "indifferent hash wrapper" do
36
+ When(:converted) { ext.send :indifferent_hash, {one: 1, "two" => 2, "Three and Four" => 34} }
37
+ Then { converted["one"].should == 1 }
38
+ And { converted[:two].should == 2 }
39
+ And { converted["Three and Four"].should == 34 }
40
+ end
41
+ end
@@ -0,0 +1,8 @@
1
+ class Ent < Schemad::Entity
2
+ attribute :forest, type: :string, default: "Green"
3
+ attribute :roads, type: :integer
4
+ attribute :beasts, type: :integer
5
+ attribute :world
6
+ attribute :cool, type: :boolean
7
+ attribute :created, type: :date_time, default: -> { Time.now }
8
+ end
@@ -0,0 +1,110 @@
1
+ require 'spec_helper'
2
+ require 'schemad/normalizer'
3
+
4
+ class Demalizer < Schemad::Normalizer
5
+ normalize :world, key: "Middle Earth" do |val|
6
+ val.upcase
7
+ end
8
+ normalize :roads do |val|
9
+ val * 10
10
+ end
11
+ end
12
+
13
+ describe Schemad::Normalizer do
14
+ Given(:data) {{
15
+ "Middle Earth" => "coordinates",
16
+ cool: "true",
17
+ roads: 5,
18
+ "beasts" => "1337"
19
+ }}
20
+
21
+ Given(:normalizer) { Demalizer.new }
22
+
23
+ context "normalization" do
24
+ When(:normalized) { normalizer.normalize(data) }
25
+
26
+ context "maps specified keys" do
27
+ Then { normalized[:world].should == "COORDINATES" }
28
+ And { normalized[:roads].should == 50 }
29
+ end
30
+
31
+ context "does not pass through unspecified keys" do
32
+ Then { normalized[:cool].should be_nil }
33
+ And { normalized[:beasts].should be_nil }
34
+ end
35
+ end
36
+
37
+ context "additional fields" do
38
+ Given { normalizer.class.include_fields [:beasts, :cool] }
39
+ When(:normalized) { normalizer.normalize(data) }
40
+
41
+ context "can be specified" do
42
+ Then { normalized[:cool].should == "true" }
43
+ And { normalized[:beasts].should == "1337" }
44
+ end
45
+
46
+ context "converts all keys to symbols" do
47
+ Then { normalized[:cool].should_not be_nil }
48
+ And { normalized[:beasts].should_not be_nil }
49
+ And { normalized["Middle Earth"].should be_nil }
50
+ end
51
+ end
52
+
53
+ context "can mine nested properties" do
54
+ class BucketNormalizer < Schemad::Normalizer
55
+ normalize :answer_to_the_universe, key: "useless_root"
56
+ normalize :username, key: "author/user/username"
57
+ normalize :avatar_url, key: "author/user/links/avatar/href"
58
+ normalize :email, key: "author/raw" do |value|
59
+ value.match(/\A[\w|\s]+<(.+)>\z/).captures.first
60
+ end
61
+ end
62
+
63
+ class BadNormalizer < Schemad::Normalizer
64
+ # missing the "links" part of the path
65
+ normalize :avatar_url, key: "author/user/avatar/href"
66
+ end
67
+
68
+ Given(:data) {
69
+ { useless_root: 42,
70
+ author: {
71
+ raw: "Joseph Walton <jwalton@atlassian.com>",
72
+ user: {
73
+ username: "jwalton",
74
+ display_name: "Joseph Walton",
75
+ links: {
76
+ self: {
77
+ href: "https://api.bitbucket.org/2.0/users/jwalton"
78
+ },
79
+ avatar: {
80
+ href: "funk_blue.png"
81
+ }
82
+ }
83
+ }
84
+ }
85
+ } }
86
+
87
+ context "parses paths" do
88
+ Given(:normalizer) { BucketNormalizer.new }
89
+ When(:results) { normalizer.normalize(data) }
90
+ Then { results[:username].should == "jwalton" }
91
+ And { results[:avatar_url].should == "funk_blue.png" }
92
+ And { results[:email].should == "jwalton@atlassian.com" }
93
+ And { results[:answer_to_the_universe].should == 42 }
94
+ end
95
+
96
+ context "bad path throws exception" do
97
+ Given(:normalizer) { BadNormalizer.new }
98
+ When(:results) { normalizer.normalize(data) }
99
+ Then { expect(results).to have_failed(Schemad::Normalizer::InvalidPath, /author\/user\/avatar\/href/) }
100
+ end
101
+
102
+ context "can allow fields from nested data" do
103
+ Given { BucketNormalizer.include_fields ["author/user/display_name"] }
104
+ Given(:normalizer) { BucketNormalizer.new }
105
+ When(:results) { normalizer.normalize(data) }
106
+ Then { results[:display_name].should == "Joseph Walton" }
107
+ end
108
+
109
+ end
110
+ end
@@ -0,0 +1,4 @@
1
+ # $LOAD_PATH.unshift "../lib"
2
+
3
+ require 'rspec/given'
4
+ require 'pry'
@@ -0,0 +1,125 @@
1
+ require 'spec_helper'
2
+
3
+ require 'schemad/type_handler'
4
+ require 'schemad/abstract_handler'
5
+
6
+ require 'schemad/types/boolean_handler'
7
+ require 'schemad/types/string_handler'
8
+ require 'schemad/types/time_handler'
9
+ require 'schemad/types/integer_handler'
10
+
11
+ describe Schemad::TypeHandler do
12
+
13
+ context "rejects unknown types" do
14
+ When(:typer) { Schemad::TypeHandler.new(:fake_type) }
15
+ Then { expect(typer).to have_failed(Schemad::TypeHandler::UnknownHandler, /No known handlers for FakeType/) }
16
+ end
17
+
18
+ context "defaults to string type" do
19
+ When(:typer) { Schemad::TypeHandler.new }
20
+ Then { expect(typer.instance_variable_get(:@handler)).to be_a(Schemad::StringHandler) }
21
+ end
22
+
23
+ context "can register custom handlers" do
24
+ class YouMomHandler < Schemad::AbstractHandler
25
+ handle :your_mom
26
+
27
+ def parse(value)
28
+ "Your Mom"
29
+ end
30
+ end
31
+
32
+ Given { Schemad::TypeHandler.register(YouMomHandler) }
33
+ Given(:typer) { Schemad::TypeHandler.new(:your_mom) }
34
+ When(:parsed) { typer.parse("Good vs. Evil") }
35
+ Then { parsed.should == "Your Mom" }
36
+ end
37
+
38
+ context "can handle bools" do
39
+ Given(:bool_handler) { Schemad::TypeHandler.new(:boolean) }
40
+
41
+ context "knows trues" do
42
+ Schemad::BooleanHandler::VALID_TRUTHS.each do |val|
43
+ When(:parsed) { bool_handler.parse(val) }
44
+ Then { parsed.should be_true }
45
+ end
46
+ end
47
+
48
+ context "rejects falses" do
49
+ [42, "Hello World", nil, String].each do |val|
50
+ When(:parsed) { bool_handler.parse(val) }
51
+ Then { parsed.should be_false }
52
+ end
53
+ end
54
+ end
55
+
56
+ context "can parse integers" do
57
+ Given(:int_handler) { Schemad::TypeHandler.new(:integer) }
58
+
59
+ context "true to 1" do
60
+ When(:result) { int_handler.parse(true) }
61
+ Then { result.should eq(1) }
62
+ end
63
+
64
+ context "false to 0" do
65
+ When(:result) { int_handler.parse(false) }
66
+ Then { result.should eq(0) }
67
+ end
68
+
69
+ context "strings to numbers" do
70
+ When(:result) { int_handler.parse("42") }
71
+ Then { result.should == 42 }
72
+ end
73
+
74
+ context "odd strings to 0" do
75
+ When(:result) { int_handler.parse("sneeze") }
76
+ Then { result.should == 0 }
77
+ end
78
+
79
+ context "nil for unknown items" do
80
+ When(:result) { int_handler.parse(String) }
81
+ Then { result.should == nil }
82
+ end
83
+ end
84
+
85
+ context "can parse dates" do
86
+ Given(:date_handler) { Schemad::TypeHandler.new(:date_time) }
87
+ Given(:time) { DateTime.now.to_time }
88
+
89
+ context "knows unix time" do
90
+ When(:result) { date_handler.parse(time.to_i) }
91
+ Then { result.to_i.should == time.to_i }
92
+ end
93
+
94
+ context "knows iso8601 time" do
95
+ When(:result) { date_handler.parse(time.iso8601) }
96
+ Then { result.to_i.should == time.to_i }
97
+ end
98
+
99
+ context "knows ruby date format time" do
100
+ When(:result) { date_handler.parse(time.to_s) }
101
+ Then { result.to_i.should == time.to_i }
102
+ end
103
+
104
+ context "knows ruby date object" do
105
+ Given(:date) { Date.today }
106
+ When(:result) { date_handler.parse(date) }
107
+ Then { result.should be_a(Time) }
108
+ And { result.to_i.should == date.to_time.to_i }
109
+ end
110
+
111
+ context "knows ruby time objects" do
112
+ Given(:time) { Time.now }
113
+ When(:result) { date_handler.parse(time) }
114
+ Then { result.should be_a(Time) }
115
+ Then { result.to_i.should == time.to_i }
116
+ end
117
+
118
+ context "knows ruby time objects" do
119
+ Given(:date_time) { DateTime.now }
120
+ When(:result) { date_handler.parse(date_time) }
121
+ Then { result.should be_a(Time) }
122
+ Then { result.to_i.should == date_time.to_time.to_i }
123
+ end
124
+ end
125
+ end
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: schemad
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Luke van der Hoeven
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-05-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.6'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: "\n This gem allows easy attribute definition, type casting and special
56
+ handling\n of data returned from a service of some kind. It's meant to be incredibly
57
+ general\n purpose. Maybe this is a bad idea...\n "
58
+ email:
59
+ - hungerandthirst@gmail.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - ".gitignore"
65
+ - Gemfile
66
+ - LICENSE.txt
67
+ - README.md
68
+ - Rakefile
69
+ - lib/schemad.rb
70
+ - lib/schemad/abstract_handler.rb
71
+ - lib/schemad/entity.rb
72
+ - lib/schemad/extensions.rb
73
+ - lib/schemad/normalizer.rb
74
+ - lib/schemad/type_handler.rb
75
+ - lib/schemad/types/boolean_handler.rb
76
+ - lib/schemad/types/integer_handler.rb
77
+ - lib/schemad/types/string_handler.rb
78
+ - lib/schemad/types/time_handler.rb
79
+ - lib/schemad/version.rb
80
+ - schemad.gemspec
81
+ - spec/entity_spec.rb
82
+ - spec/extensions_spec.rb
83
+ - spec/fixtures/demo_class.rb
84
+ - spec/normalizer_spec.rb
85
+ - spec/spec_helper.rb
86
+ - spec/type_handler_spec.rb
87
+ homepage: ''
88
+ licenses:
89
+ - MIT
90
+ metadata: {}
91
+ post_install_message:
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubyforge_project:
107
+ rubygems_version: 2.2.2
108
+ signing_key:
109
+ specification_version: 4
110
+ summary: Simple schema DSL for services
111
+ test_files:
112
+ - spec/entity_spec.rb
113
+ - spec/extensions_spec.rb
114
+ - spec/fixtures/demo_class.rb
115
+ - spec/normalizer_spec.rb
116
+ - spec/spec_helper.rb
117
+ - spec/type_handler_spec.rb