frenetic 0.0.1.alpha1

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,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ .rspec
7
+ Gemfile.lock
8
+ InstalledFiles
9
+ _yardoc
10
+ coverage
11
+ doc/
12
+ lib/bundler/man
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
data/.rvmrc ADDED
@@ -0,0 +1 @@
1
+ rvm --create use ruby-1.9.2-p290@frenetic > /dev/null
@@ -0,0 +1,4 @@
1
+ rvm:
2
+ - 1.9.2
3
+ - 1.9.3
4
+ script: bundle exec rspec --require spec_helper --order rand --color --format documentation
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in frenetic.gemspec
4
+ gemspec
5
+
6
+ gem "awesome_print"
7
+ gem "fakefs", '~> 0.4.0', :require => false
@@ -0,0 +1,15 @@
1
+ # More info at https://github.com/guard/guard#readme
2
+
3
+ guard 'rspec', :notification => false do
4
+ watch(%r{^spec/.+_spec\.rb$})
5
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
6
+ watch('spec/spec_helper.rb') { "spec" }
7
+ watch(%r{^spec/support/(.+)\.rb$}) { "spec" }
8
+ end
9
+
10
+ guard 'spork', :rspec_env => { 'RAILS_ENV' => 'test' } do
11
+ watch('config/environment.rb')
12
+ watch('Gemfile')
13
+ watch('Gemfile.lock')
14
+ watch('spec/spec_helper.rb') { :rspec }
15
+ end
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012 Derek Lindahl
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.
@@ -0,0 +1,223 @@
1
+ # Frenetic [![Build Status][travis_status]][travis]
2
+
3
+ [travis_status]: https://secure.travis-ci.org/dlindahl/frenetic.png
4
+ [travis]: http://travis-ci.org/dlindahl/frenetic
5
+
6
+ An opinionated Ruby-based Hypermedia API (HAL+JSON) client.
7
+
8
+ ## About
9
+
10
+ fre&bull;net&bull;ic |frəˈnetik|<br/>
11
+ adjective<br/>
12
+ fast and energetic in a rather wild and uncontrolled way : *a frenetic pace of activity.*
13
+
14
+ So basically, this is a crazy way to interact with your Hypermedia HAL+JSON API.
15
+
16
+ Get it? *Hypermedia*?
17
+
18
+ *Hyper*?
19
+
20
+ ...
21
+
22
+ If you have not implemented a HAL+JSON API, then this will not work very well for you.
23
+
24
+ ## Opinions
25
+
26
+ Like I said, it is opinionated. It is so opinionated, it is probably the biggest
27
+ a-hole you've ever met.
28
+
29
+ Maybe in time, if you teach it, it will become more open-minded.
30
+
31
+ ### HAL+JSON Content Type
32
+
33
+ Frenetic expects all responses to be in [HAL+JSON][hal_json]. It chose that
34
+ standard because it is trying to make JSON API's respond in a predictable
35
+ manner, which it thinks is an awesome idea.
36
+
37
+ ### Authentication
38
+
39
+ Frenetic is going to try and use Basic Auth whether you like it or not. If
40
+ that is not required, nothing will probably happen. But its going to send the
41
+ header anyway.
42
+
43
+ ### API Description
44
+
45
+ The API's root URL must respond with a description, much like the
46
+ [Spire.io][spire.io] API. This is crucial in order for Frenetic to work. If
47
+ Frenetic doesn't know what the API contains, it can't parse any resource
48
+ responses.
49
+
50
+ It is expected that any subclasses of `Frenetic::Resource` will adhere to the
51
+ schema defined here.
52
+
53
+ Example:
54
+
55
+ ```js
56
+ {
57
+ "_links":{
58
+ "self":{"href":"/api/"},
59
+ "inkers":{"href":"/api/inkers"},
60
+ },
61
+ "_embedded":{
62
+ "schema":{
63
+ "_links":{
64
+ "self":{"href":"/api/schema"}
65
+ },
66
+ "order":{
67
+ "description":"A widget order",
68
+ "type":"object",
69
+ "properties":{
70
+ "id":{"type":"integer"},
71
+ "first_name":{"type":"string"},
72
+ "last_name":{"type":"string"},
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ This response will be requested by Frenetic whenever a call to
81
+ `YourAPI.description` is made. The response is memoized so any future calls
82
+ will not trigger another API request.
83
+
84
+ ### API Resources
85
+
86
+ While HAL+JSON is awesome, not all implementations are perfect. Frenetic
87
+ assumes a HAL+JSON response as built by [Roar], which may not be in 100%
88
+ compliance.
89
+
90
+ Example:
91
+
92
+ ```js
93
+ {
94
+ "id":1,
95
+ "first_name":"Foo",
96
+ "last_name":"Bar",
97
+ "_links":{
98
+ "self":{"href":"/order/1"},
99
+ "next":{"href":"/order/2"}
100
+ }
101
+ }
102
+ ```
103
+
104
+ The problem here is that the entire response really should be wrapped in
105
+ `"_embedded"` and `"order"` keys.
106
+
107
+ So until that is fixed, Frenetic will continue to be pig headed and continue
108
+ to do the "wrong" thing.
109
+
110
+ ## Installation
111
+
112
+ Add this line to your application's Gemfile:
113
+
114
+ gem 'frenetic'
115
+
116
+ And then execute:
117
+
118
+ $ bundle
119
+
120
+ Or install it yourself as:
121
+
122
+ $ gem install frenetic
123
+
124
+ ## Usage
125
+
126
+ ### Client Initialization
127
+
128
+ ```ruby
129
+ MyAPI = Frenetic.new(
130
+ 'url' => 'https://api.yoursite.com',
131
+ 'username' => 'yourname',
132
+ 'password' => 'yourpassword',
133
+ 'headers' => {
134
+ 'accept' => 'application/vnd.yoursite-v1.hal+json'
135
+ # Optional
136
+ 'user-agent' => 'Your Site's API Client', # Optional custom User Agent, just 'cuz
137
+ }
138
+ )
139
+ ```
140
+
141
+ ### Making Requests
142
+
143
+ Once you have created a client instance, you are free to use it however you'd
144
+ like.
145
+
146
+ A Frenetic instance supports any HTTP verb that [Faraday][faraday] has
147
+ impletented. This includes GET, POST, PUT, PATCH, and DELETE.
148
+
149
+ #### Frenetic::Resource
150
+
151
+ An easier way to make requests for a resource is to have your model inherit from
152
+ `Frenetic::Resource`. This makes it a bit easier to encapsulate all of your
153
+ resource's API requests into one place.
154
+
155
+ ```ruby
156
+ class Order < Frenetic::Resource
157
+
158
+ api_client { MyAPI }
159
+
160
+ class << self
161
+ def find( id )
162
+ if response = api.get( api.description.links.order.href.gsub('{id}', id.to_s) ) and response.success?
163
+ self.new( response.body )
164
+ else
165
+ raise OrderNotFound, "No Order found for #{id}"
166
+ end
167
+ end
168
+ end
169
+ end
170
+ ```
171
+
172
+ The `api_client` class method merely tells `Frenetic::Resource` which API Client
173
+ instance to use. If you lazily instantiate your client, then you should pass a
174
+ block as demonstrated above.
175
+
176
+ Otherwise, you may pass by reference:
177
+
178
+ ```ruby
179
+ class Order < Frenetic::Resource
180
+ api_client MyAPI
181
+ end
182
+ ```
183
+
184
+ When your model is initialized, it will contain attribute readers for every
185
+ property defined in your API's schema or description. In theory, this allows an
186
+ API to add, remove, or change properties without the need to directly update
187
+ your model.
188
+
189
+ ### Interpretting Responses
190
+
191
+ Any response body returned by a Frenetic generated API call will be returned as
192
+ an OpenStruct-like object. This object responds to dot-notation as well as Hash
193
+ keys and is enumerable.
194
+
195
+ ```ruby
196
+ response.body.resources.orders.first
197
+ ```
198
+
199
+ or
200
+
201
+ ```ruby
202
+ response.body['_embedded']['orders'][0]
203
+ ```
204
+
205
+ For your convenience, certain HAL+JSON keys have been aliased by methods a bit
206
+ more readable:
207
+
208
+ * `_embedded` can be referenced as `resources`
209
+ * `_links` can be referenced as `links`
210
+ * `href` can be referenced as `url`
211
+
212
+ ## Contributing
213
+
214
+ 1. Fork it
215
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
216
+ 3. Commit your changes (`git commit -am 'Added some feature'`)
217
+ 4. Push to the branch (`git push origin my-new-feature`)
218
+ 5. Create new Pull Request
219
+
220
+ [hal_json]: http://stateless.co/hal_specification.html
221
+ [spire.io]: http://api.spire.io/
222
+ [roar]: https://github.com/apotonick/roar
223
+ [faraday]: https://github.com/technoweenie/faraday
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
@@ -0,0 +1,28 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/frenetic/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Derek Lindahl"]
6
+ gem.email = ["dlindahl@customink.com"]
7
+ gem.description = %q{An opinionated Ruby-based Hypermedia API client.}
8
+ gem.summary = %q{Here lies a Ruby-based Hypermedia API client that expects HAL+JSON and makes a lot of assumptions about your API.}
9
+ gem.homepage = "http://dlindahl.github.com/frenetic/"
10
+
11
+ gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
12
+ gem.files = `git ls-files`.split("\n")
13
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
14
+ gem.name = "frenetic"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = Frenetic::VERSION
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'
21
+
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'
28
+ end
@@ -0,0 +1,49 @@
1
+ require 'addressable/uri'
2
+ require 'patron' # Needed to prevent https://github.com/technoweenie/faraday/issues/140
3
+ require 'faraday'
4
+
5
+ require "frenetic/configuration"
6
+ require "frenetic/hal_json"
7
+ require "frenetic/resource"
8
+ require "frenetic/version"
9
+
10
+ class Frenetic
11
+
12
+ class MissingAPIReference < StandardError; end
13
+
14
+ extend Forwardable
15
+ def_delegators :@connection, :get, :put, :post, :delete
16
+
17
+ attr_reader :connection
18
+ alias_method :conn, :connection
19
+
20
+ def initialize( config = {} )
21
+ config = Configuration.new( config )
22
+
23
+ api_url = Addressable::URI.parse( config[:url] )
24
+ @root_url = api_url.path
25
+
26
+ @connection = Faraday.new( config ) do |builder|
27
+ builder.use HalJson
28
+ builder.request :basic_auth, config[:username], config[:password]
29
+ builder.adapter :patron
30
+ end
31
+ end
32
+
33
+ def description
34
+ @description ||= load_description
35
+ end
36
+
37
+ # A naive approach to reloading a Frenetic instance for testing purpose.
38
+ def reload!
39
+ instance_variables.each { |var| instance_variable_set(var, nil) }
40
+ end
41
+
42
+ private
43
+
44
+ def load_description
45
+ if response = get( @root_url ) and response.success?
46
+ response.body
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,82 @@
1
+ require 'socket'
2
+
3
+ class Frenetic
4
+ class Configuration < Hash
5
+
6
+ class ConfigurationError < StandardError; end
7
+
8
+ # TODO: This is in desperate need of .with_indifferent_access...
9
+ # TODO: "content-type" should probably be within a "headers" key
10
+ def initialize( custom_config = {} )
11
+ config = config_file.merge custom_config
12
+ config = symbolize_keys config
13
+
14
+ config[:username] = config[:api_key] if config[:api_key]
15
+ config[:headers] ||= {}
16
+ config[:request] ||= {}
17
+
18
+ config[:headers][:accept] ||= "application/hal+json"
19
+
20
+ # Copy the config into this Configuration instance.
21
+ config.each { |k, v| self[k] = v }
22
+
23
+ super()
24
+
25
+ configure_user_agent
26
+
27
+ validate
28
+ end
29
+
30
+ private
31
+
32
+ def configure_user_agent
33
+ frenetic_ua = "Frenetic v#{Frenetic::VERSION}; #{Socket.gethostname}"
34
+
35
+ if self[:headers][:user_agent]
36
+ self[:headers][:user_agent] << " (#{frenetic_ua})"
37
+ else
38
+ self[:headers][:user_agent] = frenetic_ua
39
+ end
40
+ end
41
+
42
+ def validate
43
+ unless self[:url]
44
+ raise ConfigurationError, "No API URL defined!"
45
+ end
46
+ end
47
+
48
+ def config_file
49
+ config_path = File.join( 'config', 'frenetic.yml' )
50
+
51
+ if File.exists? config_path
52
+ config = YAML.load_file( config_path )
53
+ env = ENV['RAILS_ENV'] || ENV['RACK_ENV']
54
+
55
+ if config and config.has_key? env
56
+ config[env]
57
+ else
58
+ {}
59
+ end
60
+ else
61
+ {}
62
+ end
63
+ end
64
+
65
+ def symbolize_keys( arg )
66
+ case arg
67
+ when Array
68
+ arg.map { |elem| symbolize_keys elem }
69
+ when Hash
70
+ Hash[
71
+ arg.map { |key, value|
72
+ k = key.is_a?(String) ? key.to_sym : key
73
+ v = symbolize_keys value
74
+ [k,v]
75
+ }]
76
+ else
77
+ arg
78
+ end
79
+ end
80
+
81
+ end
82
+ end
@@ -0,0 +1,23 @@
1
+ require 'json'
2
+ require 'recursive_open_struct'
3
+ require 'frenetic/hal_json/response_wrapper'
4
+
5
+ class Frenetic
6
+
7
+ class HalJson < Faraday::Middleware
8
+ def call( env )
9
+ @app.call(env).on_complete { |env| on_complete(env) }
10
+ end
11
+
12
+ def on_complete( env )
13
+ if success? env
14
+ env[:body] = ResponseWrapper.new( JSON.parse(env[:body]) )
15
+ end
16
+ end
17
+
18
+ def success?( env )
19
+ (200..201) === env[:status]
20
+ end
21
+ end
22
+
23
+ end
@@ -0,0 +1,43 @@
1
+ class Frenetic
2
+ class HalJson < Faraday::Middleware
3
+ # TODO: The API for this differs greatly from the `inspect` output.
4
+ # Perhaps the Hash keys should be normalized and then aliased back to the original keys?
5
+ class ResponseWrapper < RecursiveOpenStruct
6
+ include Enumerable
7
+
8
+ def []( key )
9
+ self.send(key)
10
+ end
11
+
12
+ def members
13
+ methods(false).grep(%r{_as_a_hash}).map { |m| m[0...-10] }
14
+ end
15
+ alias_method :keys, :members
16
+
17
+ def each
18
+ members.each do |method|
19
+ yield method, send(method)
20
+ end
21
+
22
+ self
23
+ end
24
+
25
+ class << self
26
+ # Do not define setters
27
+ def define_setter( * ); end
28
+
29
+ def define_getter( method_name, hash_key )
30
+ method_name = case method_name
31
+ when :_embedded then :resources
32
+ when :_links then :links
33
+ when :href then :url
34
+ else method_name
35
+ end
36
+
37
+ super
38
+ end
39
+ end
40
+
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,68 @@
1
+ class Frenetic
2
+ class Resource
3
+
4
+ attr_reader :links
5
+
6
+ def initialize( attributes = {} )
7
+ if attributes.is_a? Hash
8
+ load attributes.keys, attributes
9
+
10
+ @links = []
11
+ else
12
+ load self.class.schema, attributes
13
+ load attributes.resources.members, attributes.resources
14
+
15
+ @links = attributes.links
16
+ end
17
+ end
18
+
19
+ # Attempts to retrieve the Resource Schema from the API based on the name
20
+ # of the subclass.
21
+ class << self
22
+ def api_client( client = nil )
23
+ metaclass.instance_eval do
24
+ define_method :api do
25
+ block_given? ? yield : client
26
+ end
27
+ end
28
+ end
29
+
30
+ def schema
31
+ if self.respond_to? :api
32
+ class_name = self.to_s.split('::').last.downcase
33
+
34
+ api.description.resources.schema.send(class_name).properties
35
+ else
36
+ raise MissingAPIReference,
37
+ "This Resource needs a class accessor defined as " +
38
+ "`.api` that references an instance of Frenetic."
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def metaclass
45
+ metaclass = class << self; self; end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def metaclass
52
+ metaclass = class << self; self; end
53
+ end
54
+
55
+ def load( keys, attributes )
56
+ keys.each do |key|
57
+ instance_variable_set "@#{key}", attributes[key.to_s]
58
+
59
+ metaclass.class_eval do
60
+ define_method key do
61
+ instance_variable_get( "@#{key}")
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ end
68
+ end
@@ -0,0 +1,3 @@
1
+ class Frenetic
2
+ VERSION = "0.0.1.alpha1"
3
+ end
@@ -0,0 +1,79 @@
1
+ # Stolen from https://github.com/aetherknight/recursive-open-struct/blob/master/lib/recursive_open_struct.rb
2
+ require 'ostruct'
3
+
4
+ class RecursiveOpenStruct < OpenStruct
5
+
6
+ def new_ostruct_member(name)
7
+ name = name.to_sym
8
+ unless self.respond_to?(name)
9
+ class << self; self; end.class_eval { define_accessors name, name }
10
+ end
11
+ name
12
+ end
13
+
14
+ def keys
15
+ @table.keys
16
+ end
17
+
18
+ ###
19
+
20
+ def self.define_accessors( *args )
21
+ define_getter *args
22
+ define_setter *args
23
+ define_getter_as_a_hash *args
24
+ end
25
+
26
+ def self.define_getter( method_name, hash_key )
27
+ define_method( method_name ) do
28
+ v = @table[hash_key]
29
+ v.is_a?(Hash) ? self.class.new(v) : v
30
+ end
31
+ end
32
+
33
+ def self.define_setter( method_name, hash_key )
34
+ define_method("#{method_name}=") { |x| modifiable[hash_key] = x }
35
+ end
36
+
37
+ def self.define_getter_as_a_hash( method_name, hash_key )
38
+ define_method("#{method_name}_as_a_hash") { @table[hash_key] }
39
+ end
40
+
41
+ ###
42
+
43
+ def debug_inspect(indent_level = 0, recursion_limit = 12)
44
+ display_recursive_open_struct(@table, indent_level, recursion_limit)
45
+ end
46
+
47
+ def display_recursive_open_struct(ostrct_or_hash, indent_level, recursion_limit)
48
+
49
+ if recursion_limit <= 0 then
50
+ # protection against recursive structure (like in the tests)
51
+ puts ' '*indent_level + '(recursion limit reached)'
52
+ else
53
+ #puts ostrct_or_hash.inspect
54
+ if ostrct_or_hash.is_a?(self.class) then
55
+ ostrct_or_hash = ostrct_or_hash.marshal_dump
56
+ end
57
+
58
+ # We'll display the key values like this : key = value
59
+ # to align display, we look for the maximum key length of the data that will be displayed
60
+ # (everything except hashes)
61
+ data_indent = ostrct_or_hash
62
+ .reject { |k, v| v.is_a?(self.class) || v.is_a?(Hash) }
63
+ .max {|a,b| a[0].to_s.length <=> b[0].to_s.length}[0].length
64
+ # puts "max length = #{data_indent}"
65
+
66
+ ostrct_or_hash.each do |key, value|
67
+ if (value.is_a?(self.class) || value.is_a?(Hash)) then
68
+ puts ' '*indent_level + key.to_s + '.'
69
+ display_recursive_open_struct(value, indent_level + 1, recursion_limit - 1)
70
+ else
71
+ puts ' '*indent_level + key.to_s + ' '*(data_indent - key.to_s.length) + ' = ' + value.inspect
72
+ end
73
+ end
74
+ end
75
+
76
+ true
77
+ end
78
+
79
+ end
@@ -0,0 +1,38 @@
1
+ ---
2
+ http_interactions:
3
+ - request:
4
+ method: get
5
+ uri: http://example.org:5447/api
6
+ body:
7
+ encoding: US-ASCII
8
+ string: ''
9
+ headers:
10
+ Accept-Encoding:
11
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
12
+ Accept:
13
+ - ! '*/*'
14
+ User-Agent:
15
+ - Ruby
16
+ response:
17
+ status:
18
+ code: 200
19
+ message: ! 'OK '
20
+ headers:
21
+ Content-Type:
22
+ - application/json
23
+ Content-Length:
24
+ - '620'
25
+ Server:
26
+ - WEBrick/1.3.1 (Ruby/1.9.2/2011-07-09)
27
+ Date:
28
+ - Sun, 08 Apr 2012 02:09:53 GMT
29
+ Connection:
30
+ - Keep-Alive
31
+ body:
32
+ encoding: US-ASCII
33
+ string: ! '{"_links":{"self":{"href":"/api/"},"inkers":{"href":"/api/inkers"},"inker":{"href":"/api/inker/{id}"},"sessions":{"href":"/api/sessions"}},"_embedded":{"schema":{"_links":{"self":{"href":"/api/schema"}},"mediaType":"application/vnd.customink-inker_directory-v1+json","inker":{"description":"A
34
+ CustomInk employee","type":"object","properties":{"first_name":{"type":"string"},"last_name":{"type":"string"},"city":{"type":"string"},"images":{"type":"array","items":{"type":"object"}}}},"session":{"description":"A
35
+ gatewat for generating an authenticated session.","type":"object","properties":{"st-ticket-FOO":"string"}}}}}'
36
+ http_version: !!null
37
+ recorded_at: Sun, 08 Apr 2012 02:09:53 GMT
38
+ recorded_with: VCR 2.0.1
@@ -0,0 +1,77 @@
1
+ describe Frenetic::Configuration do
2
+ let(:content_type) { 'application/vnd.frenetic-v1-hal+json' }
3
+ let(:yaml_config) {
4
+ { 'test' => {
5
+ 'url' => 'http://example.org',
6
+ 'api_key' => '1234567890',
7
+ 'headers' => {
8
+ 'accept' => content_type,
9
+ },
10
+ 'request' => {
11
+ 'timeout' => 10000
12
+ }
13
+ }
14
+ }
15
+ }
16
+ let(:config) { Frenetic::Configuration.new( unknown: :option ) }
17
+
18
+ subject { config }
19
+
20
+ describe ".configuration" do
21
+ include FakeFS::SpecHelpers
22
+
23
+ context "with a proper config YAML" do
24
+ before do
25
+ FileUtils.mkdir_p("config")
26
+ File.open( 'config/frenetic.yml', 'w') do |f|
27
+ f.write( YAML::dump(yaml_config) )
28
+ end
29
+ end
30
+
31
+ it { should include(:username) }
32
+ it { should include(:url) }
33
+
34
+ it { should_not include(:unknown => 'option')}
35
+ it "should set default request options" do
36
+ subject[:request][:timeout].should == 10000
37
+ end
38
+ it "should set a User Agent request header" do
39
+ subject[:headers][:user_agent].should =~ %r{Frenetic v.+; \S+$}
40
+ end
41
+
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')
45
+ end
46
+ end
47
+ context "without a specified Accept header" do
48
+ let(:content_type) { nil }
49
+
50
+ it "should set an Accept request header" do
51
+ subject[:headers].should include(:accept => 'application/hal+json')
52
+ end
53
+ end
54
+ end
55
+
56
+ context "with no config YAML" do
57
+ context "and no passed options" do
58
+ it "should raise a configuration error" do
59
+ expect { Frenetic::Configuration.new }.to raise_error( Frenetic::Configuration::ConfigurationError )
60
+ end
61
+ end
62
+ context "and passed options" do
63
+ let(:config) { Frenetic::Configuration.new( 'url' => 'http://example.org' ) }
64
+
65
+ it { should be_a( Hash ) }
66
+ it { should_not be_empty }
67
+ it "should set an Accepts request header" do
68
+ subject[:headers].should include(:accept => 'application/hal+json')
69
+ end
70
+ it "should set a User Agent request header" do
71
+ subject[:headers][:user_agent].should =~ %r{Frenetic v.+; \S+$}
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ end
@@ -0,0 +1,70 @@
1
+ describe Frenetic::HalJson::ResponseWrapper do
2
+ let(:properties) do
3
+ { 'a' => 1, 'b' => 2 }
4
+ end
5
+ let(:wrapper) { Frenetic::HalJson::ResponseWrapper.new( properties ) }
6
+
7
+ subject { wrapper }
8
+
9
+ describe "#members" do
10
+ subject { wrapper.members }
11
+
12
+ its(:size) { should == 2 }
13
+ its(:first) { should == 'a' }
14
+ its(:last) { should == 'b' }
15
+ end
16
+
17
+ describe "#keys" do
18
+ subject { wrapper.keys }
19
+
20
+ it { should == wrapper.members }
21
+ end
22
+
23
+ describe "#each" do
24
+ before do
25
+ @items = []
26
+ wrapper.each do |*args|
27
+ @items << args
28
+ end
29
+ end
30
+
31
+ it { should be_a Frenetic::HalJson::ResponseWrapper }
32
+ it "should iterate over each getter" do
33
+ @items.should == [ ['a',1], ['b',2] ]
34
+ end
35
+ end
36
+
37
+ describe ".define_setter" do
38
+ subject { wrapper.methods(false) }
39
+
40
+ it "should not create setters" do
41
+ subject.none? { |name| name.to_s =~ %r{=} }.should be_true
42
+ end
43
+ end
44
+
45
+ describe ".define_getter" do
46
+ context "with a :_links property" do
47
+ let(:properties) { { '_links' => 'foo' } }
48
+
49
+ it "should create a :links property" do
50
+ wrapper.links.should == 'foo'
51
+ end
52
+ end
53
+
54
+ context "with a :_embedded property" do
55
+ let(:properties) { { '_embedded' => 'foo' } }
56
+
57
+ it "should create a :resources property" do
58
+ wrapper.resources.should == 'foo'
59
+ end
60
+ end
61
+
62
+ context "with a :_embedded property" do
63
+ let(:properties) { { 'href' => 'foo' } }
64
+
65
+ it "should create a :url property" do
66
+ wrapper.url.should == 'foo'
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,70 @@
1
+ describe Frenetic::HalJson do
2
+ let(:hal_json) { Frenetic::HalJson.new }
3
+ let(:app_callbacks_stub) do
4
+ stub('FaradayCallbackStubs').tap do |cb_stub|
5
+ cb_stub.stubs('on_complete').yields env
6
+ end
7
+ end
8
+ let(:app_stub) do
9
+ stub('FaradayAppStub').tap do |app_stub|
10
+ app_stub.stubs(call: app_callbacks_stub)
11
+ end
12
+ end
13
+
14
+ before { hal_json.instance_variable_set("@app", app_stub) }
15
+
16
+ subject { hal_json }
17
+
18
+ describe "#call" do
19
+ let(:env) { Hash.new(:status => 200) }
20
+
21
+ before do
22
+ hal_json.stubs(:on_complete)
23
+
24
+ hal_json.call(env)
25
+ end
26
+
27
+ it "should execute the on_complete callback" do
28
+ hal_json.should have_received(:on_complete).with(env)
29
+ end
30
+ end
31
+
32
+ describe "#on_complete" do
33
+ context "with a successful response" do
34
+ let(:env) do
35
+ {
36
+ :status => 200,
37
+ :body => JSON.generate({
38
+ '_links' => {}
39
+ })
40
+ }
41
+ end
42
+
43
+ before { hal_json.on_complete(env) }
44
+
45
+ it "should parse the HAL+JSON response" do
46
+ env[:body].should be_a( Frenetic::HalJson::ResponseWrapper )
47
+ end
48
+ end
49
+ end
50
+
51
+ describe "#success?" do
52
+ subject { hal_json.success?( env ) }
53
+
54
+ context "with a 200 OK response" do
55
+ let(:env) { {:status => 200 } }
56
+
57
+ it { should be_true }
58
+ end
59
+ context "with a 201 Created response" do
60
+ let(:env) { {:status => 201 } }
61
+
62
+ it { should be_true }
63
+ end
64
+ context "with a 204 No Content" do
65
+ let(:env) { {:status => 204 } }
66
+
67
+ it { should be_false }
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,102 @@
1
+ describe Frenetic::Resource do
2
+
3
+ @client = Frenetic.new('url' => 'http://example.org')
4
+
5
+ let(:resource) { Frenetic::Resource.new }
6
+ let(:description_stub) do
7
+ Frenetic::HalJson::ResponseWrapper.new('resources' => { 'schema' => { 'resource' =>
8
+ { 'properties' => { 'foo' => 2 } }
9
+ } } )
10
+ end
11
+
12
+ subject { resource }
13
+
14
+ context "created from a Hash" do
15
+ let(:resource) { Frenetic::Resource.new( foo: 'bar' ) }
16
+
17
+ it { should respond_to(:foo) }
18
+ its(:links) { should be_empty }
19
+ end
20
+
21
+ context "created from a HAL-JSON response" do
22
+ let(:api_response) do
23
+ Frenetic::HalJson::ResponseWrapper.new(
24
+ '_links' => {
25
+ '_self' => { '_href' => 'bar' }
26
+ },
27
+ 'foo' => 1,
28
+ 'bar' => 2,
29
+ '_embedded' => {
30
+ 'baz' => 'resource'
31
+ }
32
+ )
33
+ end
34
+ let(:resource_a) { Frenetic::Resource.new( api_response ) }
35
+ let(:resource_b) { Frenetic::Resource.new }
36
+
37
+ before do
38
+ @client.stubs(:description).returns( description_stub )
39
+
40
+ Frenetic::Resource.stubs(:api).returns( @client )
41
+
42
+ resource_a and resource_b
43
+ end
44
+
45
+ context "initialized with data" do
46
+ subject { resource_a }
47
+
48
+ its(:foo) { should == 1 }
49
+ it { should_not respond_to(:bar) }
50
+ its(:links) { should_not be_empty }
51
+ end
52
+ context "intiailized in sequence without data" do
53
+ subject { resource_b }
54
+
55
+ it { should_not respond_to(:foo) }
56
+ it { should_not respond_to(:bar) }
57
+ its(:links) { should be_empty }
58
+ end
59
+ end
60
+
61
+ describe ".api_client" do
62
+ context "with a block" do
63
+ before { resource.class.api_client { @client } }
64
+
65
+ it "should reference the defined API client" do
66
+ subject.class.api.should == @client
67
+ end
68
+ end
69
+
70
+ context "with an argument" do
71
+ before { resource.class.api_client @client }
72
+
73
+ it "should reference the defined API client" do
74
+ subject.class.api.should == @client
75
+ end
76
+ end
77
+ end
78
+
79
+ describe ".schema" do
80
+ subject { resource.class.schema }
81
+
82
+ context "with a defined API Client" do
83
+ before do
84
+ @client.stubs(:description).returns( description_stub )
85
+
86
+ resource.class.api_client @client
87
+ end
88
+
89
+ it "should return the schema for the specific resource" do
90
+ subject.should == description_stub.resources.schema.resource.properties
91
+ end
92
+ end
93
+
94
+ context "without a defined API Client" do
95
+ before { Frenetic::Resource.stubs(:respond_to?).with(:api).returns( false ) }
96
+
97
+ it "should raise an error" do
98
+ expect { subject }.to raise_error(Frenetic::MissingAPIReference)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,66 @@
1
+ describe Frenetic do
2
+ let(:client) { Frenetic.new }
3
+ let(:config) { {
4
+ :url => 'http://example.org:5447/api',
5
+ :api_key => '1234567890',
6
+ :version => 'v1'
7
+ } }
8
+
9
+ before { Frenetic::Configuration.stubs(:new).returns(config) }
10
+
11
+ subject { client }
12
+
13
+ it { should respond_to(:description) }
14
+ it { should respond_to(:connection) }
15
+ it { should respond_to(:conn) }
16
+ it { should respond_to(:get) }
17
+ it { should respond_to(:put) }
18
+ it { should respond_to(:post) }
19
+ it { should respond_to(:delete) }
20
+
21
+ its(:connection) { should be_a(Faraday::Connection) }
22
+
23
+ describe "#connection" do
24
+ before do
25
+ faraday_stub = Faraday.new
26
+ Faraday.stubs(:new).returns( faraday_stub )
27
+
28
+ client
29
+ end
30
+
31
+ subject { client.connection }
32
+
33
+ it { should be_a(Faraday::Connection) }
34
+
35
+ it "should be created" do
36
+ Faraday.should have_received(:new).with() { |config|
37
+ config.has_key? :url
38
+ }
39
+ end
40
+ end
41
+
42
+ describe "#description" do
43
+ subject do
44
+ VCR.use_cassette('description_success') do
45
+ client.description
46
+ end
47
+ end
48
+
49
+ it { should be_a( Frenetic::HalJson::ResponseWrapper ) }
50
+ end
51
+
52
+ describe "#reload!" do
53
+ before do
54
+ VCR.use_cassette('description_success') do
55
+ client.description
56
+ end
57
+
58
+ client.reload!
59
+ end
60
+
61
+ it "should not have any non-Nil instance variables" do
62
+ client.instance_variables.none? { |s| client.instance_variable_get(s).nil? }.should be_false
63
+ end
64
+ end
65
+
66
+ end
@@ -0,0 +1,31 @@
1
+ require 'spork'
2
+ ENV['RAILS_ENV'] ||= 'test'
3
+
4
+ Spork.prefork do
5
+ # Loading more in this block will cause your tests to run faster. However,
6
+ # if you change any configuration or code from libraries loaded here, you'll
7
+ # need to restart spork for it take effect.
8
+
9
+ require 'rspec'
10
+ require 'fakefs/spec_helpers'
11
+ require 'awesome_print'
12
+ require 'bourne'
13
+ require 'vcr'
14
+
15
+ RSpec.configure do |config|
16
+ config.filter_run :focus => true
17
+ config.run_all_when_everything_filtered = true
18
+ config.treat_symbols_as_metadata_keys_with_true_values = true
19
+ config.mock_with :mocha
20
+ end
21
+
22
+ VCR.configure do |c|
23
+ c.cassette_library_dir = 'spec/fixtures/vcr_cassettes'
24
+ c.hook_into :webmock
25
+ end
26
+ end
27
+
28
+ Spork.each_run do
29
+ # This code will be run each time you run your specs.
30
+ require 'frenetic'
31
+ end
metadata ADDED
@@ -0,0 +1,175 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: frenetic
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1.alpha1
5
+ prerelease: 6
6
+ platform: ruby
7
+ authors:
8
+ - Derek Lindahl
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-04-16 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: faraday
16
+ requirement: &2156831400 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 0.8.0.rc2
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *2156831400
25
+ - !ruby/object:Gem::Dependency
26
+ name: addressable
27
+ requirement: &2156830640 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ~>
31
+ - !ruby/object:Gem::Version
32
+ version: 2.2.7
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *2156830640
36
+ - !ruby/object:Gem::Dependency
37
+ name: patron
38
+ requirement: &2156829960 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ~>
42
+ - !ruby/object:Gem::Version
43
+ version: 0.4.18
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: *2156829960
47
+ - !ruby/object:Gem::Dependency
48
+ name: guard-spork
49
+ requirement: &2156829240 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ~>
53
+ - !ruby/object:Gem::Version
54
+ version: 0.6.0
55
+ type: :development
56
+ prerelease: false
57
+ version_requirements: *2156829240
58
+ - !ruby/object:Gem::Dependency
59
+ name: guard-rspec
60
+ requirement: &2156828580 !ruby/object:Gem::Requirement
61
+ none: false
62
+ requirements:
63
+ - - ~>
64
+ - !ruby/object:Gem::Version
65
+ version: 0.7.0
66
+ type: :development
67
+ prerelease: false
68
+ version_requirements: *2156828580
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: &2156827880 !ruby/object:Gem::Requirement
72
+ none: false
73
+ requirements:
74
+ - - ~>
75
+ - !ruby/object:Gem::Version
76
+ version: 2.9.0
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: *2156827880
80
+ - !ruby/object:Gem::Dependency
81
+ name: bourne
82
+ requirement: &2156827200 !ruby/object:Gem::Requirement
83
+ none: false
84
+ requirements:
85
+ - - ~>
86
+ - !ruby/object:Gem::Version
87
+ version: 1.1.2
88
+ type: :development
89
+ prerelease: false
90
+ version_requirements: *2156827200
91
+ - !ruby/object:Gem::Dependency
92
+ name: webmock
93
+ requirement: &2156826480 !ruby/object:Gem::Requirement
94
+ none: false
95
+ requirements:
96
+ - - ~>
97
+ - !ruby/object:Gem::Version
98
+ version: 1.8.6
99
+ type: :development
100
+ prerelease: false
101
+ version_requirements: *2156826480
102
+ - !ruby/object:Gem::Dependency
103
+ name: vcr
104
+ requirement: &2156825880 !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ~>
108
+ - !ruby/object:Gem::Version
109
+ version: 2.0.1
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: *2156825880
113
+ description: An opinionated Ruby-based Hypermedia API client.
114
+ email:
115
+ - dlindahl@customink.com
116
+ executables: []
117
+ extensions: []
118
+ extra_rdoc_files: []
119
+ files:
120
+ - .gitignore
121
+ - .rvmrc
122
+ - .travis.yml
123
+ - Gemfile
124
+ - Guardfile
125
+ - LICENSE
126
+ - README.md
127
+ - Rakefile
128
+ - frenetic.gemspec
129
+ - lib/frenetic.rb
130
+ - lib/frenetic/configuration.rb
131
+ - lib/frenetic/hal_json.rb
132
+ - lib/frenetic/hal_json/response_wrapper.rb
133
+ - lib/frenetic/resource.rb
134
+ - lib/frenetic/version.rb
135
+ - lib/recursive_open_struct.rb
136
+ - spec/fixtures/vcr_cassettes/description_success.yml
137
+ - spec/lib/frenetic/configuration_spec.rb
138
+ - spec/lib/frenetic/hal_json/response_wrapper_spec.rb
139
+ - spec/lib/frenetic/hal_json_spec.rb
140
+ - spec/lib/frenetic/resource_spec.rb
141
+ - spec/lib/frenetic_spec.rb
142
+ - spec/spec_helper.rb
143
+ homepage: http://dlindahl.github.com/frenetic/
144
+ licenses: []
145
+ post_install_message:
146
+ rdoc_options: []
147
+ require_paths:
148
+ - lib
149
+ required_ruby_version: !ruby/object:Gem::Requirement
150
+ none: false
151
+ requirements:
152
+ - - ! '>='
153
+ - !ruby/object:Gem::Version
154
+ version: '0'
155
+ required_rubygems_version: !ruby/object:Gem::Requirement
156
+ none: false
157
+ requirements:
158
+ - - ! '>'
159
+ - !ruby/object:Gem::Version
160
+ version: 1.3.1
161
+ requirements: []
162
+ rubyforge_project:
163
+ rubygems_version: 1.8.10
164
+ signing_key:
165
+ specification_version: 3
166
+ summary: Here lies a Ruby-based Hypermedia API client that expects HAL+JSON and makes
167
+ a lot of assumptions about your API.
168
+ test_files:
169
+ - spec/fixtures/vcr_cassettes/description_success.yml
170
+ - spec/lib/frenetic/configuration_spec.rb
171
+ - spec/lib/frenetic/hal_json/response_wrapper_spec.rb
172
+ - spec/lib/frenetic/hal_json_spec.rb
173
+ - spec/lib/frenetic/resource_spec.rb
174
+ - spec/lib/frenetic_spec.rb
175
+ - spec/spec_helper.rb