restfully 0.2.1

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 (42) hide show
  1. data/.document +5 -0
  2. data/.gitignore +5 -0
  3. data/LICENSE +20 -0
  4. data/README.rdoc +90 -0
  5. data/Rakefile +75 -0
  6. data/TODO.rdoc +3 -0
  7. data/VERSION +1 -0
  8. data/bin/restfully +80 -0
  9. data/examples/grid5000.rb +28 -0
  10. data/lib/restfully.rb +19 -0
  11. data/lib/restfully/collection.rb +63 -0
  12. data/lib/restfully/error.rb +4 -0
  13. data/lib/restfully/extensions.rb +41 -0
  14. data/lib/restfully/http.rb +9 -0
  15. data/lib/restfully/http/adapters/abstract_adapter.rb +30 -0
  16. data/lib/restfully/http/adapters/patron_adapter.rb +16 -0
  17. data/lib/restfully/http/adapters/rest_client_adapter.rb +31 -0
  18. data/lib/restfully/http/error.rb +20 -0
  19. data/lib/restfully/http/headers.rb +20 -0
  20. data/lib/restfully/http/request.rb +24 -0
  21. data/lib/restfully/http/response.rb +19 -0
  22. data/lib/restfully/link.rb +35 -0
  23. data/lib/restfully/parsing.rb +31 -0
  24. data/lib/restfully/resource.rb +117 -0
  25. data/lib/restfully/session.rb +61 -0
  26. data/lib/restfully/special_array.rb +5 -0
  27. data/lib/restfully/special_hash.rb +5 -0
  28. data/restfully.gemspec +99 -0
  29. data/spec/collection_spec.rb +93 -0
  30. data/spec/fixtures/grid5000-sites.json +489 -0
  31. data/spec/http/error_spec.rb +18 -0
  32. data/spec/http/headers_spec.rb +17 -0
  33. data/spec/http/request_spec.rb +45 -0
  34. data/spec/http/response_spec.rb +15 -0
  35. data/spec/http/rest_client_adapter_spec.rb +33 -0
  36. data/spec/link_spec.rb +58 -0
  37. data/spec/parsing_spec.rb +25 -0
  38. data/spec/resource_spec.rb +198 -0
  39. data/spec/restfully_spec.rb +13 -0
  40. data/spec/session_spec.rb +105 -0
  41. data/spec/spec_helper.rb +13 -0
  42. metadata +117 -0
