frenetic 0.0.12 → 0.0.20.alpha.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.
Files changed (43) hide show
  1. data/.gitignore +1 -0
  2. data/.irbrc +3 -0
  3. data/.ruby-version +1 -0
  4. data/.travis.yml +0 -1
  5. data/Gemfile +1 -3
  6. data/README.md +138 -125
  7. data/frenetic.gemspec +5 -6
  8. data/lib/frenetic.rb +31 -43
  9. data/lib/frenetic/concerns/collection_rest_methods.rb +13 -0
  10. data/lib/frenetic/concerns/configurable.rb +59 -0
  11. data/lib/frenetic/concerns/hal_linked.rb +59 -0
  12. data/lib/frenetic/concerns/member_rest_methods.rb +15 -0
  13. data/lib/frenetic/concerns/structured.rb +53 -0
  14. data/lib/frenetic/configuration.rb +40 -76
  15. data/lib/frenetic/middleware/hal_json.rb +23 -0
  16. data/lib/frenetic/resource.rb +77 -62
  17. data/lib/frenetic/resource_collection.rb +46 -0
  18. data/lib/frenetic/version.rb +2 -2
  19. data/spec/concerns/configurable_spec.rb +50 -0
  20. data/spec/concerns/hal_linked_spec.rb +116 -0
  21. data/spec/concerns/member_rest_methods_spec.rb +41 -0
  22. data/spec/concerns/structured_spec.rb +214 -0
  23. data/spec/configuration_spec.rb +99 -0
  24. data/spec/fixtures/test_api_requests.rb +88 -0
  25. data/spec/frenetic_spec.rb +137 -0
  26. data/spec/middleware/hal_json_spec.rb +83 -0
  27. data/spec/resource_collection_spec.rb +80 -0
  28. data/spec/resource_spec.rb +211 -0
  29. data/spec/spec_helper.rb +4 -13
  30. data/spec/support/rspec.rb +5 -0
  31. data/spec/support/webmock.rb +3 -0
  32. metadata +59 -57
  33. data/.rvmrc +0 -1
  34. data/lib/frenetic/hal_json.rb +0 -23
  35. data/lib/frenetic/hal_json/response_wrapper.rb +0 -43
  36. data/lib/recursive_open_struct.rb +0 -79
  37. data/spec/fixtures/vcr_cassettes/description_error_unauthorized.yml +0 -36
  38. data/spec/fixtures/vcr_cassettes/description_success.yml +0 -38
  39. data/spec/lib/frenetic/configuration_spec.rb +0 -189
  40. data/spec/lib/frenetic/hal_json/response_wrapper_spec.rb +0 -70
  41. data/spec/lib/frenetic/hal_json_spec.rb +0 -68
  42. data/spec/lib/frenetic/resource_spec.rb +0 -182
  43. data/spec/lib/frenetic_spec.rb +0 -129
data/.gitignore CHANGED
@@ -16,3 +16,4 @@ spec/reports
16
16
  test/tmp
17
17
  test/version_tmp
18
18
  tmp
19
+ vendor
data/.irbrc ADDED
@@ -0,0 +1,3 @@
1
+ require 'bundler'
2
+ Bundler.require
3
+ require 'awesome_print'
@@ -0,0 +1 @@
1
+ 1.9.3-p374
@@ -1,4 +1,3 @@
1
1
  rvm:
2
- - 1.9.2
3
2
  - 1.9.3
4
3
  script: bundle exec rspec --require spec_helper --order rand --color --format documentation
data/Gemfile CHANGED
@@ -1,7 +1,5 @@
1
1
  source 'https://rubygems.org'
2
+ ruby '1.9.3'
2
3
 
3
4
  # Specify your gem's dependencies in frenetic.gemspec
4
5
  gemspec
5
-
6
- gem 'awesome_print'
7
- gem 'fakefs', '~> 0.4.0', require: false
data/README.md CHANGED
@@ -9,7 +9,6 @@ An opinionated Ruby-based Hypermedia API (HAL+JSON) client.
9
9
 
10
10
 
11
11
 
12
-
13
12
  ## About
14
13
 
15
14
  fre&bull;net&bull;ic |frəˈnetik|<br/>
@@ -29,6 +28,7 @@ If you have not implemented a HAL+JSON API, then this will not work very well fo
29
28
 
30
29
 
31
30
 
31
+
32
32
  ## Opinions
33
33
 
34
34
  Like I said, it is opinionated. It is so opinionated, it is probably the biggest
@@ -37,7 +37,6 @@ a-hole you've ever met.
37
37
  Maybe in time, if you teach it, it will become more open-minded.
