contrackt 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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: []