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.
- data/.document +5 -0
- data/.gitignore +5 -0
- data/LICENSE +20 -0
- data/README.rdoc +90 -0
- data/Rakefile +75 -0
- data/TODO.rdoc +3 -0
- data/VERSION +1 -0
- data/bin/restfully +80 -0
- data/examples/grid5000.rb +28 -0
- data/lib/restfully.rb +19 -0
- data/lib/restfully/collection.rb +63 -0
- data/lib/restfully/error.rb +4 -0
- data/lib/restfully/extensions.rb +41 -0
- data/lib/restfully/http.rb +9 -0
- data/lib/restfully/http/adapters/abstract_adapter.rb +30 -0
- data/lib/restfully/http/adapters/patron_adapter.rb +16 -0
- data/lib/restfully/http/adapters/rest_client_adapter.rb +31 -0
- data/lib/restfully/http/error.rb +20 -0
- data/lib/restfully/http/headers.rb +20 -0
- data/lib/restfully/http/request.rb +24 -0
- data/lib/restfully/http/response.rb +19 -0
- data/lib/restfully/link.rb +35 -0
- data/lib/restfully/parsing.rb +31 -0
- data/lib/restfully/resource.rb +117 -0
- data/lib/restfully/session.rb +61 -0
- data/lib/restfully/special_array.rb +5 -0
- data/lib/restfully/special_hash.rb +5 -0
- data/restfully.gemspec +99 -0
- data/spec/collection_spec.rb +93 -0
- data/spec/fixtures/grid5000-sites.json +489 -0
- data/spec/http/error_spec.rb +18 -0
- data/spec/http/headers_spec.rb +17 -0
- data/spec/http/request_spec.rb +45 -0
- data/spec/http/response_spec.rb +15 -0
- data/spec/http/rest_client_adapter_spec.rb +33 -0
- data/spec/link_spec.rb +58 -0
- data/spec/parsing_spec.rb +25 -0
- data/spec/resource_spec.rb +198 -0
- data/spec/restfully_spec.rb +13 -0
- data/spec/session_spec.rb +105 -0
- data/spec/spec_helper.rb +13 -0
- metadata +117 -0
@@ -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
|