38
38
 
39
39
 
40
-
41
40
  ### HAL+JSON Content Type
42
41
 
43
42
  Frenetic expects all responses to be in [HAL+JSON][hal_json]. It chose that
@@ -45,45 +44,43 @@ standard because it is trying to make JSON API's respond in a predictable
45
44
  manner, which it thinks is an awesome idea.
46
45
 
47
46
 
48
-
49
- ### Authentication
50
-
51
- Frenetic is going to try and use Basic Auth whether you like it or not. If
52
- that is not required, nothing will probably happen. But its going to send the
53
- header anyway.
54
-
55
-
56
-
57
47
  ### API Description
58
48
 
59
49
  The API's root URL must respond with a description, much like the
60
- [Spire.io][spire.io] API. This is crucial in order for Frenetic to work. If
61
- Frenetic doesn't know what the API contains, it can't parse any resource
62
- responses.
50
+ [Spire.io][spire.io] API.
63
51
 
64
- It is expected that any subclasses of `Frenetic::Resource` will adhere to the
65
- schema defined here.
52
+ This is crucial in order for Frenetic to work. If Frenetic doesn't know what
53
+ the API contains, it can't navigate around it or parse any of it's responses.
66
54
 
67
- Example:
55
+ **Example:**
68
56
 
69
57
  ```js
58
+ // GET http://example.com/api
70
59
  {
71
- "_links":{
72
- "self":{"href":"/api/"},
73
- "orders":{"href":"/api/orders"},
60
+ "_links": {
61
+ "self": {
62
+ "href": "/api/"
63
+ },
64
+ "orders": {
65
+ "href":"/api/orders"
66
+ },
67
+ "order": {
68
+ "href": "/api/orders/{id}",
69
+ "templated": true
70
+ }
74
71
  },
75
- "_embedded":{
76
- "schema":{
77
- "_links":{
78
- "self":{"href":"/api/schema"}
72
+ "_embedded": {
73
+ "schema": {
74
+ "_links": {
75
+ "self": { "href":"/api/schema" }
79
76
  },
80
- "order":{
81
- "description":"A widget order",
82
- "type":"object",
83
- "properties":{
84
- "id":{"type":"integer"},
85
- "first_name":{"type":"string"},
86
- "last_name":{"type":"string"},
77
+ "order": {
78
+ "description": "A widget order",
79
+ "type": "object",
80
+ "properties": {
81
+ "id": { "type":"integer" },
82
+ "first_name": { "type":"string" },
83
+ "last_name": { "type":"string" },
87
84
  }
88
85
  }
89
86
  }
@@ -92,114 +89,144 @@ Example:
92
89
  ```
93
90
 
94
91
  This response will be requested by Frenetic whenever a call to
95
- `YourAPI.description` is made. The response is memoized so any future calls
96
- will not trigger another API request.
92
+ `YourAPI.description` is made.
97
93
 
94
+ **Note:** It is highly advised that you implement some sort of caching in both
95
+ your API server as well as your API client. Refer to the [Caching][caching] section for
96
+ more information.
98
97
 
99
98
 
100
99
 
101
- ## Installation
102
100
 
103
- Add this line to your application's Gemfile:
104
101
 
105
- gem 'frenetic'
102
+ ## Configuring
106
103
 
107
- And then execute:
104
+ ### Client Initialization
108
105
 
109
- $ bundle
106
+ Initializing an API client is really easy:
110
107
 
111
- Or install it yourself as:
108
+ ```ruby
109
+ class MyApiClient
110
+ # Arbitrary example
111
+ def self.api
112
+ @api ||= Frenetic.new( url:'http://example.com/api' )
113
+ end
114
+ end
115
+ ```
112
116
 
113
- $ gem install frenetic
117
+ At the bare minimum, Frenetic only needs to know what the URL of your API is.
114
118
 
115
119
 
120
+ ### Configuring
116
121
 
122
+ Configuring Frenetic can be done during instantiation:
117
123
 
118
- ## Usage
124
+ ```ruby
125
+ Frenetic.new( url:'http://example.com', api_token:'123bada55k3y' )
126
+ ```
119
127
 
128
+ Or with a block:
120
129
 
130
+ ```ruby
131
+ f = Frenetic.new
132
+ f.configure do |cfg|
133
+ cfg.url = 'http://example.com'
134
+ cfg.api_token = '123bada55key'
135
+ end
136
+ ```
121
137
 
122
- ### Client Initialization
138
+ Or both...
123
139
 