@@ -0,0 +1,9 @@
1
+ require 'restfully/http/headers'
2
+ require 'restfully/http/error'
3
+ require 'restfully/http/request'
4
+ require 'restfully/http/response'
5
+ require 'restfully/http/adapters/abstract_adapter'
6
+ module Restfully
7
+ module HTTP
8
+ end
9
+ end
@@ -0,0 +1,30 @@
1
+ module Restfully
2
+ module HTTP
3
+ module Adapters
4
+
5
+ class AbstractAdapter
6
+ attr_reader :logger, :options
7
+ def initialize(base_uri, options = {})
8
+ @options = options.symbolize_keys
9
+ @logger = @options.delete(:logger) || Restfully::NullLogger.new
10
+ @base_uri = base_uri
11
+ logger.debug "base_uri = #{base_uri.inspect}, options = #{options.inspect}."
12
+ end
13
+
14
+ def get(request)
15
+ raise NotImplementedError, "GET is not supported by your adapter."
16
+ end
17
+ def post(request)
18
+ raise NotImplementedError, "POST is not supported by your adapter."
19
+ end
20
+ def put(request)
21
+ raise NotImplementedError, "PUT is not supported by your adapter."
22
+ end
23
+ def delete(request)
24
+ raise NotImplementedError, "DELETEis not supported by your adapter."
25
+ end
26
+ end
27
+
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,16 @@
1
+ require 'restfully/http/adapters/abstract_adapter'
2
+ require 'patron'
3
+
4
+ module Restfully
5
+ module HTTP
6
+ module Adapters
7
+ class PatronAdapter < AbstractAdapter
8
+
9
+ def initialize(base_url, options = {})
10
+ super(base_url, options)
11
+ end
12
+
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,31 @@
1
+ require 'restfully/http/adapters/abstract_adapter'
2
+ require 'restclient'
3
+
4
+ module Restfully
5
+ module HTTP
6
+ module Adapters
7
+ class RestClientAdapter < AbstractAdapter
8
+ def initialize(base_uri, options = {})
9
+ super(base_uri, options)
10
+ @options[:user] = @options.delete(:username)
11
+ end
12
+ def get(request)
13
+ begin
14
+ resource = RestClient::Resource.new(request.uri.to_s, @options)
15
+ response = resource.get(request.headers)
16
+ headers = response.headers
17
+ body = response.to_s
18
+ headers.delete(:status)
19
+ status = response.code
20
+ rescue RestClient::ExceptionWithResponse => e
21
+ body = e.http_body
22
+ headers = e.response.to_hash
23
+ status = e.http_code
24
+ end
25
+ Response.new(status, headers, body)
26
+ end
27
+ end
28
+
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ module Restfully
2
+ module HTTP
3
+ class Error < Restfully::Error
4
+ attr_reader :response
5
+ def initialize(response)
6
+ @response = response
7
+ if response.body.kind_of?(Hash)
8
+ message = "#{response.status} #{response.body['title']}. #{response.body['message']}"
9
+ else
10
+ message = response.body
11
+ end
12
+ super(message)
13
+ end
14
+ end
15
+ class ClientError < Restfully::HTTP::Error; end
16
+ class ServerError < Restfully::HTTP::Error; end
17
+ end
18
+ end
19
+
20
+
@@ -0,0 +1,20 @@
1
+ module Restfully
2
+ module HTTP
3
+ module Headers
4
+ def sanitize_http_headers(headers = {})
5
+ sanitized_headers = {}
6
+ headers.each do |key, value|
7
+ sanitized_key = key.to_s.downcase.gsub(/[_-]/, ' ').split(' ').map{|word| word.capitalize}.join("-")
8
+ sanitized_value = case value
9
+ when Array
10
+ value.join(", ")
11
+ else
12
+ value
13
+ end
14
+ sanitized_headers[sanitized_key] = sanitized_value
15
+ end
16
+ sanitized_headers
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,24 @@
1
+ require 'uri'
2
+ module Restfully
3
+ module HTTP
4
+ class Request
5
+ include Headers
6
+ attr_reader :headers, :body, :uri
7
+ attr_accessor :retries
8
+ def initialize(url, options = {})
9
+ options = options.symbolize_keys
10
+ @uri = url.kind_of?(URI) ? url : URI.parse(url)
11
+ @headers = sanitize_http_headers(options.delete(:headers) || {})
12
+ if query = options.delete(:query)
13
+ @uri.query = [@uri.query, query.to_params].compact.join("&")
14
+ end
15
+ @body = body
16
+ @retries = 0
17
+ end
18
+
19
+ def add_headers(headers = {})
20
+ @headers.merge!(sanitize_http_headers(headers || {}))
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ module Restfully
2
+ module HTTP
3
+ # Container for an HTTP Response. Has <tt>status</tt>, <tt>headers</tt> and <tt>body</tt> properties.
4
+ # The body is automatically parsed into a ruby object based on the response's <tt>Content-Type</tt> header.
5
+ class Response
6
+ include Headers, Restfully::Parsing
7
+ attr_reader :status, :headers
8
+ def initialize(status, headers, body)
9
+ @status = status.to_i
10
+ @headers = sanitize_http_headers(headers)
11
+ @body = (body.nil? || body.empty?) ? nil : body.to_s
12
+ end
13
+
14
+ def body
15
+ @unserialized_body ||= unserialize(@body, :content_type => @headers['Content-Type']) unless @body.nil?
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,35 @@
1
+ module Restfully
2
+ class Link
3
+
4
+ VALID_RELATIONSHIPS = %w{member parent collection self alternate}
5
+ RELATIONSHIPS_REQUIRING_TITLE = %w{collection member}
6
+
7
+ attr_reader :rel, :title, :href, :errors
8
+
9
+ def initialize(attributes = {})
10
+ @rel = attributes['rel']
11
+ @title = attributes['title']
12
+ @href = attributes['href']
13
+ @resolvable = attributes['resolvable'] || false
14
+ @resolved = attributes['resolved'] || false
15
+ end
16
+
17
+ def resolvable?; @resolvable == true; end
18
+ def resolved?; @resolved == true; end
19
+ def self?; @rel == 'self'; end
20
+
21
+ def valid?
22
+ @errors = []
23
+ if href.nil? || href.empty?
24
+ errors << "href cannot be empty."
25
+ end
26
+ unless VALID_RELATIONSHIPS.include?(rel)
27
+ errors << "#{rel} is not a valid link relationship."
28
+ end
29
+ if (!title || title.empty?) && RELATIONSHIPS_REQUIRING_TITLE.include?(rel)
30
+ errors << "#{rel} #{href} has no title."
31
+ end
32
+ errors.empty?
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ require 'json'
2
+
3
+ module Restfully
4
+
5
+ module Parsing
6
+
7
+ class ParserNotFound < Restfully::Error; end
8
+ def unserialize(object, options = {})
9
+ content_type = options[:content_type]
10
+ content_type ||= object.headers['Content-Type'] if object.respond_to?(:headers)
11
+ case content_type
12
+ when /^application\/json/i
13
+ JSON.parse(object)
14
+ else
15
+ raise ParserNotFound.new("Content-Type '#{content_type}' is not supported. Cannot parse the given object.")
16
+ end
17
+ end
18
+
19
+ def serialize(object, options = {})
20
+ content_type = options[:content_type]
21
+ content_type ||= object.headers['Content-Type'] if object.respond_to?(:headers)
22
+ case content_type
23
+ when /^application\/json/i
24
+ JSON.dump(object)
25
+ else
26
+ raise ParserNotFound, [object, content_type]
27
+ end
28
+ end
29
+
30
+ end
31
+ end
@@ -0,0 +1,117 @@
1
+ require 'delegate'
2
+
3
+ module Restfully
4
+
5
+ # Suppose that the load method has been called on the resource before trying to access its attributes or associations
6
+
7
+ class Resource < DelegateClass(Hash)
8
+
9
+ undef :type
10
+ attr_reader :uri, :session, :state, :raw, :uid, :associations, :type
11
+
12
+ def initialize(uri, session, options = {})
13
+ options = options.symbolize_keys
14
+ @uri = uri
15
+ @session = session
16
+ @raw = options[:raw]
17
+ @state = :unloaded
18
+ @attributes = {}
19
+ super(@attributes)
20
+ @associations = {}
21
+ end
22
+
23
+ def loaded?; @state == :loaded; end
24
+
25
+ def method_missing(method, *args)
26
+ if association = @associations[method.to_s]
27
+ session.logger.debug "Loading association #{method}, args=#{args.inspect}"
28
+ association.load(*args)
29
+ else
30
+ super(method, *args)
31
+ end
32
+ end
33
+
34
+ def load(options = {})
35
+ options = options.symbolize_keys
36
+ force_reload = !!options.delete(:reload) || options.has_key?(:query)
37
+ if loaded? && !force_reload
38
+ self
39
+ else
40
+ @associations.clear
41
+ @attributes.clear
42
+ if raw.nil? || force_reload
43
+ response = session.get(uri, options)
44
+ @raw = response.body
45
+ end
46
+ (raw['links'] || []).each{|link| define_link(Link.new(link))}
47
+ raw.each do |key, value|
48
+ case key
49
+ when 'links' then next
50
+ when 'uid' then @uid = value
51
+ when 'type' then @type = value
52
+ else
53
+ case value
54
+ when Hash
55
+ @attributes.store(key, SpecialHash.new.replace(value)) unless @associations.has_key?(key)
56
+ when Array
57
+ @attributes.store(key, SpecialArray.new(value))
58
+ else
59
+ @attributes.store(key, value)
60
+ end
61
+ end
62
+ end
63
+ @state = :loaded
64
+ self
65
+ end
66
+ end
67
+
68
+ def respond_to?(method, *args)
69
+ @associations.has_key?(method.to_s) || super(method, *args)
70
+ end
71
+
72
+ def inspect(options = {:space => "\t"})
73
+ output = "#<#{self.class}:0x#{self.object_id.to_s(16)}"
74
+ if loaded?
75
+ output += "\n#{options[:space]}------------ META ------------"
76
+ output += "\n#{options[:space]}@uri: #{uri.inspect}"
77
+ output += "\n#{options[:space]}@uid: #{uid.inspect}"
78
+ output += "\n#{options[:space]}@type: #{type.inspect}"
79
+ @associations.each do |title, assoc|
80
+ output += "\n#{options[:space]}@#{title}: #{assoc.class.name}"
81
+ end
82
+ unless @attributes.empty?
83
+ output += "\n#{options[:space]}------------ PROPERTIES ------------"
84
+ @attributes.each do |key, value|
85
+ output += "\n#{options[:space]}#{key.inspect} => #{value.inspect}"
86
+ end
87
+ end
88
+ end
89
+ output += ">"
90
+ end
91
+
92
+ protected
93
+ def define_link(link)
94
+ if link.valid?
95
+ case link.rel
96
+ when 'parent'
97
+ @associations['parent'] = Resource.new(link.href, session)
98
+ when 'collection'
99
+ raw_included = link.resolved? ? raw[link.title] : nil
100
+ @associations[link.title] = Collection.new(link.href, session,
101
+ :raw => raw_included,
102
+ :title => link.title)
103
+ when 'member'
104
+ raw_included = link.resolved? ? raw[link.title] : nil
105
+ @associations[link.title] = Resource.new(link.href, session,
106
+ :title => link.title,
107
+ :raw => raw_included)
108
+ when 'self'
109
+ # we do nothing
110
+ end
111
+ else
112
+ session.logger.warn link.errors.join("\n")
113
+ end
114
+ end
115
+
116
+ end # class Resource
117
+ end # module Restfully
@@ -0,0 +1,61 @@
1
+ require 'uri'
2
+ require 'logger'
3
+ require 'restfully/parsing'
4
+
5
+ module Restfully
6
+ class NullLogger
7
+ def method_missing(method, *args)
8
+ nil
9
+ end
10
+ end
11
+ class Session
12
+ include Parsing
13
+ attr_reader :base_uri, :root_path, :logger, :connection, :root, :default_headers
14
+
15
+ # TODO: use CacheableResource
16
+ def initialize(base_uri, options = {})
17
+ options = options.symbolize_keys
18
+ @base_uri = base_uri
19
+ @root_path = options.delete(:root_path) || '/'
20
+ @logger = options.delete(:logger) || NullLogger.new
21
+ @default_headers = options.delete(:default_headers) || {'Accept' => 'application/json'}
22
+ @connection = Restfully.adapter.new(@base_uri, options.merge(:logger => @logger))
23
+ @root = Resource.new(URI.parse(@root_path), self)
24
+ yield @root.load, self if block_given?
25
+ end
26
+
27
+ # TODO: inspect response headers to determine which methods are available
28
+ def get(path, options = {})
29
+ path = path.to_s
30
+ options = options.symbolize_keys
31
+ uri = URI.parse(base_uri)
32
+ path_uri = URI.parse(path)
33
+ # if the given path is complete URL, forget the base_uri, else append the path to the base_uri
34
+ unless path_uri.scheme.nil?
35
+ uri = path_uri
36
+ else
37
+ uri.path << path
38
+ end
39
+ request = HTTP::Request.new(uri, :headers => options.delete(:headers) || {}, :query => options.delete(:query) || {})
40
+ request.add_headers(@default_headers) unless @default_headers.empty?
41
+ logger.info "GET #{request.uri}, #{request.headers.inspect}"
42
+ response = connection.get(request)
43
+ logger.debug "Response to GET #{request.uri}: #{response.headers.inspect}"
44
+ response = deal_with_eventual_errors(response, request)
45
+ end
46
+
47
+ protected
48
+ def deal_with_eventual_errors(response, request)
49
+ case response.status
50
+ when 400..499
51
+ # should retry on 406 with another Accept header
52
+ raise Restfully::HTTP::ClientError, response
53
+ when 500..599
54
+ raise Restfully::HTTP::ServerError, response
55
+ else
56
+ response
57
+ end
58
+ end
59
+
60
+ end
61
+ end
@@ -0,0 +1,5 @@
1
+ module Restfully
2
+ # To be used to provide facilities such as hash-like lookups
3
+ class SpecialArray < Array
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module Restfully
2
+ # To be used later
3
+ class SpecialHash < Hash
4
+ end
5
+ end