frenetic 0.0.1 → 0.0.2.alpha1

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -5,6 +5,122 @@
5
5
 
6
6
  An opinionated Ruby-based Hypermedia API (HAL+JSON) client.
7
7
 
8
+
9
+
10
+
11
+ ## About
12
+
13
+ fre&bull;net&bull;ic |frəˈnetik|<br/>
14
+ adjective<br/>
15
+ fast and energetic in a rather wild and uncontrolled way : *a frenetic pace of activity.*
16
+
17
+ So basically, this is a crazy way to interact with your Hypermedia HAL+JSON API.
18
+
19
+ Get it? *Hypermedia*?
20
+
21
+ *Hyper*?
22
+
23
+ ...
24
+
25
+ If you have not implemented a HAL+JSON API, then this will not work very well for you.
26
+
27
+
28
+
29
+
30
+ ## Opinions
31
+
32
+ Like I said, it is opinionated. It is so opinionated, it is probably the biggest
33
+ a-hole you've ever met.
34
+
35
+ Maybe in time, if you teach it, it will become more open-minded.
36
+
37
+
38
+
39
+ ### HAL+JSON Content Type
40
+
41
+ Frenetic expects all responses to be in [HAL+JSON][hal_json]. It chose that
42
+ standard because it is trying to make JSON API's respond in a predictable
43
+ manner, which it thinks is an awesome idea.
44
+
45
+
46
+
47
+ ### Authentication
48
+
49
+ Frenetic is going to try and use Basic Auth whether you like it or not. If
50
+ that is not required, nothing will probably happen. But its going to send the
51
+ header anyway.
52
+
53
+
54
+
55
+ ### API Description
56
+
57
+ The API's root URL must respond with a description, much like the
58
+ [Spire.io][spire.io] API. This is crucial in order for Frenetic to work. If
59
+ Frenetic doesn't know what the API contains, it can't parse any resource
60
+ responses.
61
+
62
+ It is expected that any subclasses of `Frenetic::Resource` will adhere to the
63
+ schema defined here.
64
+
65
+ Example:
66
+
67
+ ```js
68
+ {
69
+ "_links":{
70
+ "self":{"href":"/api/"},
71
+ "inkers":{"href":"/api/inkers"},
72
+ },
73
+ "_embedded":{
74
+ "schema":{
75
+ "_links":{
76
+ "self":{"href":"/api/schema"}
77
+ },
78
+ "order":{
79
+ "description":"A widget order",
80
+ "type":"object",
81
+ "properties":{
82
+ "id":{"type":"integer"},
83
+ "first_name":{"type":"string"},
84
+ "last_name":{"type":"string"},
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ This response will be requested by Frenetic whenever a call to
93
+ `YourAPI.description` is made. The response is memoized so any future calls
94
+ will not trigger another API request.
95
+
96
+
97
+
98
+ ### API Resources
99
+
100
+ While HAL+JSON is awesome, not all implementations are perfect. Frenetic
101
+ assumes a HAL+JSON response as built by [Roar], which may not be in 100%
102
+ compliance.
103
+
104
+ Example:
105
+
106
+ ```js
107
+ {
108
+ "id":1,
109
+ "first_name":"Foo",
110
+ "last_name":"Bar",
111
+ "_links":{
112
+ "self":{"href":"/order/1"},
113
+ "next":{"href":"/order/2"}
114
+ }
115
+ }
116
+ ```
117
+
118
+ The problem here is that the entire response really should be wrapped in
119
+ `"_embedded"` and `"order"` keys.
120
+
121
+ So until that is fixed, Frenetic will continue to be pig headed and continue
122
+ to do the "wrong" thing.
123
+
8
124
  ## Installation
9
125
 
10
126
  Add this line to your application's Gemfile:
@@ -19,9 +135,128 @@ Or install it yourself as:
19
135
 
20
136
  $ gem install frenetic
21
137
 
138
+
139
+
140
+
22
141
  ## Usage
23
142
 
24
- Your guess is as good as mine. (*TODO*)
143
+
144
+
145
+ ### Client Initialization
146
+
147
+ ```ruby
148
+ MyAPI = Frenetic.new(
149
+ 'url' => 'https://api.yoursite.com',
150
+ 'username' => 'yourname',
151
+ 'password' => 'yourpassword',
152
+ 'headers' => {
153
+ 'accept' => 'application/vnd.yoursite-v1.hal+json'
154
+ # Optional
155
+ 'user-agent' => 'Your Site's API Client', # Optional custom User Agent, just 'cuz
156
+ }
157
+ )
158
+ ```
159
+
160
+
161
+ ### Response Caching
162
+
163
+ If configured to do so, Frenetic will autotmatically cache appropriate responses
164
+ through [Rack::Cache][rach_cache]. Only on-disk stores are supported right now.
165
+
166
+ Add the following `Rack::Cache` configuration options when initializing Frenetic:
167
+
168
+ ```ruby
169
+ MyAPI = Frenetic.new(
170
+ ...
171
+ 'cache' => {
172
+ 'metastore' => 'file:/path/to/where/you/want/to/store/files/meta',
173
+ 'entitystore' => 'file:/path/to/where/you/want/to/store/files/meta'
174
+ }
175
+ )
176
+ ```
177
+
178
+ The `cache` options are passed directly to `Rack::Cache`, so anything it
179
+ supports can be added to the Hash.
180
+
181
+
182
+
183
+ ### Making Requests
184
+
185
+ Once you have created a client instance, you are free to use it however you'd
186
+ like.
187
+
188
+ A Frenetic instance supports any HTTP verb that [Faraday][faraday] has
189
+ impletented. This includes GET, POST, PUT, PATCH, and DELETE.
190
+
191
+
192
+
193
+ #### Frenetic::Resource
194
+
195
+ An easier way to make requests for a resource is to have your model inherit from
196
+ `Frenetic::Resource`. This makes it a bit easier to encapsulate all of your
197
+ resource's API requests into one place.
198
+
199
+ ```ruby
200
+ class Order < Frenetic::Resource
201
+
202
+ api_client { MyAPI }
203
+
204
+ class << self
205
+ def find( id )
206
+ if response = api.get( api.description.links.order.href.gsub('{id}', id.to_s) ) and response.success?
207
+ self.new( response.body )
208
+ else
209
+ raise OrderNotFound, "No Order found for #{id}"
210
+ end
211
+ end
212
+ end
213
+ end
214
+ ```
215
+
216
+ The `api_client` class method merely tells `Frenetic::Resource` which API Client
217
+ instance to use. If you lazily instantiate your client, then you should pass a
218
+ block as demonstrated above.
219
+
220
+ Otherwise, you may pass by reference:
221
+
222
+ ```ruby
223
+ class Order < Frenetic::Resource
224
+ api_client MyAPI
225
+ end
226
+ ```
227
+
228
+ When your model is initialized, it will contain attribute readers for every
229
+ property defined in your API's schema or description. In theory, this allows an
230
+ API to add, remove, or change properties without the need to directly update
231
+ your model.
232
+
233
+
234
+
235
+ ### Interpretting Responses
236
+
237
+ Any response body returned by a Frenetic generated API call will be returned as
238
+ an OpenStruct-like object. This object responds to dot-notation as well as Hash
239
+ keys and is enumerable.
240
+
241
+ ```ruby
242
+ response.body.resources.orders.first
243
+ ```
244
+
245
+ or
246
+
247
+ ```ruby
248
+ response.body['_embedded']['orders'][0]
249
+ ```
250
+
251
+ For your convenience, certain HAL+JSON keys have been aliased by methods a bit
252
+ more readable:
253
+
254
+ * `_embedded` can be referenced as `resources`
255
+ * `_links` can be referenced as `links`
256
+ * `href` can be referenced as `url`
257
+
258
+
259
+
25
260
 
26
261
  ## Contributing
27
262
 
@@ -30,3 +265,9 @@ Your guess is as good as mine. (*TODO*)
30
265
  3. Commit your changes (`git commit -am 'Added some feature'`)
31
266
  4. Push to the branch (`git push origin my-new-feature`)
32
267
  5. Create new Pull Request
268
+
269
+ [hal_json]: http://stateless.co/hal_specification.html
270
+ [spire.io]: http://api.spire.io/
271
+ [roar]: https://github.com/apotonick/roar
272
+ [faraday]: https://github.com/technoweenie/faraday
273
+ [rack_cache]: https://github.com/rtomayko/rack-cache
@@ -15,14 +15,16 @@ Gem::Specification.new do |gem|
15
15
  gem.require_paths = ["lib"]
16
16
  gem.version = Frenetic::VERSION
17
17
 
18
- gem.add_dependency 'faraday', '~> 0.8.0.rc2'
19
- gem.add_dependency 'addressable', '~> 2.2.7'
20
- gem.add_dependency 'patron', '~> 0.4.18'
18
+ gem.add_dependency 'faraday', '~> 0.8.0.rc2'
19
+ gem.add_dependency 'faraday_middleware', '~> 0.8.6'
20
+ gem.add_dependency 'rack-cache', '~> 1.1'
21
+ gem.add_dependency 'addressable', '~> 2.2.7'
22
+ gem.add_dependency 'patron', '~> 0.4.18'
21
23
 
22
- gem.add_development_dependency 'guard-spork', '~> 0.6.0'
23
- gem.add_development_dependency 'guard-rspec', '~> 0.7.0'
24
- gem.add_development_dependency 'rspec', '~> 2.9.0'
25
- gem.add_development_dependency 'bourne', '~> 1.1.2'
26
- gem.add_development_dependency 'webmock', '~> 1.8.6'
27
- gem.add_development_dependency 'vcr', '~> 2.0.1'
24
+ gem.add_development_dependency 'guard-spork', '~> 0.6.0'
25
+ gem.add_development_dependency 'guard-rspec', '~> 0.7.0'
26
+ gem.add_development_dependency 'rspec', '~> 2.9.0'
27
+ gem.add_development_dependency 'bourne', '~> 1.1.2'
28
+ gem.add_development_dependency 'webmock', '~> 1.8.6'
29
+ gem.add_development_dependency 'vcr', '~> 2.0.1'
28
30
  end
@@ -1,5 +1,8 @@
1
1
  require 'addressable/uri'
2
+ require 'patron' # Needed to prevent https://github.com/technoweenie/faraday/issues/140
2
3
  require 'faraday'
4
+ require 'faraday_middleware'
5
+ require 'rack-cache'
3
6
 
4
7
  require "frenetic/configuration"
5
8
  require "frenetic/hal_json"
@@ -25,6 +28,11 @@ class Frenetic
25
28
  @connection = Faraday.new( config ) do |builder|
26
29
  builder.use HalJson
27
30
  builder.request :basic_auth, config[:username], config[:password]
31
+
32
+ if config[:cache]
33
+ builder.use FaradayMiddleware::RackCompatible, Rack::Cache::Context, config[:cache]
34
+ end
35
+
28
36
  builder.adapter :patron
29
37
  end
30
38
  end
@@ -15,11 +15,7 @@ class Frenetic
15
15
  config[:headers] ||= {}
16
16
  config[:request] ||= {}
17
17
 
18
- if config[:"content-type"]
19
- config[:headers][:accepts] = config[:"content-type"]
20
- else
21
- config[:headers][:accepts] = "application/hal+json"
22
- end
18
+ config[:headers][:accept] ||= "application/hal+json"
23
19
 
24
20
  # Copy the config into this Configuration instance.
25
21
  config.each { |k, v| self[k] = v }
@@ -27,6 +23,7 @@ class Frenetic
27
23
  super()
28
24
 
29
25
  configure_user_agent
26
+ configure_cache
30
27
 
31
28
  validate
32
29
  end
@@ -43,12 +40,35 @@ class Frenetic
43
40
  end
44
41
  end
45
42
 
43
+ def configure_cache
44
+ if self[:cache]
45
+ ignore_headers = (self[:cache][:ignore_headers] || '').split(' ')
46
+
47
+ self[:cache][:ignore_headers] = (ignore_headers + %w[Set-Cookie X-Content-Digest]).uniq
48
+ end
49
+ end
50
+
46
51
  def validate
47
52
  unless self[:url]
48
53
  raise ConfigurationError, "No API URL defined!"
49
54
  end
55
+ if self[:cache]
56
+ raise( ConfigurationError, "No cache :metastore defined!" ) if self[:cache][:metastore].to_s == ""
57
+ raise( ConfigurationError, "No cache :entitystore defined!" ) if self[:cache][:entitystore].to_s == ""
58
+ raise( ConfigurationError, "Required cache header filters are missing!" ) if missing_required_headers?
59
+ end
60
+ end
61
+
62
+ def missing_required_headers?
63
+ return true if self[:cache][:ignore_headers].empty?
64
+
65
+ header_set = self[:cache][:ignore_headers]
66
+ custom_headers = header_set - %w[Set-Cookie X-Content-Digest]
67
+
68
+ header_set == custom_headers
50
69
  end
51
70
 
71
+ # TODO: Is this even being used?
52
72
  def config_file
53
73
  config_path = File.join( 'config', 'frenetic.yml' )
54
74
 
@@ -1,3 +1,3 @@
1
1
  class Frenetic
2
- VERSION = "0.0.1"
2
+ VERSION = "0.0.2.alpha1"
3
3
  end
@@ -4,7 +4,9 @@ describe Frenetic::Configuration do
4
4
  { 'test' => {
5
5
  'url' => 'http://example.org',
6
6
  'api_key' => '1234567890',
7
- 'content-type' => content_type,
7
+ 'headers' => {
8
+ 'accept' => content_type,
9
+ },
8
10
  'request' => {
9
11
  'timeout' => 10000
10
12
  }
@@ -37,16 +39,16 @@ describe Frenetic::Configuration do
37
39
  subject[:headers][:user_agent].should =~ %r{Frenetic v.+; \S+$}
38
40
  end
39
41
 
40
- context "with a specified Content-Type" do
41
- it "should set an Accepts request header" do
42
- subject[:headers].should include(:accepts => 'application/vnd.frenetic-v1-hal+json')
42
+ context "with a specified Accept header" do
43
+ it "should set an Accept request header" do
44
+ subject[:headers].should include(:accept => 'application/vnd.frenetic-v1-hal+json')
43
45
  end
44
46
  end
45
- context "without a specified Content-Type" do
47
+ context "without a specified Accept header" do
46
48
  let(:content_type) { nil }
47
49
 
48
- it "should set an Accepts request header" do
49
- subject[:headers].should include(:accepts => 'application/hal+json')
50
+ it "should set an Accept request header" do
51
+ subject[:headers].should include(:accept => 'application/hal+json')
50
52
  end
51
53
  end
52
54
  end
@@ -63,11 +65,45 @@ describe Frenetic::Configuration do
63
65
  it { should be_a( Hash ) }
64
66
  it { should_not be_empty }
65
67
  it "should set an Accepts request header" do
66
- subject[:headers].should include(:accepts => 'application/hal+json')
68
+ subject[:headers].should include(:accept => 'application/hal+json')
67
69
  end
68
70
  it "should set a User Agent request header" do
69
71
  subject[:headers][:user_agent].should =~ %r{Frenetic v.+; \S+$}
70
72
  end
73
+
74
+ context "which includes incorrect cache settings" do
75
+ before { Frenetic::Configuration.any_instance.stubs(:configure_cache).returns(nil) }
76
+
77
+ it "should raise a configuration error for a missing :metastore" do
78
+ expect {
79
+ Frenetic::Configuration.new('url' => 'http://example.org', 'cache' => {} )
80
+ }.to raise_error(
81
+ Frenetic::Configuration::ConfigurationError, "No cache :metastore defined!"
82
+ )
83
+ end
84
+
85
+ it "should raise a configuration error for a missing :entitystore" do
86
+ expect {
87
+ Frenetic::Configuration.new('url' => 'http://example.org', 'cache' => { 'metastore' => 'foo' } )
88
+ }.to raise_error(
89
+ Frenetic::Configuration::ConfigurationError, "No cache :entitystore defined!"
90
+ )
91
+ end
92
+
93
+ it "should raise a configuration error for missing required header filters" do
94
+ cache_cfg = {
95
+ 'metastore' => 'foo',
96
+ 'entitystore' => 'bar',
97
+ 'ignore_headers' => ['baz'] # `configure_cache` method is skipped to create a bad state
98
+ }
99
+
100
+ expect {
101
+ Frenetic::Configuration.new('url' => 'http://example.org', 'cache' => cache_cfg )
102
+ }.to raise_error(
103
+ Frenetic::Configuration::ConfigurationError, "Required cache header filters are missing!"
104
+ )
105
+ end
106
+ end
71
107
  end
72
108
  end
73
109
  end
metadata CHANGED
@@ -1,19 +1,19 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: frenetic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
5
- prerelease:
4
+ version: 0.0.2.alpha1
5
+ prerelease: 6
6
6
  platform: ruby
7
7
  authors:
8
8
  - Derek Lindahl
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-04-11 00:00:00.000000000Z
12
+ date: 2012-04-18 00:00:00.000000000Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: faraday
16
- requirement: &2157361220 !ruby/object:Gem::Requirement
16
+ requirement: &2156329760 !ruby/object:Gem::Requirement
17
17
  none: false
18
18
  requirements:
19
19
  - - ~>
@@ -21,10 +21,32 @@ dependencies:
21
21
  version: 0.8.0.rc2
22
22
  type: :runtime
23
23
  prerelease: false
24
- version_requirements: *2157361220
24
+ version_requirements: *2156329760
25
+ - !ruby/object:Gem::Dependency
26
+ name: faraday_middleware
27
+ requirement: &2156328980 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ~>
31
+ - !ruby/object:Gem::Version
32
+ version: 0.8.6
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *2156328980
36
+ - !ruby/object:Gem::Dependency
37
+ name: rack-cache
38
+ requirement: &2156328220 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ~>
42
+ - !ruby/object:Gem::Version
43
+ version: '1.1'
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: *2156328220
25
47
  - !ruby/object:Gem::Dependency
26
48
  name: addressable
27
- requirement: &2157360720 !ruby/object:Gem::Requirement
49
+ requirement: &2156327400 !ruby/object:Gem::Requirement
28
50
  none: false
29
51
  requirements:
30
52
  - - ~>
@@ -32,10 +54,10 @@ dependencies:
32
54
  version: 2.2.7
33
55
  type: :runtime
34
56
  prerelease: false
35
- version_requirements: *2157360720
57
+ version_requirements: *2156327400
36
58
  - !ruby/object:Gem::Dependency
37
59
  name: patron
38
- requirement: &2157360240 !ruby/object:Gem::Requirement
60
+ requirement: &2156326620 !ruby/object:Gem::Requirement
39
61
  none: false
40
62
  requirements:
41
63
  - - ~>
@@ -43,10 +65,10 @@ dependencies:
43
65
  version: 0.4.18
44
66
  type: :runtime
45
67
  prerelease: false
46
- version_requirements: *2157360240
68
+ version_requirements: *2156326620
47
69
  - !ruby/object:Gem::Dependency
48
70
  name: guard-spork
49
- requirement: &2157359780 !ruby/object:Gem::Requirement
71
+ requirement: &2156325900 !ruby/object:Gem::Requirement
50
72
  none: false
51
73
  requirements:
52
74
  - - ~>
@@ -54,10 +76,10 @@ dependencies:
54
76
  version: 0.6.0
55
77
  type: :development
56
78
  prerelease: false
57
- version_requirements: *2157359780
79
+ version_requirements: *2156325900
58
80
  - !ruby/object:Gem::Dependency
59
81
  name: guard-rspec
60
- requirement: &2157359320 !ruby/object:Gem::Requirement
82
+ requirement: &2156325160 !ruby/object:Gem::Requirement
61
83
  none: false
62
84
  requirements:
63
85
  - - ~>
@@ -65,10 +87,10 @@ dependencies:
65
87
  version: 0.7.0
66
88
  type: :development
67
89
  prerelease: false
68
- version_requirements: *2157359320
90
+ version_requirements: *2156325160
69
91
  - !ruby/object:Gem::Dependency
70
92
  name: rspec
71
- requirement: &2157358860 !ruby/object:Gem::Requirement
93
+ requirement: &2156324300 !ruby/object:Gem::Requirement
72
94
  none: false
73
95
  requirements:
74
96
  - - ~>
@@ -76,10 +98,10 @@ dependencies:
76
98
  version: 2.9.0
77
99
  type: :development
78
100
  prerelease: false
79
- version_requirements: *2157358860
101
+ version_requirements: *2156324300
80
102
  - !ruby/object:Gem::Dependency
81
103
  name: bourne
82
- requirement: &2157358400 !ruby/object:Gem::Requirement
104
+ requirement: &2156322980 !ruby/object:Gem::Requirement
83
105
  none: false
84
106
  requirements:
85
107
  - - ~>
@@ -87,10 +109,10 @@ dependencies:
87
109
  version: 1.1.2
88
110
  type: :development
89
111
  prerelease: false
90
- version_requirements: *2157358400
112
+ version_requirements: *2156322980
91
113
  - !ruby/object:Gem::Dependency
92
114
  name: webmock
93
- requirement: &2157357940 !ruby/object:Gem::Requirement
115
+ requirement: &2156322340 !ruby/object:Gem::Requirement
94
116
  none: false
95
117
  requirements:
96
118
  - - ~>
@@ -98,10 +120,10 @@ dependencies:
98
120
  version: 1.8.6
99
121
  type: :development
100
122
  prerelease: false
101
- version_requirements: *2157357940
123
+ version_requirements: *2156322340
102
124
  - !ruby/object:Gem::Dependency
103
125
  name: vcr
104
- requirement: &2157357480 !ruby/object:Gem::Requirement
126
+ requirement: &2156321440 !ruby/object:Gem::Requirement
105
127
  none: false
106
128
  requirements:
107
129
  - - ~>
@@ -109,7 +131,7 @@ dependencies:
109
131
  version: 2.0.1
110
132
  type: :development
111
133
  prerelease: false
112
- version_requirements: *2157357480
134
+ version_requirements: *2156321440
113
135
  description: An opinionated Ruby-based Hypermedia API client.
114
136
  email:
115
137
  - dlindahl@customink.com
@@ -155,9 +177,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
155
177
  required_rubygems_version: !ruby/object:Gem::Requirement
156
178
  none: false
157
179
  requirements:
158
- - - ! '>='
180
+ - - ! '>'
159
181
  - !ruby/object:Gem::Version
160
- version: '0'
182
+ version: 1.3.1
161
183
  requirements: []
162
184
  rubyforge_project:
163
185
  rubygems_version: 1.8.10