124
140
  ```ruby
125
- MyAPI = Frenetic.new(
126
- # 'adapter' => :patron # Or some other Faraday-compatible Adapter. Defaults to `:net_http`
127
- 'url' => 'https://api.yoursite.com',
128
- 'username' => 'yourname',
129
- 'password' => 'yourpassword',
130
- 'headers' => {
131
- 'accept' => 'application/vnd.yoursite-v1.hal+json'
132
- # Optional
133
- 'user-agent' => 'Your Site's API Client', # Optional custom User Agent, just 'cuz
134
- }
135
- )
141
+ f = Frenetic.new( url:'http://example.com' )
142
+ f.configure do |cfg|
143
+ cfg.api_token = '123bada55key'
144
+ end
136
145
  ```
137
146
 
138
- Symbol- or string-based keys work equally well.
147
+ #### Authentication
148
+
149
+ Frenetic supports both Basic Auth and Tokens Auth via the appropriate Faraday
150
+ middleware.
151
+
152
+ ##### Basic Auth
153
+
154
+ To use Basic Auth, simply configure Frenetic with a `username` and `password`:
139
155
 
140
- #### Sending API Keys
156
+ ```ruby
157
+ Frenetic.new( url:url, username:'user', password:'password' )
158
+ ```
141
159
 
142
- If the API you are consuming requires an API Key, you can provide that in the
143
- config hash:
160
+ If your API uses an App ID and API Key pair, you can pass those as well:
144
161
 
145
162
  ```ruby
146
- Frenetic.new( url:'https://example.org', api_key:'abcde12345' )
163
+ Frenetic.new( url:url, app_id:'123abcSHA1', api_key:'bada55SHA1k3y' )
147
164
  ```
148
165
 
149
- The value will be sent as the `:username` portion of the HTTP
150
- Basic Authentication header.
166
+ The `app_id` and `api_key` values are simply aliases to `username` and
167
+ `password`
151
168
 
152
- #### Sending API Keys with an App ID
169
+ ##### Token Auth
153
170
 
154
- If the API requires both an App ID or access token in addition to an API Key,
155
- you can provide that in the config hash as well:
171
+ To use Token Auth, simply configure Frenetic with your token:
156
172
 
157
173
  ```ruby
158
- Frenetic.new( url:'https://example.org', app_id:'abcde12345', api_key:'mysecret' )
174
+ Frenetic.new( url:url, api_token:'bada55SHA1t0k3n' )
159
175
  ```
160
176
 
161
- The App ID will be sent as the `:username` and the API Key will be sent as the
162
- password portion of the HTTP Basic Authentication header.
163
177
 
178
+ #### Response Caching
164
179
 
180
+ If configured to do so, Frenetic will autotmatically cache API responses.
165
181
 
166
- ### Response Caching
182
+ *It is highly recommended that you turn this feature on!*
167
183
 
168
- If configured to do so, Frenetic will autotmatically cache appropriate responses
169
- through [Rack::Cache][rack_cache]. Only on-disk stores are supported right now.
184
+ ##### Rack::Cache
170
185
 
171
- Add the following `Rack::Cache` configuration options when initializing Frenetic:
186
+ ```ruby
187
+ Frenetic.new( url:url, cache: :rack )
188
+ ```
189
+
190
+ Passing in a cache option of `:rack` will cause Frenetic to use Faraday's
191
+ `Rack::Cache` middleware with a set of sane default configuration options.
192
+
193
+ If you wish to provide your own configuration options:
172
194
 
173
195
  ```ruby
174
- MyAPI = Frenetic.new(
175
- ...
176
- 'cache' => {
177
- 'metastore' => 'file:/path/to/where/you/want/to/store/files/meta',
178
- 'entitystore' => 'file:/path/to/where/you/want/to/store/files/meta'
179
- }
180
- )
196
+ Frenetic.new({
197
+ url: url,
198
+ cache: {
199
+ metastore: 'file:tmp/rack/meta',
200
+ entitystore: 'file:tmp/rack/body',
201
+ ignore_headers: %w{Authorization Set-Cookie X-Content-Digest}
202
+ }})
181
203
  ```
182
204
 
183
- The `cache` options are passed directly to `Rack::Cache`, so anything it
184
- supports can be added to the Hash.
205
+ Any key/value pair contained in the `cache` hash will be passed directly onto
206
+ the Rack::Cache middleware.
185
207
 
208
+ ##### Memcached
186
209
 
210
+ **TODO**
187
211
 
188
- # Middleware
189
212
 
190
- Frenetic supports anything that Faraday does. You may specify additional
191
- middleware with the `use` method:
213
+ #### Faraday Middleware
214
+
215
+ Frenetic will yield its internal Faraday connection during initialization:
192
216
 
