crohr-restfully 0.1.1 → 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +70 -2
- data/Rakefile +38 -9
- data/TODO.rdoc +3 -0
- data/VERSION +1 -1
- data/bin/restfully +8 -8
- data/examples/grid5000.rb +2 -1
- data/lib/restfully/collection.rb +12 -16
- data/lib/restfully/error.rb +4 -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/http.rb +9 -0
- data/lib/restfully/parsing.rb +7 -7
- data/lib/restfully/resource.rb +15 -18
- data/lib/restfully/session.rb +41 -41
- data/lib/restfully.rb +12 -5
- data/restfully.gemspec +29 -5
- data/spec/collection_spec.rb +73 -17
- 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/parsing_spec.rb +25 -0
- data/spec/resource_spec.rb +109 -60
- data/spec/restfully_spec.rb +9 -1
- data/spec/session_spec.rb +97 -13
- data/spec/spec_helper.rb +3 -1
- metadata +27 -4
data/README.rdoc
CHANGED
@@ -1,8 +1,76 @@
|
|
1
1
|
= restfully
|
2
2
|
|
3
|
-
An attempt at dynamically providing "clever" wrappers on top of RESTful APIs that follow the HATEOAS
|
3
|
+
An attempt at dynamically providing "clever" wrappers on top of RESTful APIs that follow the principle of Hyperlinks As The Engine Of Application State (HATEOAS). For it to work, the API must follow certain conventions (to be explained later).
|
4
4
|
|
5
|
-
Alpha work
|
5
|
+
Alpha work.
|
6
|
+
|
7
|
+
== Installation
|
8
|
+
$ gem install restfully
|
9
|
+
|
10
|
+
== Usage
|
11
|
+
=== Command line
|
12
|
+
$ resfully api_base_uri relative_root_uri [-u username] [-p password]
|
13
|
+
|
14
|
+
e.g., for the Grid5000 API:
|
15
|
+
$ restfully https://api.grid5000.fr/sid /grid5000 -u username -p password
|
16
|
+
|
17
|
+
If the connection was successful, you should get a prompt. Call the +root+ function to see what are the available resources. <tt>@property</tt> means that you can call the +property+ method on the object. Other properties are available via the <tt>[]</tt> function.
|
18
|
+
e.g.:
|
19
|
+
irb(main):005:0> root
|
20
|
+
=> #<Restfully::Resource:0x90bdd4
|
21
|
+
------------ META ------------
|
22
|
+
@uri: "/grid5000"
|
23
|
+
@uid: "grid5000"
|
24
|
+
@type: "grid"
|
25
|
+
@environments: Restfully::Collection
|
26
|
+
@sites: Restfully::Collection
|
27
|
+
@version: Restfully::Resource
|
28
|
+
@versions: Restfully::Collection
|
29
|
+
------------ PROPERTIES ------------
|
30
|
+
"version" => "ee1f8111c8835496a9e4a6f7c9491c0238ee9827">
|
31
|
+
irb(main):006:0> root.uri
|
32
|
+
=> "/grid5000"
|
33
|
+
irb(main):007:0> root.sites
|
34
|
+
=> #<Restfully::Collection:0x8faaf2
|
35
|
+
------------ META ------------
|
36
|
+
@uri: "/grid5000/sites"
|
37
|
+
------------ PROPERTIES ------------
|
38
|
+
"lille" => Restfully::Resource
|
39
|
+
"grenoble" => Restfully::Resource
|
40
|
+
"toulouse" => Restfully::Resource
|
41
|
+
"sophia" => Restfully::Resource
|
42
|
+
"rennes" => Restfully::Resource
|
43
|
+
"lyon" => Restfully::Resource
|
44
|
+
"nancy" => Restfully::Resource
|
45
|
+
"bordeaux" => Restfully::Resource
|
46
|
+
"orsay" => Restfully::Resource>
|
47
|
+
irb(main):008:0> root['version']
|
48
|
+
=> "ee1f8111c8835496a9e4a6f7c9491c0238ee9827"
|
49
|
+
irb(main):009:0> root.version
|
50
|
+
=> #<Restfully::Resource:0x8facf0
|
51
|
+
------------ META ------------
|
52
|
+
@uri: "/grid5000/versions/ee1f8111c8835496a9e4a6f7c9491c0238ee9827"
|
53
|
+
@uid: "ee1f8111c8835496a9e4a6f7c9491c0238ee9827"
|
54
|
+
@type: "version"
|
55
|
+
@parent: Restfully::Resource
|
56
|
+
------------ PROPERTIES ------------
|
57
|
+
"author" => "Cyril Constantin <>"
|
58
|
+
"date" => "Fri, 11 Sep 2009 14:32:48 GMT"
|
59
|
+
"message" => "[environments] update of environments on sites">
|
60
|
+
|
61
|
+
You may also prefer to use a configuration file to avoid entering the command line options:
|
62
|
+
$ echo '
|
63
|
+
base_uri: https://api.grid5000.fr/sid
|
64
|
+
relative_root_uri: /grid5000
|
65
|
+
username: MYLOGIN
|
66
|
+
password: MYPASSWORD
|
67
|
+
' > ~/somewhere/api.grid5000.fr.yml
|
68
|
+
|
69
|
+
And then:
|
70
|
+
$ restfully -c ~/somewhere/api.grid5000.fr.yml
|
71
|
+
|
72
|
+
=== As a library
|
73
|
+
See the +examples+ directory for examples.
|
6
74
|
|
7
75
|
== Note on Patches/Pull Requests
|
8
76
|
|
data/Rakefile
CHANGED
@@ -10,8 +10,8 @@ begin
|
|
10
10
|
gem.email = "cyril.rohr@gmail.com"
|
11
11
|
gem.homepage = "http://github.com/crohr/restfully"
|
12
12
|
gem.authors = ["Cyril Rohr"]
|
13
|
-
gem.add_dependency "rest-client"
|
14
|
-
|
13
|
+
gem.add_dependency "rest-client", '>= 1.0'
|
14
|
+
gem.rubyforge_project = 'restfully'
|
15
15
|
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
16
16
|
end
|
17
17
|
|
@@ -31,16 +31,45 @@ Spec::Rake::SpecTask.new(:rcov) do |spec|
|
|
31
31
|
spec.rcov = true
|
32
32
|
end
|
33
33
|
|
34
|
+
# These are new tasks
|
35
|
+
begin
|
36
|
+
require 'rake/contrib/sshpublisher'
|
37
|
+
namespace :rubyforge do
|
34
38
|
|
39
|
+
desc "Release gem and RDoc documentation to RubyForge"
|
40
|
+
task :release => ["rubyforge:release:gem", "rubyforge:release:docs"]
|
35
41
|
|
42
|
+
namespace :release do
|
43
|
+
desc "Publish RDoc to RubyForge."
|
44
|
+
task :docs => [:rdoc] do
|
45
|
+
config = YAML.load(
|
46
|
+
File.read(File.expand_path('~/.rubyforge/user-config.yml'))
|
47
|
+
)
|
36
48
|
|
37
|
-
|
49
|
+
host = "#{config['username']}@rubyforge.org"
|
50
|
+
remote_dir = "/var/www/gforge-projects/the-perfect-gem/"
|
51
|
+
local_dir = 'rdoc'
|
38
52
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
rescue LoadError
|
43
|
-
task :yardoc do
|
44
|
-
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
53
|
+
Rake::SshDirPublisher.new(host, remote_dir, local_dir).upload
|
54
|
+
end
|
55
|
+
end
|
45
56
|
end
|
57
|
+
rescue LoadError
|
58
|
+
puts "Rake SshDirPublisher is unavailable or your rubyforge environment is not configured."
|
46
59
|
end
|
60
|
+
|
61
|
+
Jeweler::RubyforgeTasks.new do |rubyforge|
|
62
|
+
rubyforge.doc_task = "rdoc"
|
63
|
+
end
|
64
|
+
|
65
|
+
|
66
|
+
task :default => :spec
|
67
|
+
|
68
|
+
require 'rake/rdoctask'
|
69
|
+
Rake::RDocTask.new do |rdoc|
|
70
|
+
rdoc.rdoc_dir = 'rdoc'
|
71
|
+
rdoc.title = 'restfully'
|
72
|
+
rdoc.options << '--line-numbers' << '--inline-source'
|
73
|
+
rdoc.rdoc_files.include('README*')
|
74
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
75
|
+
end
|
data/TODO.rdoc
ADDED
@@ -0,0 +1,3 @@
|
|
1
|
+
* Detects methods allowed on a resource using the Allow HTTP header, and generate corresponding wrappers.
|
2
|
+
* On a 406, detects accepted content based on a (custom) HTTP header, and retry (if possible) with another Accept header for which a parser is available.
|
3
|
+
* Investigate the possibility of using a :include => {} when loading resources or collection so that Restfully prefetches the included associations (using threads or events).
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.
|
1
|
+
0.2.1
|
data/bin/restfully
CHANGED
@@ -10,7 +10,7 @@ require 'pp'
|
|
10
10
|
|
11
11
|
@options = {}
|
12
12
|
option_parser = OptionParser.new do |opts|
|
13
|
-
opts.banner = "Usage: restfully
|
13
|
+
opts.banner = "Usage: restfully base_uri [root_path] [options]"
|
14
14
|
|
15
15
|
opts.on("-u=", "--username=", "Sets the username") do |u|
|
16
16
|
@options['username'] = u
|
@@ -37,17 +37,17 @@ end
|
|
37
37
|
|
38
38
|
option_parser.parse!
|
39
39
|
|
40
|
-
@
|
41
|
-
@
|
40
|
+
@base_uri = ARGV.shift
|
41
|
+
@root_path = ARGV.shift || "/"
|
42
42
|
|
43
43
|
if (config_filename = @options.delete('configuration_file')) && File.exists?(File.expand_path(config_filename))
|
44
44
|
config = YAML.load_file(File.expand_path(config_filename))
|
45
|
-
@
|
46
|
-
@
|
45
|
+
@base_uri = config.delete('base_uri') || @base_uri
|
46
|
+
@root_path = config.delete('root_path') || @root_path
|
47
47
|
@options.merge!(config)
|
48
48
|
end
|
49
49
|
|
50
|
-
unless @
|
50
|
+
unless @base_uri
|
51
51
|
$stderr.puts option_parser.help
|
52
52
|
exit(-1)
|
53
53
|
else
|
@@ -63,10 +63,10 @@ else
|
|
63
63
|
end
|
64
64
|
|
65
65
|
def session
|
66
|
-
@session ||= Restfully::Session.new(@
|
66
|
+
@session ||= Restfully::Session.new(@base_uri, @options.merge('root_path' => @root_path, 'logger' => @logger))
|
67
67
|
end
|
68
68
|
def root
|
69
|
-
@root ||= Restfully::Resource.new(session.
|
69
|
+
@root ||= Restfully::Resource.new(session.root_path, session).load
|
70
70
|
end
|
71
71
|
|
72
72
|
root # preloads
|
data/examples/grid5000.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'rubygems'
|
2
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
2
3
|
require 'pp'
|
3
4
|
|
4
5
|
require File.dirname(__FILE__)+'/../lib/restfully'
|
@@ -9,7 +10,7 @@ logger.level = Logger::INFO
|
|
9
10
|
# Restfully.adapter = Restfully::HTTP::RestClientAdapter
|
10
11
|
# Restfully.adapter = Patron::Session
|
11
12
|
RestClient.log = 'stdout'
|
12
|
-
Restfully::Session.new('https://localhost:3443/sid', '
|
13
|
+
Restfully::Session.new('https://localhost:3443/sid', 'root_path' => '/grid5000', 'logger' => logger) do |grid, session|
|
13
14
|
grid_stats = {'hardware' => {}, 'system' => {}}
|
14
15
|
grid.sites.each do |site_uid, site|
|
15
16
|
site_stats = site.status.inject({'hardware' => {}, 'system' => {}}) {|accu, (node_uid, node_status)|
|
data/lib/restfully/collection.rb
CHANGED
@@ -6,10 +6,11 @@ module Restfully
|
|
6
6
|
attr_reader :state, :raw, :uri, :session, :title
|
7
7
|
|
8
8
|
def initialize(uri, session, options = {})
|
9
|
+
options = options.symbolize_keys
|
9
10
|
@uri = uri
|
10
|
-
@title = options[
|
11
|
+
@title = options[:title]
|
11
12
|
@session = session
|
12
|
-
@raw = options[
|
13
|
+
@raw = options[:raw]
|
13
14
|
@state = :unloaded
|
14
15
|
@items = {}
|
15
16
|
super(@items)
|
@@ -19,25 +20,20 @@ module Restfully
|
|
19
20
|
|
20
21
|
def load(options = {})
|
21
22
|
options = options.symbolize_keys
|
22
|
-
force_reload = options.delete(:reload) ||
|
23
|
-
|
24
|
-
if options.has_key?(:query)
|
25
|
-
path, query_string = uri.split("?")
|
26
|
-
query_string ||= ""
|
27
|
-
query_string.concat(options.delete(:query).to_params)
|
28
|
-
path = "#{path}?#{query_string}"
|
29
|
-
force_reload = true
|
30
|
-
end
|
31
|
-
if loaded? && force_reload == false
|
23
|
+
force_reload = !!options.delete(:reload) || options.has_key?(:query)
|
24
|
+
if loaded? && !force_reload
|
32
25
|
self
|
33
|
-
else
|
34
|
-
@raw = session.get(path, options) if raw.nil? || force_reload
|
26
|
+
else
|
35
27
|
@items.clear
|
28
|
+
if raw.nil? || force_reload
|
29
|
+
response = session.get(uri, options)
|
30
|
+
@raw = response.body
|
31
|
+
end
|
36
32
|
raw.each do |key, value|
|
37
33
|
next if key == 'links'
|
38
34
|
self_link = (value['links'] || []).map{|link| Link.new(link)}.detect{|link| link.self?}
|
39
35
|
if self_link && self_link.valid?
|
40
|
-
@items.store(key, Resource.new(self_link.href, session,
|
36
|
+
@items.store(key, Resource.new(self_link.href, session, :raw => value).load)
|
41
37
|
else
|
42
38
|
session.logger.warn "Resource #{key} does not have a 'self' link. skipped."
|
43
39
|
end
|
@@ -47,7 +43,7 @@ module Restfully
|
|
47
43
|
end
|
48
44
|
end
|
49
45
|
|
50
|
-
def inspect(options = {:space => "
|
46
|
+
def inspect(options = {:space => "\t"})
|
51
47
|
output = "#<#{self.class}:0x#{self.object_id.to_s(16)}"
|
52
48
|
if loaded?
|
53
49
|
output += "\n#{options[:space]}------------ META ------------"
|
@@ -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
|
data/lib/restfully/parsing.rb
CHANGED
@@ -2,23 +2,23 @@ require 'json'
|
|
2
2
|
|
3
3
|
module Restfully
|
4
4
|
|
5
|
-
class ParserNotFound < RestfullyError; end
|
6
|
-
|
7
5
|
module Parsing
|
8
|
-
|
6
|
+
|
7
|
+
class ParserNotFound < Restfully::Error; end
|
8
|
+
def unserialize(object, options = {})
|
9
9
|
content_type = options[:content_type]
|
10
|
-
content_type ||= object.headers[
|
10
|
+
content_type ||= object.headers['Content-Type'] if object.respond_to?(:headers)
|
11
11
|
case content_type
|
12
12
|
when /^application\/json/i
|
13
13
|
JSON.parse(object)
|
14
14
|
else
|
15
|
-
raise ParserNotFound
|
15
|
+
raise ParserNotFound.new("Content-Type '#{content_type}' is not supported. Cannot parse the given object.")
|
16
16
|
end
|
17
17
|
end
|
18
18
|
|
19
|
-
def
|
19
|
+
def serialize(object, options = {})
|
20
20
|
content_type = options[:content_type]
|
21
|
-
content_type ||= object.headers[
|
21
|
+
content_type ||= object.headers['Content-Type'] if object.respond_to?(:headers)
|
22
22
|
case content_type
|
23
23
|
when /^application\/json/i
|
24
24
|
JSON.dump(object)
|
data/lib/restfully/resource.rb
CHANGED
@@ -10,9 +10,10 @@ module Restfully
|
|
10
10
|
attr_reader :uri, :session, :state, :raw, :uid, :associations, :type
|
11
11
|
|
12
12
|
def initialize(uri, session, options = {})
|
13
|
+
options = options.symbolize_keys
|
13
14
|
@uri = uri
|
14
15
|
@session = session
|
15
|
-
@raw = options[
|
16
|
+
@raw = options[:raw]
|
16
17
|
@state = :unloaded
|
17
18
|
@attributes = {}
|
18
19
|
super(@attributes)
|
@@ -32,21 +33,16 @@ module Restfully
|
|
32
33
|
|
33
34
|
def load(options = {})
|
34
35
|
options = options.symbolize_keys
|
35
|
-
force_reload = options.delete(:reload) ||
|
36
|
-
|
37
|
-
if options.has_key?(:query)
|
38
|
-
path, query_string = uri.split("?")
|
39
|
-
query_string ||= ""
|
40
|
-
query_string.concat(options.delete(:query).to_params)
|
41
|
-
path = "#{path}?#{query_string}"
|
42
|
-
force_reload = true
|
43
|
-
end
|
44
|
-
if loaded? && force_reload == false
|
36
|
+
force_reload = !!options.delete(:reload) || options.has_key?(:query)
|
37
|
+
if loaded? && !force_reload
|
45
38
|
self
|
46
|
-
else
|
47
|
-
@raw = session.get(path, options) if raw.nil? || force_reload
|
39
|
+
else
|
48
40
|
@associations.clear
|
49
41
|
@attributes.clear
|
42
|
+
if raw.nil? || force_reload
|
43
|
+
response = session.get(uri, options)
|
44
|
+
@raw = response.body
|
45
|
+
end
|
50
46
|
(raw['links'] || []).each{|link| define_link(Link.new(link))}
|
51
47
|
raw.each do |key, value|
|
52
48
|
case key
|
@@ -73,7 +69,7 @@ module Restfully
|
|
73
69
|
@associations.has_key?(method.to_s) || super(method, *args)
|
74
70
|
end
|
75
71
|
|
76
|
-
def inspect(options = {:space => "
|
72
|
+
def inspect(options = {:space => "\t"})
|
77
73
|
output = "#<#{self.class}:0x#{self.object_id.to_s(16)}"
|
78
74
|
if loaded?
|
79
75
|
output += "\n#{options[:space]}------------ META ------------"
|
@@ -102,14 +98,15 @@ module Restfully
|
|
102
98
|
when 'collection'
|
103
99
|
raw_included = link.resolved? ? raw[link.title] : nil
|
104
100
|
@associations[link.title] = Collection.new(link.href, session,
|
105
|
-
|
106
|
-
|
101
|
+
:raw => raw_included,
|
102
|
+
:title => link.title)
|
107
103
|
when 'member'
|
108
104
|
raw_included = link.resolved? ? raw[link.title] : nil
|
109
105
|
@associations[link.title] = Resource.new(link.href, session,
|
110
|
-
|
106
|
+
:title => link.title,
|
107
|
+
:raw => raw_included)
|
111
108
|
when 'self'
|
112
|
-
|
109
|
+
# we do nothing
|
113
110
|
end
|
114
111
|
else
|
115
112
|
session.logger.warn link.errors.join("\n")
|
data/lib/restfully/session.rb
CHANGED
@@ -1,11 +1,8 @@
|
|
1
1
|
require 'uri'
|
2
2
|
require 'logger'
|
3
|
-
require 'restclient'
|
4
3
|
require 'restfully/parsing'
|
5
4
|
|
6
5
|
module Restfully
|
7
|
-
class Error < StandardError; end
|
8
|
-
class HTTPError < Error; end
|
9
6
|
class NullLogger
|
10
7
|
def method_missing(method, *args)
|
11
8
|
nil
|
@@ -13,49 +10,52 @@ module Restfully
|
|
13
10
|
end
|
14
11
|
class Session
|
15
12
|
include Parsing
|
16
|
-
attr_reader :
|
13
|
+
attr_reader :base_uri, :root_path, :logger, :connection, :root, :default_headers
|
17
14
|
|
18
15
|
# TODO: use CacheableResource
|
19
|
-
def initialize(
|
20
|
-
options = options.
|
21
|
-
@
|
22
|
-
@
|
23
|
-
@logger = options
|
24
|
-
@
|
25
|
-
@
|
26
|
-
|
27
|
-
|
28
|
-
# @connection.timeout = 10
|
29
|
-
# @connection.base_url = base_url
|
30
|
-
# @connection.headers['User-Agent'] = 'restfully'
|
31
|
-
# @connection.insecure = true
|
32
|
-
# @connection.username = @user
|
33
|
-
# @connection.password = @password
|
34
|
-
@connection = RestClient::Resource.new(base_url || '', :user => @username, :password => @password)
|
35
|
-
yield Resource.new(root, self).load, self if block_given?
|
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?
|
36
25
|
end
|
37
26
|
|
38
|
-
# TODO: uniformize headers hash (should accept symbols and strings in any capitalization format)
|
39
27
|
# TODO: inspect response headers to determine which methods are available
|
40
|
-
def get(path,
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
#
|
54
|
-
|
55
|
-
|
56
|
-
|
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)
|
57
45
|
end
|
58
|
-
|
59
|
-
|
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
60
|
end
|
61
61
|
end
|