demetriusnunes-clouder 0.5.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/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
|
+
|