193
217
  ```ruby
194
- Frenetic.new( url:'http://example.org' ) do |config|
195
- config.use :instrumentation
196
- config.use MyMiddleware, { foo:123 }
218
+ Frenetic.new( url:url ) do |builder|
219
+ # `builder` is the Faraday Connection instance with which you can
220
+ # add additional Faraday Middlewares or tweak the configuration.
197
221
  end
198
222
  ```
199
223
 
224
+ You can then use the `builder` object as you see fit.
225
+
226
+
200
227
 
201
228
 
202
- ### Making Requests
229
+ ## Usage
203
230
 
204
231
  Once you have created a client instance, you are free to use it however you'd
205
232
  like.
@@ -207,12 +234,20 @@ like.
207
234
  A Frenetic instance supports any HTTP verb that [Faraday][faraday] has
208
235
  impletented. This includes GET, POST, PUT, PATCH, and DELETE.
209
236
 
237
+ ```ruby
238
+ api = Frenetic.new( url:url )
239
+
240
+ api.get '/my_things/1'
241
+ # { 'id' => 1, 'name' => 'My Thing', '_links' => { 'self' { 'href' => '/api/my_things/1' } } }
242
+ ```
210
243
 
244
+ ### Frenetic::Resource
211
245
 
212
- #### Frenetic::Resource
246
+ An easier way to make requests for a resource is to create an object that
247
+ inherits from `Frenetic::Resource`.
213
248
 
214
- An easier way to make requests for a resource is to have your model inherit from
215
- `Frenetic::Resource`. This makes it a bit easier to encapsulate all of your
249
+ Not only does `Frenetic::Resource` handle the parsing of the raw API response
250
+ into a Ruby object, but it also makes it a bit easier to encapsulate all of your
216
251
  resource's API requests into one place.
217
252
 
218
253
  ```ruby
@@ -220,14 +255,9 @@ class Order < Frenetic::Resource
220
255
 
221
256
  api_client { MyAPI }
222
257
 
223
- class << self
224
- def find( id )
225
- if response = api.get( api.description.links.order.href.gsub('{id}', id.to_s) ) and response.success?
226
- self.new( response.body )
227
- else
228
- raise OrderNotFound, "No Order found for #{id}"
229
- end
230
- end
258
+ # TODO: Write a better example for this.
259
+ def self.find_all_by_name( name )
260
+ api.get( search_url(name) ) and response.success?
231
261
  end
232
262
  end
233
263
  ```
@@ -244,35 +274,14 @@ class Order < Frenetic::Resource
244
274
  end
