restfully 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
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