demetriusnunes-clouder 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- data/History.txt +7 -0
- data/Manifest.txt +13 -0
- data/PostInstall.txt +0 -0
- data/README.rdoc +136 -0
- data/Rakefile +28 -0
- data/lib/clouder.rb +31 -0
- data/lib/clouder/entity.rb +255 -0
- data/lib/clouder/rest.rb +80 -0
- data/spec/clouder_spec.rb +17 -0
- data/spec/config.ru +4 -0
- data/spec/entity_spec.rb +303 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +10 -0
- metadata +89 -0
data/History.txt
ADDED
data/Manifest.txt
ADDED
data/PostInstall.txt
ADDED
File without changes
|
data/README.rdoc
ADDED
@@ -0,0 +1,136 @@
|
|
1
|
+
= clouder
|
2
|
+
|
3
|
+
* http://github.com/demetriusnunes/clouder
|
4
|
+
|
5
|
+
== DESCRIPTION:
|
6
|
+
|
7
|
+
A Ruby client library for accessing CloudKit (http://getcloudkit.com)
|
8
|
+
RESTful repositories using simple Ruby objects.
|
9
|
+
|
10
|
+
== FEATURES/PROBLEMS:
|
11
|
+
|
12
|
+
At this time, *clouder* implements all methods in CloudKit API (as seen
|
13
|
+
in the checklist at the requirements section) and is good to be used in
|
14
|
+
most basic cases.
|
15
|
+
|
16
|
+
Clouder::Entity is a subclass of OStruct, but that might change in
|
17
|
+
the future.
|
18
|
+
|
19
|
+
== SYNOPSIS:
|
20
|
+
|
21
|
+
Here is some sample code of how to use *clouder*, assuming that you
|
22
|
+
have a CloudKit server running at <tt>http://localhost:9292/</tt> and
|
23
|
+
exposing "*notes*".
|
24
|
+
|
25
|
+
=== Entity declaration
|
26
|
+
|
27
|
+
class Note < Clouder::Entity
|
28
|
+
uri "http://localhost:9292/notes"
|
29
|
+
end
|
30
|
+
|
31
|
+
=== Creating new documents
|
32
|
+
|
33
|
+
n = Note.new
|
34
|
+
n.new? # true
|
35
|
+
n.text = "My first note"
|
36
|
+
n.author = "John Doe"
|
37
|
+
n.save
|
38
|
+
n.new? # false
|
39
|
+
|
40
|
+
# Short alternative
|
41
|
+
n = Note.create(:text => "My first note", :author => "John Doe")
|
42
|
+
|
43
|
+
=== Retrieving a single document
|
44
|
+
|
45
|
+
n = Note.get("d35bfa70-cca0-012b-cd41-0016cb91f13d") # some valid id
|
46
|
+
puts n.text, n.author
|
47
|
+
|
48
|
+
=== Deleting documents
|
49
|
+
|
50
|
+
n = Note.get("d35bfa70-cca0-012b-cd41-0016cb91f13d") # some valid id
|
51
|
+
n.delete
|
52
|
+
n.deleted? # true
|
53
|
+
|
54
|
+
=== Retrieving collections
|
55
|
+
|
56
|
+
# This retrieves only the URIs
|
57
|
+
uris = Note.all
|
58
|
+
uris.each { |uri|
|
59
|
+
n = Note.get(uri)
|
60
|
+
puts n.text, n.author
|
61
|
+
}
|
62
|
+
|
63
|
+
# Using offset and limit
|
64
|
+
uris = Note.all(:offset => 5, :limit => 10)
|
65
|
+
|
66
|
+
# To retrieve full documents, use the :resolved option
|
67
|
+
notes = Note.all(:resolved => true)
|
68
|
+
|
69
|
+
# You can combine all the options
|
70
|
+
notes = Note.all(:resolved => true, :offset => 3, :limit => 50)
|
71
|
+
|
72
|
+
=== Retrieving older versions of a document
|
73
|
+
|
74
|
+
n = Note.get("d35bfa70-cca0-012b-cd41-0016cb91f13d") # some valid id
|
75
|
+
|
76
|
+
# This retrieves only the URIs
|
77
|
+
# - you can also use offset and limit options
|
78
|
+
older_uris = n.versions(:limit => 5)
|
79
|
+
|
80
|
+
# To retrieve full documents, use the :resolved option
|
81
|
+
older_notes = n.versions(:resolved)
|
82
|
+
|
83
|
+
=== Inspecting some metadata for a repository
|
84
|
+
|
85
|
+
# Which collections are available for this server?
|
86
|
+
uris = Clouder.collections("http://localhost:9292/")
|
87
|
+
uris # [ "notes" ]
|
88
|
+
|
89
|
+
# Retrieving only the headers for a valid URI
|
90
|
+
headers = Clouder.head("http://localhost:9292/notes")
|
91
|
+
|
92
|
+
== REQUIREMENTS:
|
93
|
+
|
94
|
+
CloudKit API (http://getcloudkit.com/rest-api.html) Checklist:
|
95
|
+
|
96
|
+
* GET /cloudkit-meta - OK!
|
97
|
+
* OPTIONS /%uri% - OK!
|
98
|
+
* GET /%collection% - OK!
|
99
|
+
* GET /%collection%/_resolved - OK!
|
100
|
+
* GET /%collection%/%uuid% - OK!
|
101
|
+
* GET /%collection%/%uuid%/versions - OK!
|
102
|
+
* GET /%collection%/%uuid%/versions/_resolved - OK!
|
103
|
+
* GET /%collection%/%uuid%/versions/%etag% - OK!
|
104
|
+
* POST /%collection% - OK!
|
105
|
+
* PUT /%collection%/%uuid% - OK!
|
106
|
+
* DELETE /%collection%/%uuid% - OK!
|
107
|
+
* HEAD /%uri% - OK!
|
108
|
+
|
109
|
+
== INSTALL:
|
110
|
+
|
111
|
+
* sudo gem install clouder
|
112
|
+
|
113
|
+
== LICENSE:
|
114
|
+
|
115
|
+
(The MIT License)
|
116
|
+
|
117
|
+
Copyright (c) 2009 Demetrius Nunes
|
118
|
+
|
119
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
120
|
+
a copy of this software and associated documentation files (the
|
121
|
+
'Software'), to deal in the Software without restriction, including
|
122
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
123
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
124
|
+
permit persons to whom the Software is furnished to do so, subject to
|
125
|
+
the following conditions:
|
126
|
+
|
127
|
+
The above copyright notice and this permission notice shall be
|
128
|
+
included in all copies or substantial portions of the Software.
|
129
|
+
|
130
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
131
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
132
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
133
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
134
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
135
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
136
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/Rakefile
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
%w[rubygems rake rake/clean fileutils newgem rubigen].each { |f| require f }
|
2
|
+
require File.dirname(__FILE__) + '/lib/clouder'
|
3
|
+
|
4
|
+
# Generate all the Rake tasks
|
5
|
+
# Run 'rake -T' to see list of generated tasks (from gem root directory)
|
6
|
+
$hoe = Hoe.new('clouder', Clouder::VERSION) do |p|
|
7
|
+
p.developer('Demetrius Nunes', 'demetriusnunes@gmail.com')
|
8
|
+
p.changes = p.paragraphs_of("History.txt", 0..1).join("\n\n")
|
9
|
+
# p.post_install_message = 'PostInstall.txt' # TODO remove if post-install message not required
|
10
|
+
p.rubyforge_name = p.name # TODO this is default value
|
11
|
+
# p.extra_deps = [
|
12
|
+
# ['activesupport','>= 2.0.2'],
|
13
|
+
# ]
|
14
|
+
p.extra_dev_deps = [
|
15
|
+
['newgem', ">= #{::Newgem::VERSION}"]
|
16
|
+
]
|
17
|
+
|
18
|
+
p.clean_globs |= %w[**/.DS_Store tmp *.log]
|
19
|
+
path = (p.rubyforge_name == p.name) ? p.rubyforge_name : "\#{p.rubyforge_name}/\#{p.name}"
|
20
|
+
p.remote_rdoc_dir = File.join(path.gsub(/^#{p.rubyforge_name}\/?/,''), 'rdoc')
|
21
|
+
p.rsync_args = '-av --delete --ignore-errors'
|
22
|
+
end
|
23
|
+
|
24
|
+
require 'newgem/tasks' # load /tasks/*.rake
|
25
|
+
Dir['tasks/**/*.rake'].each { |t| load t }
|
26
|
+
|
27
|
+
# TODO - want other tests/tasks run by default? Add them to the list
|
28
|
+
# task :default => [:spec, :features]
|
data/lib/clouder.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
$:.unshift(File.dirname(__FILE__)) unless
|
2
|
+
$:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
|
3
|
+
|
4
|
+
require "rubygems"
|
5
|
+
require 'json'
|
6
|
+
require 'restclient'
|
7
|
+
require 'clouder/entity'
|
8
|
+
require 'clouder/rest'
|
9
|
+
|
10
|
+
# The Clouder module holds global server-wide functions.
|
11
|
+
module Clouder
|
12
|
+
VERSION = '0.5.0'
|
13
|
+
|
14
|
+
# Returns an array of URIs of the resources exposed by
|
15
|
+
# the CloudKit server at the +uri+.
|
16
|
+
#
|
17
|
+
# Clouder.collection("http://localhost:9292")
|
18
|
+
# => [ "notes", "comments" ]
|
19
|
+
def Clouder.collections(uri)
|
20
|
+
uris = Rest.get(File.join(uri, "cloudkit-meta"))["uris"]
|
21
|
+
uris.map { |uri| uri.split("/").last }
|
22
|
+
end
|
23
|
+
|
24
|
+
# Makes a HEAD request to the +uri+ and returns a hash
|
25
|
+
# of headers contained in the response.
|
26
|
+
def Clouder.head(uri)
|
27
|
+
Rest.head(uri)
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
|
@@ -0,0 +1,255 @@
|
|
1
|
+
require 'ostruct'
|
2
|
+
require 'time'
|
3
|
+
|
4
|
+
module Clouder
|
5
|
+
# This is the base class to be used when accessing resources
|
6
|
+
# in a CloudKit server. It contains all the basic persistence
|
7
|
+
# methods and attributes. See the entity_spec.rb file for sample usages.
|
8
|
+
#
|
9
|
+
# A Note class should be declared as follows:
|
10
|
+
# class Note < Clouder::Entity
|
11
|
+
# uri "http://localhost:9292/notes"
|
12
|
+
# end
|
13
|
+
class Entity < OpenStruct
|
14
|
+
|
15
|
+
class << self
|
16
|
+
|
17
|
+
# If +address+ is passed, sets the URI for the target class.
|
18
|
+
# If nothing is passed, returns the current URI for the target class.
|
19
|
+
#
|
20
|
+
# Note.uri "http://localhost:8989/notes" # changes the old URI
|
21
|
+
# Note.uri # => "http://localhost:8989/notes"
|
22
|
+
def uri(address = nil)
|
23
|
+
address ? @uri = address : @uri
|
24
|
+
end
|
25
|
+
|
26
|
+
# If +options+ is nil, returns an array containing all URIs
|
27
|
+
# of existing objects for this class. Sort order is from the
|
28
|
+
# most recent to the oldest.
|
29
|
+
#
|
30
|
+
# For other results, +options+ can be:
|
31
|
+
# [:resolved] If +true+, returns full objects instead of URIs.
|
32
|
+
# [:offset] A positive integer, starting at 0, offsetting the result.
|
33
|
+
# [:limit] A positive integer, limiting the results.
|
34
|
+
#
|
35
|
+
# All options can be combined.
|
36
|
+
#
|
37
|
+
# Note.all
|
38
|
+
# Note.all(:resolved => true)
|
39
|
+
# Note.all(:offset => 20, :limit => 10)
|
40
|
+
# Note.all(:resolved => true, :limit => 20, :offset => 10)
|
41
|
+
def all(options = {})
|
42
|
+
uri = options[:resolved] ? File.join(@uri, "_resolved") : @uri
|
43
|
+
result = Rest.get(Rest.paramify_url(uri, options))
|
44
|
+
options[:resolved] ? result["documents"].map { |d| new(d) } : result["uris"]
|
45
|
+
end
|
46
|
+
|
47
|
+
# Creates and saves an object with the attributes and values
|
48
|
+
# passed as a hash. Returns the newly created object.
|
49
|
+
#
|
50
|
+
# note = Note.create(:text => "My note", :author => "John Doe")
|
51
|
+
def create(hsh = {})
|
52
|
+
obj = self.new(hsh || {})
|
53
|
+
obj.save
|
54
|
+
obj
|
55
|
+
end
|
56
|
+
|
57
|
+
# Retrieves an existing object with the given id or uri.
|
58
|
+
# Returns nil if the object is not found.
|
59
|
+
#
|
60
|
+
# note = Note.get("ce655c90-cf09-012b-cd41-0016cb91f13d")
|
61
|
+
def get(id_or_uri)
|
62
|
+
uri = uri_from_id(id_or_uri)
|
63
|
+
document = Rest.get(uri)
|
64
|
+
new({'uri' => uri, 'etag' => document.headers[:etag],
|
65
|
+
'last_modified' => document.headers[:last_modified],
|
66
|
+
'document' => document })
|
67
|
+
rescue RestClient::ResourceNotFound
|
68
|
+
nil
|
69
|
+
end
|
70
|
+
|
71
|
+
# Returns an array of allowed HTTP methods to be requested at
|
72
|
+
# +uri+. If +uri+ is nil, the class URI is queried.
|
73
|
+
#
|
74
|
+
# Note.options # => [ "GET", "HEAD", "POST", "OPTIONS" ]
|
75
|
+
def options(uri = self.uri)
|
76
|
+
doc = Rest.custom(:options, uri)
|
77
|
+
doc["allow"].to_s.split(",").map { |s| s.strip }
|
78
|
+
end
|
79
|
+
|
80
|
+
# Extracts object ids from absolute or partial URIs.
|
81
|
+
#
|
82
|
+
# Note.id_from_uri("http://localhost:9292/notes/ce655c90-cf09-012b-cd41-0016cb91f13d")
|
83
|
+
# => "ce655c90-cf09-012b-cd41-0016cb91f13d"
|
84
|
+
def id_from_uri(uri)
|
85
|
+
id = URI.parse(uri)
|
86
|
+
# /notes/abc
|
87
|
+
if id.path[0,1] == "/"
|
88
|
+
id.path.split("/")[2]
|
89
|
+
else
|
90
|
+
id.to_s
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# Composes a full URI from an object id or partial URI.
|
95
|
+
#
|
96
|
+
# Note.uri_from_id("/notes/ce655c90-cf09-012b-cd41-0016cb91f13d")
|
97
|
+
# => "http://localhost:9292/notes/ce655c90-cf09-012b-cd41-0016cb91f13d"
|
98
|
+
#
|
99
|
+
# Note.uri_from_id("ce655c90-cf09-012b-cd41-0016cb91f13d")
|
100
|
+
# => "http://localhost:9292/notes/ce655c90-cf09-012b-cd41-0016cb91f13d"
|
101
|
+
def uri_from_id(id)
|
102
|
+
url = URI.parse(id)
|
103
|
+
if url.absolute?
|
104
|
+
url.to_s
|
105
|
+
else
|
106
|
+
# /notes/1234
|
107
|
+
if url.path[0,1] == "/"
|
108
|
+
(URI.parse(self.uri) + url).to_s
|
109
|
+
# 1234
|
110
|
+
else
|
111
|
+
File.join("#{self.uri}", id)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
end
|
117
|
+
|
118
|
+
# Unique object id - not an URI, just a UUID
|
119
|
+
attr_reader :id
|
120
|
+
|
121
|
+
# ETag, a UUID
|
122
|
+
attr_reader :etag
|
123
|
+
|
124
|
+
# Last modified timestamp
|
125
|
+
attr_reader :last_modified
|
126
|
+
|
127
|
+
# Full URI for the object
|
128
|
+
# => "http://localhost:9292/notes/ce655c90-cf09-012b-cd41-0016cb91f13d"
|
129
|
+
def uri
|
130
|
+
@uri ||= self.class.uri_from_id(id) if id
|
131
|
+
end
|
132
|
+
|
133
|
+
# Partial URI for the object (without the protocol, hostname)
|
134
|
+
# => "/notes/ce655c90-cf09-012b-cd41-0016cb91f13d"
|
135
|
+
def path
|
136
|
+
URI.parse(uri).path
|
137
|
+
end
|
138
|
+
|
139
|
+
# Constructs a new, unsaved object. If +attributes+ are passed in a hash
|
140
|
+
# the new object is initialized with the corresponding attributes and values.
|
141
|
+
#
|
142
|
+
# note = Note.new # => new, empty
|
143
|
+
# note = Note.new(:text => "Ready note", :author => "Myself") # => new, with attributes set
|
144
|
+
def initialize(attributes = {})
|
145
|
+
@id, @etag, @last_modified, @deleted = nil
|
146
|
+
build(attributes)
|
147
|
+
end
|
148
|
+
|
149
|
+
# Saves a new or existing object. If the object already exists,
|
150
|
+
# then its etag should match the etag in the database, otherwise
|
151
|
+
# the operation fails.
|
152
|
+
#
|
153
|
+
# Returns +true+ if save was successful, +false+ otherwise.
|
154
|
+
# note = Note.new(:text => "Ready note", :author => "Myself")
|
155
|
+
# note.save # => true
|
156
|
+
def save
|
157
|
+
result = new? ? Rest.post(collection_uri, @table) : Rest.put(uri, @table, "If-Match" => etag)
|
158
|
+
@id, @etag, @last_modified = result.values_at("uri", "etag", "last_modified")
|
159
|
+
@id = self.class.id_from_uri(@id)
|
160
|
+
@last_modified = Time.parse(@last_modified)
|
161
|
+
true
|
162
|
+
rescue RestClient::RequestFailed
|
163
|
+
false
|
164
|
+
end
|
165
|
+
|
166
|
+
# Deletes an existing object. Its etag should match the etag in
|
167
|
+
# the database, otherwise the operation fails.
|
168
|
+
#
|
169
|
+
# Returns +true+ if save was successful, +false+ otherwise.
|
170
|
+
# note = Note.new("ce655c90-cf09-012b-cd41-0016cb91f13d")
|
171
|
+
# note.delete # => true
|
172
|
+
def delete
|
173
|
+
Rest.delete uri, "If-Match" => etag
|
174
|
+
@deleted = true
|
175
|
+
freeze
|
176
|
+
true
|
177
|
+
rescue RestClient::RequestFailed
|
178
|
+
false
|
179
|
+
end
|
180
|
+
|
181
|
+
# +true+ if object was not saved yet, +false+ otherwise.
|
182
|
+
def new?
|
183
|
+
@uri == nil and @etag == nil and @last_modified == nil
|
184
|
+
end
|
185
|
+
|
186
|
+
# +true+ if object was deleted, +false+ otherwise.
|
187
|
+
def deleted?; @deleted end
|
188
|
+
|
189
|
+
# Retrieves older versions of the object. Sort order is from the
|
190
|
+
# current version to the oldest one.
|
191
|
+
# The +options+ parameter works as in +all+.
|
192
|
+
#
|
193
|
+
# note = Note.new("ce655c90-cf09-012b-cd41-0016cb91f13d")
|
194
|
+
# older_versions = note.versions(:resolved, :limit => 3)
|
195
|
+
def versions(options = {})
|
196
|
+
if uri
|
197
|
+
url = File.join(uri, "versions")
|
198
|
+
if options[:etag]
|
199
|
+
url = File.join(url, options[:etag])
|
200
|
+
self.class.get(url)
|
201
|
+
else
|
202
|
+
url = options[:resolved] ? File.join(url, "_resolved") : url
|
203
|
+
result = Rest.get(Rest.paramify_url(url, options))
|
204
|
+
if options[:resolved]
|
205
|
+
result["documents"].map { |d| self.class.new(d) }
|
206
|
+
else
|
207
|
+
result["uris"]
|
208
|
+
end
|
209
|
+
end
|
210
|
+
else
|
211
|
+
[]
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# Returns an array of allowed HTTP methods to be requested for
|
216
|
+
# this object.
|
217
|
+
#
|
218
|
+
# note = Note.new("ce655c90-cf09-012b-cd41-0016cb91f13d") # => existing object
|
219
|
+
# note.options # => [ "DELETE", "GET", "HEAD", "PUT", "OPTIONS" ]
|
220
|
+
def options
|
221
|
+
self.class.options(uri)
|
222
|
+
end
|
223
|
+
|
224
|
+
def inspect
|
225
|
+
"#<#{self.class.name} uri=#{uri}, id=#{id}, etag=#{@etag}, last_modified=#{@last_modified}, #{@table.inspect}>"
|
226
|
+
end
|
227
|
+
|
228
|
+
private
|
229
|
+
|
230
|
+
def collection_uri
|
231
|
+
self.class.uri
|
232
|
+
end
|
233
|
+
|
234
|
+
def build(doc)
|
235
|
+
if doc['uri']
|
236
|
+
set_internal_attributes(doc['uri'], doc['etag'],
|
237
|
+
doc['last_modified'], doc['document'])
|
238
|
+
else
|
239
|
+
@table = doc
|
240
|
+
end
|
241
|
+
symbolize_table_keys!
|
242
|
+
end
|
243
|
+
|
244
|
+
def set_internal_attributes(id, etag, last_modified, table)
|
245
|
+
@id = self.class.id_from_uri(id)
|
246
|
+
@etag = etag.to_s.gsub('"', '')
|
247
|
+
@last_modified = Time.parse(last_modified)
|
248
|
+
@table = table.is_a?(String) ? JSON.parse(table) : table
|
249
|
+
end
|
250
|
+
|
251
|
+
def symbolize_table_keys!
|
252
|
+
@table.keys.each { |k| v = @table.delete(k); @table[k.to_sym] = v }
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
data/lib/clouder/rest.rb
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'cgi'
|
2
|
+
|
3
|
+
class Rest
|
4
|
+
class << self
|
5
|
+
|
6
|
+
# set proxy for RestClient to use
|
7
|
+
def proxy url
|
8
|
+
RestClient.proxy = url
|
9
|
+
end
|
10
|
+
|
11
|
+
def put uri, doc = nil, headers = {}
|
12
|
+
payload = doc.to_json if doc
|
13
|
+
parse(RestClient.put(uri, payload, headers))
|
14
|
+
end
|
15
|
+
|
16
|
+
def get uri
|
17
|
+
parse(RestClient.get(uri), :max_nesting => false)
|
18
|
+
end
|
19
|
+
|
20
|
+
def post uri, doc = nil, headers = {}
|
21
|
+
payload = doc.to_json if doc
|
22
|
+
parse(RestClient.post(uri, payload, headers))
|
23
|
+
end
|
24
|
+
|
25
|
+
def delete uri, headers = {}
|
26
|
+
parse(RestClient.delete(uri, headers))
|
27
|
+
end
|
28
|
+
|
29
|
+
def copy uri, destination
|
30
|
+
parse(RestClient.copy(uri, {'Destination' => destination}))
|
31
|
+
end
|
32
|
+
|
33
|
+
def move uri, destination
|
34
|
+
parse(RestClient.move(uri, {'Destination' => destination}))
|
35
|
+
end
|
36
|
+
|
37
|
+
def head uri, headers = {}
|
38
|
+
custom(:head, uri, headers)
|
39
|
+
end
|
40
|
+
|
41
|
+
def custom method, uri, headers = {}
|
42
|
+
response = nil
|
43
|
+
url = URI.parse(uri)
|
44
|
+
Net::HTTP.start(url.host, url.port) { |http|
|
45
|
+
response = http.send(method, url.path, headers)
|
46
|
+
}
|
47
|
+
response.to_hash
|
48
|
+
end
|
49
|
+
|
50
|
+
def parse(response, opts = {})
|
51
|
+
if response
|
52
|
+
json = JSON.parse(response, opts)
|
53
|
+
json.extend(ResponseHeaders)
|
54
|
+
json.headers = response.headers
|
55
|
+
json
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def paramify_url url, params = {}
|
60
|
+
if params && !params.empty?
|
61
|
+
query = params.collect do |k,v|
|
62
|
+
v = v.to_json if %w{key startkey endkey}.include?(k.to_s)
|
63
|
+
"#{k}=#{CGI.escape(v.to_s)}"
|
64
|
+
end.join("&")
|
65
|
+
url = "#{url}?#{query}"
|
66
|
+
end
|
67
|
+
url
|
68
|
+
end
|
69
|
+
end # class << self
|
70
|
+
end
|
71
|
+
|
72
|
+
module ResponseHeaders
|
73
|
+
def headers
|
74
|
+
@headers
|
75
|
+
end
|
76
|
+
|
77
|
+
def headers=(h)
|
78
|
+
@headers = h
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper.rb'
|
2
|
+
|
3
|
+
describe "Clouder" do
|
4
|
+
|
5
|
+
it "should get available collections" do
|
6
|
+
collections = Clouder.collections "http://localhost:9292/"
|
7
|
+
collections.size.should == 1
|
8
|
+
collections.first.should == "notes"
|
9
|
+
end
|
10
|
+
|
11
|
+
it "should allow to query any uri and just return the headers" do
|
12
|
+
headers = Clouder.head("http://localhost:9292/notes")
|
13
|
+
headers["content-type"].should include("application/json")
|
14
|
+
end
|
15
|
+
|
16
|
+
|
17
|
+
end
|
data/spec/config.ru
ADDED
data/spec/entity_spec.rb
ADDED
@@ -0,0 +1,303 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper.rb'
|
2
|
+
|
3
|
+
describe "Entity" do
|
4
|
+
|
5
|
+
before :all do
|
6
|
+
class Note < Clouder::Entity
|
7
|
+
uri "http://localhost:9292/notes"
|
8
|
+
end
|
9
|
+
Note.all(:resolved => true).each { |n| n.delete }
|
10
|
+
end
|
11
|
+
|
12
|
+
it "should let each class be associated with a different uri" do
|
13
|
+
class Comment < Clouder::Entity
|
14
|
+
uri "http://localhost:9292/comments"
|
15
|
+
end
|
16
|
+
|
17
|
+
Comment.uri.should == "http://localhost:9292/comments"
|
18
|
+
Note.uri.should == "http://localhost:9292/notes"
|
19
|
+
end
|
20
|
+
|
21
|
+
it "should let each subclass be associated with a different uri" do
|
22
|
+
class Reminder < Note
|
23
|
+
uri "http://localhost:9292/reminders"
|
24
|
+
end
|
25
|
+
|
26
|
+
Reminder.uri.should == "http://localhost:9292/reminders"
|
27
|
+
Note.uri.should == "http://localhost:9292/notes"
|
28
|
+
end
|
29
|
+
|
30
|
+
it "should let you know the available methods for the class" do
|
31
|
+
options = Note.options
|
32
|
+
options.should == %w(GET HEAD POST OPTIONS)
|
33
|
+
end
|
34
|
+
|
35
|
+
it "should let you know the available methods for the object" do
|
36
|
+
n = Note.new
|
37
|
+
n.text = "My note"
|
38
|
+
n.save
|
39
|
+
n.options.should == %w(GET HEAD PUT DELETE OPTIONS)
|
40
|
+
end
|
41
|
+
|
42
|
+
it "should let you create entity classes" do
|
43
|
+
n = Note.new
|
44
|
+
n.new?.should == true
|
45
|
+
end
|
46
|
+
|
47
|
+
it "should let you create entity classes with attributes" do
|
48
|
+
n = Note.new(:text => "My first note", :author => "Myself")
|
49
|
+
n.new?.should == true
|
50
|
+
n.text.should == "My first note"
|
51
|
+
n.author.should == "Myself"
|
52
|
+
end
|
53
|
+
|
54
|
+
it "should let you inspect its uri" do
|
55
|
+
Note.uri.should == "http://localhost:9292/notes"
|
56
|
+
end
|
57
|
+
|
58
|
+
it "should retrieve all uris" do
|
59
|
+
size = Note.all.size
|
60
|
+
Note.create(:text => "note 1").should be_an_instance_of(Note)
|
61
|
+
Note.create(:text => "note 2").should be_an_instance_of(Note)
|
62
|
+
Note.all.size.should == size + 2
|
63
|
+
end
|
64
|
+
|
65
|
+
it "should retrieve all instances" do
|
66
|
+
size = Note.all.size
|
67
|
+
Note.create(:text => "note 1").should be_an_instance_of(Note)
|
68
|
+
Note.create(:text => "note 2").should be_an_instance_of(Note)
|
69
|
+
|
70
|
+
notes = Note.all(:resolved => true)
|
71
|
+
notes.each { |n|
|
72
|
+
n.should be_an_instance_of(Note)
|
73
|
+
n.etag.should_not == nil
|
74
|
+
n.id.should_not == nil
|
75
|
+
n.last_modified.should_not == nil
|
76
|
+
}
|
77
|
+
Note.all.size.should == size + 2
|
78
|
+
end
|
79
|
+
|
80
|
+
it "should retrieve all uris with limit and offset" do
|
81
|
+
size = Note.all.size
|
82
|
+
|
83
|
+
notes = []
|
84
|
+
(1..4).each { |i| notes << Note.create(:text => "$note #{i}") }
|
85
|
+
|
86
|
+
last_notes = Note.all(:limit => 4)
|
87
|
+
last_notes.size.should == 4
|
88
|
+
last_notes.should == notes.map { |n| n.path }.reverse
|
89
|
+
|
90
|
+
last_notes = Note.all(:offset => 2, :limit => 2)
|
91
|
+
last_notes.size.should == 2
|
92
|
+
last_notes.should == notes[0..1].map { |n| n.path }.reverse
|
93
|
+
|
94
|
+
last_notes = Note.all(:offset => 2)
|
95
|
+
last_notes.size.should == size + 2
|
96
|
+
end
|
97
|
+
|
98
|
+
it "should let you access attributes" do
|
99
|
+
n = Note.new
|
100
|
+
n.text = "My Note"
|
101
|
+
n.author = "John Doe"
|
102
|
+
|
103
|
+
n.text.should == "My Note"
|
104
|
+
n.author.should == "John Doe"
|
105
|
+
end
|
106
|
+
|
107
|
+
it "should let you save new objects" do
|
108
|
+
n = Note.new
|
109
|
+
n.new?.should == true
|
110
|
+
n.uri.should == nil
|
111
|
+
n.last_modified.should == nil
|
112
|
+
n.etag.should == nil
|
113
|
+
|
114
|
+
n.text = "My Note"
|
115
|
+
n.author = "John Doe"
|
116
|
+
|
117
|
+
n.save
|
118
|
+
|
119
|
+
n.new?.should == false
|
120
|
+
n.uri.should_not == nil
|
121
|
+
n.last_modified.should be_close(Time.now, 10)
|
122
|
+
n.etag.should_not == nil
|
123
|
+
end
|
124
|
+
|
125
|
+
it "should let you retrieve saved objects" do
|
126
|
+
n = Note.new
|
127
|
+
n.text = "My Note"
|
128
|
+
n.author = "John Doe"
|
129
|
+
n.save
|
130
|
+
|
131
|
+
id, etag, last_modified = n.id, n.etag, n.last_modified
|
132
|
+
|
133
|
+
n = Note.get(id)
|
134
|
+
n.text.should == "My Note"
|
135
|
+
n.author.should == "John Doe"
|
136
|
+
n.id.should == id
|
137
|
+
n.etag.should == etag
|
138
|
+
n.last_modified.should == last_modified
|
139
|
+
end
|
140
|
+
|
141
|
+
it "should let you retrieve saved objects by URI" do
|
142
|
+
n = Note.new
|
143
|
+
n.text = "My Note"
|
144
|
+
n.author = "John Doe"
|
145
|
+
n.save
|
146
|
+
|
147
|
+
id, etag, last_modified = n.id, n.etag, n.last_modified
|
148
|
+
|
149
|
+
n = Note.get Note.all.first
|
150
|
+
n.text.should == "My Note"
|
151
|
+
n.author.should == "John Doe"
|
152
|
+
n.id.should == id
|
153
|
+
n.etag.should == etag
|
154
|
+
n.last_modified.should == last_modified
|
155
|
+
end
|
156
|
+
|
157
|
+
it "should return nil when trying to retrieve a non-existing object" do
|
158
|
+
Note.get("abcdef").should == nil
|
159
|
+
end
|
160
|
+
|
161
|
+
it "should let you update saved objects" do
|
162
|
+
n = Note.new
|
163
|
+
n.text = "My Note"
|
164
|
+
n.author = "John Doe"
|
165
|
+
n.save
|
166
|
+
|
167
|
+
n = Note.get(n.id)
|
168
|
+
n.versions.size.should == 1
|
169
|
+
n.text = "My modified note"
|
170
|
+
n.author = "John Doe II"
|
171
|
+
n.save.should == true
|
172
|
+
|
173
|
+
n = Note.get(n.id)
|
174
|
+
n.versions.size.should == 2
|
175
|
+
n.text.should == "My modified note"
|
176
|
+
n.author.should == "John Doe II"
|
177
|
+
end
|
178
|
+
|
179
|
+
it "should let you delete existing objects" do
|
180
|
+
size = Note.all.size
|
181
|
+
n = Note.new
|
182
|
+
n.text = "My Note"
|
183
|
+
n.author = "John Doe"
|
184
|
+
n.save
|
185
|
+
Note.all.size.should == size + 1
|
186
|
+
n.delete
|
187
|
+
n.frozen?.should == true
|
188
|
+
n.deleted?.should == true
|
189
|
+
Note.all.size.should == size
|
190
|
+
end
|
191
|
+
|
192
|
+
it "should let you query versions about the object" do
|
193
|
+
n = Note.new
|
194
|
+
n.versions.should == []
|
195
|
+
n.text = "First version"
|
196
|
+
n.save
|
197
|
+
|
198
|
+
etag = n.etag
|
199
|
+
n.versions.size.should == 1
|
200
|
+
n.text = "Second version"
|
201
|
+
n.save
|
202
|
+
|
203
|
+
n.versions.size.should == 2
|
204
|
+
n.versions[1].should =~ Regexp.new(etag)
|
205
|
+
n.text = "Third version"
|
206
|
+
etag = n.etag
|
207
|
+
n.save
|
208
|
+
n.versions.size.should == 3
|
209
|
+
n.versions[1].should =~ Regexp.new(etag)
|
210
|
+
end
|
211
|
+
|
212
|
+
it "should let you query versions about the object using offset and limit" do
|
213
|
+
etags = []
|
214
|
+
n = Note.new
|
215
|
+
n.text = "First version"
|
216
|
+
n.save
|
217
|
+
etags << n.etag
|
218
|
+
|
219
|
+
n.text = "Second version"
|
220
|
+
n.save
|
221
|
+
etags << n.etag
|
222
|
+
|
223
|
+
n.text = "Third version"
|
224
|
+
etag = n.etag
|
225
|
+
n.save
|
226
|
+
etags << n.etag
|
227
|
+
|
228
|
+
versions = n.versions(:limit => 2)
|
229
|
+
versions.size.should == 2
|
230
|
+
versions.first.should include(n.path)
|
231
|
+
versions.last.should =~ Regexp.new(etags[1])
|
232
|
+
|
233
|
+
versions = n.versions(:offset => 1, :limit => 2)
|
234
|
+
versions.size.should == 2
|
235
|
+
versions.first.should =~ Regexp.new(etags[1])
|
236
|
+
versions.last.should =~ Regexp.new(etags[0])
|
237
|
+
|
238
|
+
versions = n.versions(:offset => 2, :limit => 2)
|
239
|
+
versions.size.should == 1
|
240
|
+
versions.first.should =~ Regexp.new(etags[0])
|
241
|
+
end
|
242
|
+
|
243
|
+
it "should let you retrieve full objects from versions" do
|
244
|
+
texts = %w{text1 text2 text3}
|
245
|
+
etags = []
|
246
|
+
last_modifieds = []
|
247
|
+
n = Note.new
|
248
|
+
texts.each { |text|
|
249
|
+
n.text = text
|
250
|
+
n.save
|
251
|
+
etags << n.etag
|
252
|
+
last_modifieds << n.last_modified
|
253
|
+
}
|
254
|
+
versions = n.versions(:resolved => true)
|
255
|
+
versions.size.should == 3
|
256
|
+
versions.map { |v| v.etag }.should == etags.reverse
|
257
|
+
versions.map { |v| v.last_modified }.should == last_modifieds.reverse
|
258
|
+
versions.map { |v| v.text }.should == texts.reverse
|
259
|
+
end
|
260
|
+
|
261
|
+
it "should let you retrieve older versions by etag" do
|
262
|
+
texts = %w{text1 text2 text3}
|
263
|
+
etags = []
|
264
|
+
last_modifieds = []
|
265
|
+
n = Note.new
|
266
|
+
texts.each { |text|
|
267
|
+
n.text = text
|
268
|
+
n.save
|
269
|
+
etags << n.etag
|
270
|
+
last_modifieds << n.last_modified
|
271
|
+
}
|
272
|
+
|
273
|
+
etags[0..1].each_with_index { |etag, i|
|
274
|
+
n = n.versions(:etag => etag)
|
275
|
+
n.etag.should == etag
|
276
|
+
n.text.should == texts[i]
|
277
|
+
n.last_modified.should == last_modifieds[i]
|
278
|
+
}
|
279
|
+
end
|
280
|
+
|
281
|
+
it "should fail when trying to save an outdated object" do
|
282
|
+
n = Note.create(:text => "Original")
|
283
|
+
|
284
|
+
n2 = Note.get(n.id)
|
285
|
+
n2.text = "Modified"
|
286
|
+
n2.save.should == true
|
287
|
+
|
288
|
+
n.text = "Modified but outdated"
|
289
|
+
n.save.should == false
|
290
|
+
end
|
291
|
+
|
292
|
+
it "should fail when trying to delete an outdated object" do
|
293
|
+
n = Note.create(:text => "Original")
|
294
|
+
|
295
|
+
n2 = Note.get(n.id)
|
296
|
+
n2.text = "Modified"
|
297
|
+
n2.save.should == true
|
298
|
+
|
299
|
+
n.text = "Modified but outdated"
|
300
|
+
n.delete.should == false
|
301
|
+
end
|
302
|
+
|
303
|
+
end
|
data/spec/spec.opts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--colour
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: demetriusnunes-clouder
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.5.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Demetrius Nunes
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-01-29 00:00:00 -08:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: newgem
|
17
|
+
type: :development
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: 1.2.3
|
24
|
+
version:
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: hoe
|
27
|
+
type: :development
|
28
|
+
version_requirement:
|
29
|
+
version_requirements: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 1.8.0
|
34
|
+
version:
|
35
|
+
description: A Ruby client library for accessing CloudKit using Ruby objects.
|
36
|
+
email:
|
37
|
+
- demetriusnunes@gmail.com
|
38
|
+
executables: []
|
39
|
+
|
40
|
+
extensions: []
|
41
|
+
|
42
|
+
extra_rdoc_files:
|
43
|
+
- History.txt
|
44
|
+
- Manifest.txt
|
45
|
+
- PostInstall.txt
|
46
|
+
- README.rdoc
|
47
|
+
files:
|
48
|
+
- History.txt
|
49
|
+
- Manifest.txt
|
50
|
+
- PostInstall.txt
|
51
|
+
- README.rdoc
|
52
|
+
- Rakefile
|
53
|
+
- lib/clouder.rb
|
54
|
+
- lib/clouder/entity.rb
|
55
|
+
- lib/clouder/rest.rb
|
56
|
+
- spec/clouder_spec.rb
|
57
|
+
- spec/entity_spec.rb
|
58
|
+
- spec/spec_helper.rb
|
59
|
+
- spec/spec.opts
|
60
|
+
- spec/config.ru
|
61
|
+
has_rdoc: true
|
62
|
+
homepage: http://github.com/demetriusnunes/clouder/tree/master
|
63
|
+
post_install_message: PostInstall.txt
|
64
|
+
rdoc_options:
|
65
|
+
- --main
|
66
|
+
- README.rdoc
|
67
|
+
require_paths:
|
68
|
+
- lib
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: "0"
|
74
|
+
version:
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
76
|
+
requirements:
|
77
|
+
- - ">="
|
78
|
+
- !ruby/object:Gem::Version
|
79
|
+
version: "0"
|
80
|
+
version:
|
81
|
+
requirements: []
|
82
|
+
|
83
|
+
rubyforge_project: clouder
|
84
|
+
rubygems_version: 1.2.0
|
85
|
+
signing_key:
|
86
|
+
specification_version: 2
|
87
|
+
summary: A Ruby client library for accessing CloudKit using Ruby objects.
|
88
|
+
test_files: []
|
89
|
+
|