contrackt 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 5b24a1a4206a9550aced407dd6a29c7496485d1e
4
+ data.tar.gz: f346524d7e88b2cd1987dee455dde888e42e3ce3
5
+ SHA512:
6
+ metadata.gz: 7329932b9a65f010c19096a241c80077f2ce97b32b49df431791b10a490a30a22fab70a1c299df195ef54067f71c2b4c561a2477503c24f4521838c96bf624a2
7
+ data.tar.gz: a45b3e9c369ebeb5f3b66c4b801688c8507e9871cfa4e03863272be8dc9874099b8959c024666d40cf13a8821187fda6f927d91293b0e6d57b43152f65d75099
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'rspec'
@@ -0,0 +1,26 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+ diff-lcs (1.2.5)
5
+ rspec (3.5.0)
6
+ rspec-core (~> 3.5.0)
7
+ rspec-expectations (~> 3.5.0)
8
+ rspec-mocks (~> 3.5.0)
9
+ rspec-core (3.5.3)
10
+ rspec-support (~> 3.5.0)
11
+ rspec-expectations (3.5.0)
12
+ diff-lcs (>= 1.2.0, < 2.0)
13
+ rspec-support (~> 3.5.0)
14
+ rspec-mocks (3.5.0)
15
+ diff-lcs (>= 1.2.0, < 2.0)
16
+ rspec-support (~> 3.5.0)
17
+ rspec-support (3.5.0)
18
+
19
+ PLATFORMS
20
+ ruby
21
+
22
+ DEPENDENCIES
23
+ rspec
24
+
25
+ BUNDLED WITH
26
+ 1.12.4
@@ -0,0 +1,13 @@
1
+ Copyright 2016 Credit Karma
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1,124 @@
1
+ Contrackt is a tool to make handling request and response objects more object-oriented.
2
+
3
+ In general, Ruby handles these request and response objects mostly using hashes. One would expect to see something along the lines of
4
+
5
+ ```ruby
6
+ {
7
+ body: {
8
+ "foo" => {
9
+ "bar" => "baz"
10
+ }
11
+ }
12
+ }
13
+ ```
14
+
15
+ So your code might involve accessing variables like `response[:body]['foo']['bar']` (always keeping track of which key is a string and which is a symbol) instead of the more object-oriented `response.body.foo.bar`. Contracts are an attempt to represent these JSON hashes more clearly based on the objects they represent.
16
+
17
+ `Contrackt` allows the user to define self-documenting "contracts" that they expect to receive or send based on whatever API tool (raml, swagger, blueprint) they use to specify an API. An example usage is
18
+
19
+ ```ruby
20
+ class MyContract < Contrackt::Base
21
+ required 'foo' => Foo
22
+ end
23
+
24
+ class Foo < Contrackt::Base
25
+ required 'bar'
26
+ end
27
+ ```
28
+
29
+ You can then use those classes to work with the resulting JSON, e.g.
30
+
31
+ ```ruby
32
+ # json_response = { body: { "foo" => { "bar" => "baz" } } }
33
+ contract = MyContract.new(json_response[:body])
34
+ contract.foo.bar #=> 'baz'
35
+ contract.to_hash #=> { "foo" => { "bar" => "baz" } }
36
+ ```
37
+
38
+ You can also specify arrays of type:
39
+ ```
40
+ # json = { albums: [{ name: "21", artist: "Adele" }, { name: "IV", artist: "Led Zeppelin" }] }
41
+
42
+ class Album < Contrackt::Base
43
+ required :name
44
+ required :artist
45
+ end
46
+
47
+ class AlbumPayload < Contrackt::Base
48
+ required albums: Album[]
49
+ end
50
+ ```
51
+
52
+ The RAML spec provides for the concept of a "discriminator," i.e. a field that indicates the object's type. You can specify a discriminator as follows:
53
+
54
+ ```ruby
55
+ # json = { pets:
56
+ # [
57
+ # { type: "Cat", name: "Garfield", owner: "Jon", color: "striped" },
58
+ # { type: "Dog", name: "Marmaduke", owner: "Phil", breed: "great dane" },
59
+ # ...
60
+ # ]
61
+ # }
62
+
63
+ class Pet < Contrackt::Base
64
+ discriminator :type
65
+ required :name
66
+ required :owner
67
+ end
68
+
69
+ class PetsPayload < Contrackt::Base
70
+ required pets: Pet[]
71
+ end
72
+
73
+ class Cat < Pet
74
+ required :color
75
+ end
76
+
77
+ class Dog < Pet
78
+ required :breed
79
+ end
80
+
81
+ ```
82
+
83
+ You can also use discriminator along with a map if the discriminator doesn't provide the class name:
84
+
85
+ ```ruby
86
+ # json = { pets:
87
+ # [
88
+ # { type: "cat", name: "Garfield", owner: "Jon", color: "striped" },
89
+ # { type: "dog", name: "Marmaduke", owner: "Phil", breed: "great dane" },
90
+ # ...
91
+ # ]
92
+ # }
93
+
94
+ class Pet < Contrackt::Base
95
+ discriminator :type, 'cat' => Cat, 'dog' => Dog
96
+ required :name
97
+ required :owner
98
+ end
99
+
100
+ class PetsPayload < Contrackt::Base
101
+ required pets: Pet[]
102
+ end
103
+
104
+ class Cat < Pet
105
+ required :color
106
+ end
107
+
108
+ class Dog < Pet
109
+ required :breed
110
+ end
111
+
112
+ ```
113
+
114
+ If you have complicated code, you can write a custom parser, e.g.
115
+
116
+ ```ruby
117
+ # json = { foo: 'bar' }
118
+
119
+ class Thing < Contrackt::Base
120
+ required(:foo).with_custom_parser { |json| "#{json} (came from key foo)" }
121
+ end
122
+
123
+ Thing.new(json).foo #=> "bar (came from key foo)"
124
+ ```
@@ -0,0 +1,13 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'contrackt'
3
+ s.version = '0.0.0'
4
+ s.date = '2016-10-20'
5
+ s.summary = "Contrackt: simple Ruby contracts"
6
+ s.description = "Self-documenting contracts for Ruby"
7
+ s.authors = ["David Reiman"]
8
+ s.email = 'david.reiman@creditkarma.com'
9
+ s.files = `git ls-files`.split($\)
10
+ s.require_path = 'lib'
11
+ s.homepage = 'https://github.com/creditkarma/contrackt'
12
+ s.license = 'Apache 2.0'
13
+ end
@@ -0,0 +1,3 @@
1
+ require 'contrackt/version'
2
+ require 'contrackt/props'
3
+ require 'contrackt/base'
@@ -0,0 +1,72 @@
1
+ module Contrackt
2
+ class Base
3
+ def self.required(key)
4
+ define_prop(Props::Required.new(key))
5
+ end
6
+
7
+ def self.optional(key)
8
+ define_prop(Props::Optional.new(key))
9
+ end
10
+
11
+ def self.discriminator(discriminator, mapping = nil)
12
+ @discriminator = discriminator
13
+ @mapping = mapping
14
+ define_prop(Props::Required.new(discriminator))
15
+ end
16
+
17
+ def self.define_prop(prop)
18
+ symbol_key = prop.key.to_sym
19
+ attr_reader symbol_key
20
+ props[symbol_key] = prop
21
+ end
22
+
23
+ def self.props
24
+ @props ||= superclass == Contrackt::Base ? {} : superclass.clone_props
25
+ end
26
+
27
+ def self.clone_props
28
+ props.reduce({}) { |props, kvp| props.merge(kvp[0] => kvp[1].clone) }
29
+ end
30
+
31
+ def self.[]
32
+ Collection.new(self)
33
+ end
34
+
35
+ def self.new(json)
36
+ klass = determine_contract_class(json)
37
+ klass == self ? super(json) : klass.new(json)
38
+ end
39
+
40
+ def self.determine_contract_class(json)
41
+ if @discriminator
42
+ class_key = json[@discriminator]
43
+ (@mapping && @mapping[class_key]) || Object.const_get(class_key)
44
+ else
45
+ self
46
+ end
47
+ end
48
+
49
+ def initialize(json)
50
+ self.class.props.each do |symbol_key, prop|
51
+ instance_variable_set "@#{prop.key}", prop.parse(json)
52
+ end
53
+ end
54
+
55
+ def to_hash
56
+ self.class.props.reduce({}) do |hash, array|
57
+ symbol_key, prop = array
58
+ hash.merge(prop.hashify(send(symbol_key)))
59
+ end
60
+ end
61
+ end
62
+
63
+ class Collection
64
+ def initialize(klass)
65
+ @klass = klass
66
+ end
67
+
68
+ def new(values)
69
+ values.map {|value| @klass.new(value)}
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,68 @@
1
+ module Contrackt
2
+ module Props
3
+ class Prop
4
+ attr_reader :key
5
+ def initialize(key)
6
+ unpack(key)
7
+ end
8
+
9
+ def parse(json)
10
+ target = json[key]
11
+ if @custom_parser
12
+ @custom_parser[target]
13
+ elsif @klass
14
+ @klass.new(target)
15
+ else
16
+ target
17
+ end
18
+ end
19
+
20
+ def hashify(value)
21
+ { key => hashify_value(value) }
22
+ end
23
+
24
+ def with_custom_parser(&block)
25
+ @custom_parser = block
26
+ end
27
+
28
+ private
29
+ def unpack(key)
30
+ case key
31
+ when String
32
+ @key = key
33
+ when Symbol
34
+ @key = key
35
+ when Hash
36
+ raise ArgumentError, "Prop can only take one key-value pair" unless key.length == 1
37
+ @key = key.keys[0]
38
+ @klass = key.values[0]
39
+ else
40
+ raise ArgumentError, "Prop can only take a string or a hash"
41
+ end
42
+ end
43
+
44
+ def hashify_value(value)
45
+ if (value.is_a? Array)
46
+ value.map {|value| hashify_value(value)}
47
+ elsif value.respond_to?(:to_hash)
48
+ value.to_hash
49
+ else
50
+ value
51
+ end
52
+ end
53
+ end
54
+
55
+ class Required < Prop
56
+ end
57
+
58
+ class Optional < Prop
59
+ def parse(json)
60
+ json[key].nil? ? nil : super(json)
61
+ end
62
+
63
+ def hashify(value)
64
+ value.nil? ? {} : super(value)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,3 @@
1
+ module Contrackt
2
+ VERSION = '0.0.0'
3
+ end
@@ -0,0 +1,213 @@
1
+ require_relative '../lib/contrackt'
2
+
3
+ describe Contrackt::Base do
4
+ before(:all) do
5
+ class MyContract < Contrackt::Base
6
+ required 'foo'
7
+ end
8
+
9
+ class Foo < Contrackt::Base
10
+ required 'bar'
11
+ end
12
+
13
+ class ComposedContract < Contrackt::Base
14
+ required 'foo' => Foo
15
+ end
16
+
17
+ class ComposedWithArray < Contrackt::Base
18
+ required 'foos' => Foo[]
19
+ end
20
+
21
+ class ComposedWithOptional < Contrackt::Base
22
+ required 'foo' => Foo
23
+ optional 'additionalStuff' => Foo
24
+ end
25
+
26
+ class WithCustomParser < Contrackt::Base
27
+ required('foo').with_custom_parser { |foo| Foo.new(foo.merge('bar' => foo['bar'] + " (this was the bar)")) }
28
+ end
29
+
30
+ @simple_payload_hash = { "foo" => "bar" }
31
+ @complex_payload_hash = { "foo" => { "bar" => "baz" } }
32
+ @array_payload_hash = { "foos" => [{ "bar" => "baz" }, { "bar" => "qux" }] }
33
+ @complex_with_optional_hash = @complex_payload_hash.merge({ "additionalStuff" => { "bar" => "and more"}})
34
+ end
35
+
36
+ it "can be inherited" do
37
+ expect(true).to be true # if we get here, the inheritance(s) above didn't throw an error
38
+ end
39
+
40
+ context "when inherited" do
41
+ it "should be able to be constructed with a json payload" do
42
+ expect { MyContract.new(@simple_payload_hash) }.not_to raise_exception
43
+ end
44
+
45
+ it "should be able to reproduce the original json from which it was constructed" do
46
+ contract = MyContract.new(@simple_payload_hash)
47
+ expect(contract.to_hash).to eq @simple_payload_hash
48
+ end
49
+
50
+ it "should provide a getter for the key that is defined in inheritor::required" do
51
+ contract = MyContract.new(@simple_payload_hash)
52
+ expect(contract.foo).to eq @simple_payload_hash['foo']
53
+ end
54
+ end
55
+
56
+ context "with subcontract" do
57
+ it "should allow a hash like 'foo' => Foo in the required call" do
58
+ expect { ComposedContract.new(@complex_payload_hash) }.not_to raise_exception
59
+ end
60
+
61
+ it "should properly convert its subcontract values into contract objects" do
62
+ contract = ComposedContract.new(@complex_payload_hash)
63
+ expect(contract.foo).to be_a Foo
64
+ expect(contract.foo.bar).to eq 'baz'
65
+ end
66
+
67
+ it "should be able to reconstruct the hash from which it was constructed" do
68
+ expect(ComposedContract.new(@complex_payload_hash).to_hash).to eq @complex_payload_hash
69
+ end
70
+ end
71
+
72
+ context "with array subcontract" do
73
+ it "should allow a hash like 'foo' => Foo[] in the required call" do
74
+ expect { ComposedWithArray.new(@array_payload_hash) }.not_to raise_exception
75
+ end
76
+
77
+ it "should properly map its subcontract values into contract objects" do
78
+ contract = ComposedWithArray.new(@array_payload_hash)
79
+ expect(contract.foos.all? { |foo| foo.is_a? Foo }).to be true
80
+ expect(contract.foos.map(&:bar)).to eq ['baz', 'qux']
81
+ end
82
+
83
+ it "should be able to reconstruct the hash from which it was constructed" do
84
+ expect(ComposedWithArray.new(@array_payload_hash).to_hash).to eq @array_payload_hash
85
+ end
86
+ end
87
+
88
+ context "with optional props" do
89
+ context "missing in the payload" do
90
+ it "should not throw an exception if the properties are missing" do
91
+ expect { ComposedWithOptional.new(@complex_payload_hash) }.not_to raise_exception
92
+ end
93
+
94
+ it "should still allow those optional properties to be accessed, just with nil value" do
95
+ expect(ComposedWithOptional.new(@complex_payload_hash).additionalStuff).to be nil
96
+ end
97
+
98
+ it "should ignore those optional properties when reconstructing the hash" do
99
+ expect(ComposedWithOptional.new(@complex_payload_hash).to_hash).to eq @complex_payload_hash
100
+ end
101
+ end
102
+
103
+ context "present in the payload" do
104
+ it "should parse optional properties properly when they are present" do
105
+ expect(ComposedWithOptional.new(@complex_with_optional_hash).additionalStuff).to be_a Foo
106
+ expect(ComposedWithOptional.new(@complex_with_optional_hash).additionalStuff.bar).to eq "and more"
107
+ end
108
+
109
+ it "should ignore those optional properties when reconstructing the hash" do
110
+ expect(ComposedWithOptional.new(@complex_payload_hash).to_hash).to eq @complex_payload_hash
111
+ end
112
+ end
113
+ end
114
+
115
+ context "with custom parser" do
116
+ it "uses the custom parser to build new objects" do
117
+ expect(WithCustomParser.new(@complex_payload_hash).foo.bar).to eq "baz (this was the bar)"
118
+ end
119
+ end
120
+
121
+ context "discriminator" do
122
+ before(:all) do
123
+ class Card < Contrackt::Base
124
+ required :owner
125
+ discriminator :type
126
+ end
127
+
128
+ class BusinessCard < Card
129
+ required :phone
130
+ end
131
+
132
+ class CreditCard < Card
133
+ required :account
134
+ end
135
+ end
136
+
137
+ context "not a collection" do
138
+ before(:all) do
139
+ @discriminator_hash = { card:
140
+ { type: "BusinessCard", owner: "Jane Doe", phone: "(555) 555-5555" }
141
+ }
142
+
143
+ class CardPayload < Contrackt::Base
144
+ required card: Card
145
+ end
146
+ end
147
+
148
+ it "properly applies the discriminator" do
149
+ contract = CardPayload.new(@discriminator_hash)
150
+ expect(contract.card.class).to be BusinessCard
151
+ expect(contract.card.owner).to eq "Jane Doe"
152
+ end
153
+ end
154
+
155
+ context "collection" do
156
+ before(:all) do
157
+ @discriminator_hash = {
158
+ cards: [
159
+ { type: "BusinessCard", owner: "Jane Doe", phone: "(555) 555-5555" },
160
+ { type: "CreditCard", owner: "Jane Doe", account: "1234-5678-9000-0000" }
161
+ ]
162
+ }
163
+
164
+ class CardsPayload < Contrackt::Base
165
+ required cards: Card[]
166
+ end
167
+ end
168
+
169
+ it "properly applies the discriminator" do
170
+ contract = CardsPayload.new(@discriminator_hash)
171
+ expect(contract.cards.first.class).to be BusinessCard
172
+ expect(contract.cards.first.owner).to eq "Jane Doe"
173
+ expect(contract.cards.first.phone).to eq "(555) 555-5555"
174
+ expect(contract.cards.last.class).to be CreditCard
175
+ expect(contract.cards.last.owner).to eq "Jane Doe"
176
+ expect(contract.cards.last.account).to eq "1234-5678-9000-0000"
177
+ end
178
+
179
+ it "reproduces the original hash via to_hash" do
180
+ expect(CardsPayload.new(@discriminator_hash).to_hash).to eq @discriminator_hash
181
+ end
182
+ end
183
+
184
+ context "custom discriminator handling" do
185
+ before(:all) do
186
+ class CustomCard < Contrackt::Base
187
+ required :owner
188
+ discriminator :type, 'business' => BusinessCard, 'credit' => CreditCard
189
+ end
190
+
191
+ class CustomCardsPayload < Contrackt::Base
192
+ required cards: CustomCard[]
193
+ end
194
+
195
+ @discriminator_hash = {
196
+ cards: [
197
+ { type: "business", owner: "Jane Doe", phone: "(555) 555-5555" },
198
+ { type: "credit", owner: "Jane Doe", account: "1234-5678-9000-0000" }
199
+ ]
200
+ }
201
+ end
202
+ it "properly applies the discriminator" do
203
+ contract = CustomCardsPayload.new(@discriminator_hash)
204
+ expect(contract.cards.first.class).to be BusinessCard
205
+ expect(contract.cards.first.owner).to eq "Jane Doe"
206
+ expect(contract.cards.first.phone).to eq "(555) 555-5555"
207
+ expect(contract.cards.last.class).to be CreditCard
208
+ expect(contract.cards.last.owner).to eq "Jane Doe"
209
+ expect(contract.cards.last.account).to eq "1234-5678-9000-0000"
210
+ end
211
+ end
212
+ end
213
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: contrackt
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - David Reiman
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-10-20 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Self-documenting contracts for Ruby
14
+ email: david.reiman@creditkarma.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - Gemfile
20
+ - Gemfile.lock
21
+ - LICENSE.txt
22
+ - README.md
23
+ - contrackt.gemspec
24
+ - lib/contrackt.rb
25
+ - lib/contrackt/base.rb
26
+ - lib/contrackt/props.rb
27
+ - lib/contrackt/version.rb
28
+ - spec/contrackt_spec.rb
29
+ homepage: https://github.com/creditkarma/contrackt
30
+ licenses:
31
+ - Apache 2.0
32
+ metadata: {}
33
+ post_install_message:
34
+ rdoc_options: []
35
+ require_paths:
36
+ - lib
37
+ required_ruby_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ requirements: []
48
+ rubyforge_project:
49
+ rubygems_version: 2.4.6
50
+ signing_key:
51
+ specification_version: 4
52
+ summary: 'Contrackt: simple Ruby contracts'
53
+ test_files: []