245
275
  ```
246
276
 
247
- When your model is initialized, it will contain attribute readers for every
248
- property defined in your API's schema or description. In theory, this allows an
249
- API to add, remove, or change properties without the need to directly update
250
- your model.
251
-
277
+ When your model is initialized, it will contain getter methods for every
278
+ property defined in your API's schema/description.
252
279
 
280
+ Each time a request is made for a resource, Frenetic checks the API to see if
281
+ the schema has changed. If so, it will redefine the the getter methods available
282
+ on your Class. This is what Hypermedia APIs are all about, a loose coupling
283
+ between client and server.
253
284
 
254
- ### Interpretting Responses
255
-
256
- Any response body returned by a Frenetic generated API call will be returned as
257
- an OpenStruct-like object. This object responds to dot-notation as well as Hash
258
- keys and is enumerable.
259
-
260
- ```ruby
261
- response.body.resources.orders.first
262
- ```
263
-
264
- or
265
-
266
- ```ruby
267
- response.body['_embedded']['orders'][0]
268
- ```
269
-
270
- For your convenience, certain HAL+JSON keys have been aliased by methods to
271
- make your code a bit more readable:
272
-
273
- * `_embedded` can be referenced as `resources`
274
- * `_links` can be referenced as `links`
275
- * `href` can be referenced as `url`
276
285
 
277
286
 
278
287
 
@@ -285,8 +294,12 @@ make your code a bit more readable:
285
294
  4. Push to the branch (`git push origin my-new-feature`)
286
295
  5. Create new Pull Request
287
296
 
297
+ I would love to hear how other people are using this (if at all) and am open to
298
+ ideas on how to support other Hypermedia formats like [Collection+JSON][coll_json].
299
+
288
300
  [hal_json]: http://stateless.co/hal_specification.html
289
301
  [spire.io]: http://api.spire.io/
290
- [roar]: https://github.com/apotonick/roar
302
+ [caching]: #response-caching
291
303
  [faraday]: https://github.com/technoweenie/faraday
292
304
  [rack_cache]: https://github.com/rtomayko/rack-cache
305
+ [coll_json]: http://amundsen.com/media-types/collection/
@@ -15,14 +15,13 @@ 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.1'
18
+ gem.add_dependency 'faraday', '~> 0.8.7'
19
19
  gem.add_dependency 'faraday_middleware', '~> 0.9.0'
20
- gem.add_dependency 'activesupport', '>= 2'
21
- gem.add_dependency 'rack-cache', '~> 1.1'
22
- gem.add_dependency 'addressable', '~> 2.2'
20
+ gem.add_dependency 'activesupport', '>= 3'
21
+ gem.add_dependency 'addressable', '~> 2.3.4'
23
22
 
24
- gem.add_development_dependency 'patron', '~> 0.4.18'
23
+ gem.add_development_dependency 'awesome_print'
25
24
  gem.add_development_dependency 'rspec', '~> 2.13.0'
25
+ gem.add_development_dependency 'rack-cache', '~> 1.2'
26
26
  gem.add_development_dependency 'webmock', '~> 1.11.0'
27
- gem.add_development_dependency 'vcr', '~> 2.4.0'
28
27
  end
@@ -1,66 +1,54 @@
1
- require 'addressable/uri'
1
+ require 'socket'
2
2
  require 'faraday'
3
3
  require 'faraday_middleware'
4
- require 'rack-cache'
5
4
 
6
- require "frenetic/configuration"
7
- require "frenetic/hal_json"
8
- require "frenetic/resource"
9
- require "frenetic/version"
5
+ require 'frenetic/concerns/configurable'
6
+ require 'frenetic/middleware/hal_json'
7
+ require 'frenetic/resource'
8
+ require 'frenetic/resource_collection'
9
+ require 'frenetic/version'
10
10
 
11
11
  class Frenetic
12
- Error = Class.new(StandardError)
13
- ConfigurationError = Class.new(Error)
14
- MissingAPIReference = Class.new(Error)
15
- InvalidAPIDescription = Class.new(Error)
16
-
17
12
  extend Forwardable
18
- def_delegators :@connection, :get, :put, :post, :delete
19
-
20
- attr_reader :connection
21
- alias_method :conn, :connection
13
+ include Configurable
22
14
 
23
- def initialize( config = {} )
24
- config = Configuration.new( config )
15
+ def_delegators :connection, :get, :put, :post, :delete
25
16
 
26
- yield config if block_given?
17
+ Error = Class.new(StandardError)
18
+ ConfigError = Class.new(Error)
19
+ ClientError = Class.new(Error)
20
+ ServerError = Class.new(Error)
21
+ ParsingError = Class.new(Error)
22
+ HypermediaError = Class.new(Error)
27
23
 
28
- api_url = Addressable::URI.parse( config.url )
29
- @root_url = api_url.path
24
+ def connection
25
+ @connection ||= begin
26
+ validate_configuration!
30
27
 
31
- @connection = Faraday.new( config.to_hash ) do |builder|
32
- builder.use HalJson
28
+ Faraday.new( config ) do |builder|
29
+ configure_authentication builder
33
30
 
34
- config.middleware.each { |mw| builder.use(*mw) }
31
+ builder.response :hal_json
35
32
 
36
- builder.request :basic_auth, config.username, config.password
33
+ configure_caching builder
37
34
 
38
- builder.response :logger if config.response[:use_logger]
35
+ @builder_config.call( builder ) if @builder_config
39
36
 
40
- if config.cache.present?
41
- builder.use FaradayMiddleware::RackCompatible, Rack::Cache::Context, config.cache
37
+ builder.adapter config.adapter
42
38
  end
43
-
44
- builder.adapter config.adapter || :net_http
45
39
  end
46
40
  end
47
41
 
42
+ # It is highly advised that the server responds with some cache headers and
43
+ # the API Client is configured to use a Faraday caching strategy
48
44
  def description
49
- @description ||= load_description
45
+ if response = get( config.url.to_s ) and response.success?
46
+ response.body
47
+ end
50
48
  end
51
49
 
52
- # A naive approach to reloading a Frenetic instance for testing purpose.
53
- def reload!
54
- instance_variables.each { |var| instance_variable_set(var, nil) }
50
+ def schema
51
+ description['_embedded']['schema']
55
52
  end
56
53
 
57
- private
58
-
59
- def load_description
60
- if response = get( @root_url ) and response.success?
61
- response.body
62
- else
63
- raise InvalidAPIDescription, "Status code #{response.status} encountered."
64
- end
65
- end
66
- end
54
+ end