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