cloudkit-jruby 0.11.2

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.
Files changed (64) hide show
  1. data/CHANGES +47 -0
  2. data/COPYING +20 -0
  3. data/README +84 -0
  4. data/Rakefile +42 -0
  5. data/TODO +21 -0
  6. data/cloudkit.gemspec +89 -0
  7. data/doc/curl.html +388 -0
  8. data/doc/images/example-code.gif +0 -0
  9. data/doc/images/json-title.gif +0 -0
  10. data/doc/images/oauth-discovery-logo.gif +0 -0
  11. data/doc/images/openid-logo.gif +0 -0
  12. data/doc/index.html +90 -0
  13. data/doc/main.css +151 -0
  14. data/doc/rest-api.html +467 -0
  15. data/examples/1.ru +3 -0
  16. data/examples/2.ru +3 -0
  17. data/examples/3.ru +6 -0
  18. data/examples/4.ru +5 -0
  19. data/examples/5.ru +9 -0
  20. data/examples/6.ru +11 -0
  21. data/examples/TOC +17 -0
  22. data/lib/cloudkit.rb +92 -0
  23. data/lib/cloudkit/constants.rb +34 -0
  24. data/lib/cloudkit/exceptions.rb +10 -0
  25. data/lib/cloudkit/flash_session.rb +20 -0
  26. data/lib/cloudkit/oauth_filter.rb +266 -0
  27. data/lib/cloudkit/oauth_store.rb +48 -0
  28. data/lib/cloudkit/openid_filter.rb +236 -0
  29. data/lib/cloudkit/openid_store.rb +100 -0
  30. data/lib/cloudkit/rack/builder.rb +120 -0
  31. data/lib/cloudkit/rack/router.rb +20 -0
  32. data/lib/cloudkit/request.rb +177 -0
  33. data/lib/cloudkit/service.rb +162 -0
  34. data/lib/cloudkit/store.rb +349 -0
  35. data/lib/cloudkit/store/memory_table.rb +99 -0
  36. data/lib/cloudkit/store/resource.rb +269 -0
  37. data/lib/cloudkit/store/response.rb +52 -0
  38. data/lib/cloudkit/store/response_helpers.rb +84 -0
  39. data/lib/cloudkit/templates/authorize_request_token.erb +19 -0
  40. data/lib/cloudkit/templates/oauth_descriptor.erb +43 -0
  41. data/lib/cloudkit/templates/oauth_meta.erb +8 -0
  42. data/lib/cloudkit/templates/openid_login.erb +31 -0
  43. data/lib/cloudkit/templates/request_authorization.erb +23 -0
  44. data/lib/cloudkit/templates/request_token_denied.erb +18 -0
  45. data/lib/cloudkit/uri.rb +88 -0
  46. data/lib/cloudkit/user_store.rb +37 -0
  47. data/lib/cloudkit/util.rb +25 -0
  48. data/spec/ext_spec.rb +76 -0
  49. data/spec/flash_session_spec.rb +20 -0
  50. data/spec/memory_table_spec.rb +86 -0
  51. data/spec/oauth_filter_spec.rb +326 -0
  52. data/spec/oauth_store_spec.rb +10 -0
  53. data/spec/openid_filter_spec.rb +81 -0
  54. data/spec/openid_store_spec.rb +101 -0
  55. data/spec/rack_builder_spec.rb +39 -0
  56. data/spec/request_spec.rb +191 -0
  57. data/spec/resource_spec.rb +310 -0
  58. data/spec/service_spec.rb +1039 -0
  59. data/spec/spec_helper.rb +32 -0
  60. data/spec/store_spec.rb +10 -0
  61. data/spec/uri_spec.rb +93 -0
  62. data/spec/user_store_spec.rb +10 -0
  63. data/spec/util_spec.rb +11 -0
  64. metadata +180 -0
@@ -0,0 +1,99 @@
1
+ module CloudKit
2
+
3
+ # A MemoryTable implements the essential pieces of the Rufus Tokyo Table API
4
+ # required for CloudKit's operation. It is basically a hash of hashes with
5
+ # querying capabilities. None of the data is persisted to disk nor is it
6
+ # designed with production use in mind. The primary purpose is to enable
7
+ # testing and development with CloudKit without depending on binary Tokyo
8
+ # Cabinet dependencies.
9
+ #
10
+ # Implementing a new adapter for CloudKit means writing an adapter that
11
+ # passes the specs for this one.
12
+ class MemoryTable
13
+
14
+ # Create a new MemoryTable instance.
15
+ def initialize
16
+ @serial_id = 0
17
+ clear
18
+ end
19
+
20
+ # Create a hash record for the given key. Returns the record if valid or nil
21
+ # otherwise. Records are valid if they are hashses with both string keys and
22
+ # string values.
23
+ def []=(key, record)
24
+ if valid?(record)
25
+ @keys << key unless @hash[key]
26
+ return @hash[key] = record
27
+ end
28
+ nil
29
+ end
30
+
31
+ # Retrieve the hash record for a given key.
32
+ def [](key)
33
+ @hash[key]
34
+ end
35
+
36
+ # Clear the contents of the store.
37
+ def clear
38
+ @hash = {}
39
+ @keys = []
40
+ end
41
+
42
+ # Return an ordered set of all keys in the store.
43
+ def keys
44
+ @keys
45
+ end
46
+
47
+ # Generate a unique ID within the scope of this store.
48
+ def generate_unique_id
49
+ @serial_id += 1
50
+ end
51
+
52
+ # Run a query configured by the provided block. If no block is provided, all
53
+ # records are returned. Each record contains the original hash key/value
54
+ # pairs, plus the primary key (indexed by :pk => value).
55
+ def query(&block)
56
+ return @keys.map { |key| @hash[key].merge(:pk => key) } unless block
57
+ q = MemoryQuery.new
58
+ block.call(q)
59
+ q.run(self)
60
+ end
61
+
62
+ protected
63
+
64
+ def valid?(record)
65
+ return false unless record.is_a?(Hash)
66
+ record.keys.all? { |k| k.is_a?(String) && record[k].is_a?(String) }
67
+ end
68
+
69
+ end
70
+
71
+ # MemoryQuery is used internally by MemoryTable to configure a query and run
72
+ # it against the store.
73
+ class MemoryQuery
74
+
75
+ # Initialize a new MemoryQuery.
76
+ def initialize
77
+ @conditions = []
78
+ end
79
+
80
+ # Run a query against the provided table using the conditions stored in this
81
+ # MemoryQuery instance. Returns all records that match all conditions.
82
+ # Conditions are added using #add_condition.
83
+ def run(table)
84
+ table.keys.inject([]) do |result, key|
85
+ if @conditions.all? { |condition| table[key][condition[0]] == condition[2] }
86
+ result << table[key].merge(:pk => key)
87
+ else
88
+ result
89
+ end
90
+ end
91
+ end
92
+
93
+ # Add a condition to this query. The operator parameter is ignored at this
94
+ # time, assuming only equality.
95
+ def add_condition(key, operator, value)
96
+ @conditions << [key, operator, value]
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,269 @@
1
+ module CloudKit
2
+
3
+ # A CloudKit::Resource represents a "resource" in the REST/HTTP sense of the
4
+ # word. It encapsulates a JSON document and its metadata such as its URI, ETag,
5
+ # Last-Modified date, remote user, and historical versions.
6
+ class Resource
7
+
8
+ attr_reader :uri, :etag, :last_modified, :json, :remote_user
9
+
10
+ # Initialize a new instance of a resource.
11
+ #
12
+ # === Parameters
13
+ # - uri - A CloudKit::URI for the resource or its parent collection.
14
+ # - json - A string representing a valid JSON object.
15
+ # - remote_user - Optional. The URI for the user creating the resource.
16
+ # - options - Optional. A hash of other internal properties to set, mostly for internal use.
17
+ def initialize(uri, json, remote_user=nil, options={})
18
+ load_from_options(options.merge(
19
+ :uri => uri,
20
+ :json => json,
21
+ :remote_user => remote_user))
22
+ end
23
+
24
+ # Save the resource. If this is a new resource with only a resource
25
+ # collection URI, its full URI will be generated. ETags and Last-Modified
26
+ # dates are also generated upon saving. No manual reloads are required.
27
+ def save
28
+ @id ||= '%064d' % CloudKit.storage_adapter.generate_unique_id
29
+ @etag = UUID.generate unless @deleted
30
+ @last_modified = Time.now.httpdate
31
+
32
+ CloudKit.storage_adapter[@id] = {
33
+ 'uri' => @uri.cannonical_uri_string,
34
+ 'etag' => escape(@etag),
35
+ 'last_modified' => @last_modified,
36
+ 'json' => @json,
37
+ 'deleted' => escape(@deleted),
38
+ 'archived' => escape(@archived),
39
+ 'remote_user' => escape(@remote_user),
40
+ 'collection_reference' => @collection_reference ||= @uri.collection_uri_fragment,
41
+ 'resource_reference' => @resource_reference ||= @uri.cannonical_uri_string
42
+ }.merge(escape_values(parsed_json))
43
+ reload
44
+ end
45
+
46
+ # Update the json and optionally the remote_user for the current resource.
47
+ # Automatically archives the previous version, generating new ETag and
48
+ # Last-Modified dates. Raises HistoricalIntegrityViolation for attempts to
49
+ # modify resources that are not current.
50
+ def update(json, remote_user=nil)
51
+ raise HistoricalIntegrityViolation unless current?
52
+ transaction do
53
+ record = CloudKit.storage_adapter[@id]
54
+ record['uri'] = "#{@uri.string}/versions/#{@etag}"
55
+ record['archived'] = escape(true)
56
+ CloudKit.storage_adapter[@id] = record
57
+ self.class.create(@uri, json, remote_user || @remote_user)
58
+ end
59
+ reload
60
+ end
61
+
62
+ # Delete the given resource. This is a soft delete, archiving the previous
63
+ # resource and inserting a deleted resource placeholder at the old URI.
64
+ # Raises HistoricalIntegrityViolation for attempts to delete resources that
65
+ # are not current.
66
+ def delete
67
+ raise HistoricalIntegrityViolation unless current?
68
+ transaction do
69
+ original_uri = @uri
70
+ record = CloudKit.storage_adapter[@id]
71
+ record['uri'] = "#{@uri.string}/versions/#{@etag}"
72
+ record['archived'] = escape(true)
73
+ @uri = wrap_uri(record['uri'])
74
+ @archived = unescape(record['archived'])
75
+ CloudKit.storage_adapter[@id] = record
76
+ self.class.new(original_uri, @json, @remote_user, {:deleted => true}).save
77
+ end
78
+ reload
79
+ end
80
+
81
+ # Returns all versions of the given resource, reverse ordered by
82
+ # Last-Modified date, including the current version of the resource.
83
+ def versions
84
+ # TODO make this a collection proxy, only loading the first, then the
85
+ # rest as needed during iteration (possibly in chunks)
86
+ return nil if @archived
87
+ @versions ||= [self].concat(CloudKit.storage_adapter.query { |q|
88
+ q.add_condition('resource_reference', :eql, @resource_reference)
89
+ q.add_condition('archived', :eql, 'true')
90
+ }.reverse.map { |hash| self.class.build_from_hash(hash) })
91
+ end
92
+
93
+ # Returns all previous versions of a resource, reverse ordered by
94
+ # Last-Modified date.
95
+ def previous_versions
96
+ @previous_versions ||= versions[1..-1] rescue []
97
+ end
98
+
99
+ # Returns the most recent previous version of the given resource.
100
+ def previous_version
101
+ @previous_version ||= previous_versions[0]
102
+ end
103
+
104
+ # Returns true if the resource has been deleted.
105
+ def deleted?
106
+ @deleted
107
+ end
108
+
109
+ # Returns true if the resource is archived i.e. not the current version.
110
+ def archived?
111
+ @archived
112
+ end
113
+
114
+ # Returns true if the resource is not archived and not deleted.
115
+ def current?
116
+ !@deleted && !@archived
117
+ end
118
+
119
+ # Returns and caches the parsed JSON representation for this resource.
120
+ def parsed_json
121
+ @parsed_json ||= JSON.parse(@json)
122
+ end
123
+
124
+ # Create a new resource. Intializes and saves in one step.
125
+ #
126
+ # === Parameters
127
+ # - uri - A CloudKit::URI for the resource or its parent collection.
128
+ # - json - A string representing a valid JSON object.
129
+ # - remote_user - Optional. The URI for the user creating the resource.
130
+ def self.create(uri, json, remote_user=nil)
131
+ resource = new(uri, json, remote_user)
132
+ resource.save
133
+ resource
134
+ end
135
+
136
+ # Find all current resources with the given properties. Expectes a hash
137
+ # specifying the search parameters. Returns an array of Resources.
138
+ def self.current(spec={})
139
+ all({:deleted => false, :archived => false}.merge(spec))
140
+ end
141
+
142
+ # Find all resources with the given properties. Expects a hash specifying
143
+ # the search parameters. Returns an array of Resources.
144
+ def self.all(spec={})
145
+ CloudKit.storage_adapter.query { |q|
146
+ spec.keys.each { |k|
147
+ q.add_condition(k.to_s, :eql, escape(spec[k]))
148
+ }
149
+ }.reverse.map { |hash| build_from_hash(hash) }
150
+ end
151
+
152
+ # Find the first matching resource or nil. Expects a hash specifying the
153
+ # search parameters.
154
+ def self.first(spec)
155
+ all(spec)[0]
156
+ end
157
+
158
+ protected
159
+
160
+ def load_from_options(opts)
161
+ options = symbolize_keys(opts)
162
+
163
+ @uri = wrap_uri(options[:uri])
164
+ @json = options[:json]
165
+ @last_modified = options[:last_modified]
166
+ @resource_reference = options[:resource_reference]
167
+ @collection_reference = options[:collection_reference]
168
+ @id = options[:id] || options[:pk] || nil
169
+ @etag = unescape(options[:etag])
170
+ @remote_user = unescape(options[:remote_user])
171
+ @archived = unescape(options[:archived]) || false
172
+ @deleted = unescape(options[:deleted]) || false
173
+ end
174
+
175
+ def reload
176
+ result = CloudKit.storage_adapter.query { |q|
177
+ q.add_condition('uri', :eql, @resource_reference)
178
+ }
179
+ load_from_options(result[0])
180
+ end
181
+
182
+ def self.build_from_hash(data)
183
+ new(
184
+ data['uri'],
185
+ data['json'],
186
+ data['remote_user'],
187
+ {}.filter_merge!(
188
+ :etag => data['etag'],
189
+ :last_modified => data['last_modified'],
190
+ :resource_reference => data['resource_reference'],
191
+ :collection_reference => data['collection_reference'],
192
+ :id => data[:pk],
193
+ :deleted => data['deleted'],
194
+ :archived => data['archived']))
195
+ end
196
+
197
+ def wrap_uri(uri)
198
+ self.class.wrap_uri(uri)
199
+ end
200
+
201
+ def self.wrap_uri(uri)
202
+ case uri
203
+ when CloudKit::URI; uri
204
+ else CloudKit::URI.new(uri)
205
+ end
206
+ end
207
+
208
+ def scope(key)
209
+ "#{@id}:#{key}"
210
+ end
211
+
212
+ def escape(value)
213
+ self.class.escape(value)
214
+ end
215
+
216
+ def self.escape(value)
217
+ case value
218
+ when TrueClass
219
+ "true"
220
+ when FalseClass
221
+ "false"
222
+ when NilClass
223
+ "null"
224
+ when Fixnum, Bignum, Float
225
+ value.to_s
226
+ when Array, Hash
227
+ JSON.generate(value) # temporary bug fix prior to JSONQuery support
228
+ else
229
+ value
230
+ end
231
+ end
232
+
233
+ def unescape(value)
234
+ self.class.unescape(value)
235
+ end
236
+
237
+ def self.unescape(value)
238
+ case value
239
+ when "true"
240
+ true
241
+ when "false"
242
+ false
243
+ when "null"
244
+ nil
245
+ else
246
+ value
247
+ end
248
+ end
249
+
250
+ def symbolize_keys(hash)
251
+ hash.inject({}) { |memo, pair| memo.merge({pair[0].to_sym => pair[1]}) }
252
+ end
253
+
254
+ def escape_values(hash)
255
+ hash.inject({}) { |memo, pair| memo.merge({pair[0] => escape(pair[1])}) }
256
+ end
257
+
258
+ def transaction
259
+ open('.lock', 'w+') do |f|
260
+ f.flock(File::LOCK_EX)
261
+ begin
262
+ yield
263
+ ensure
264
+ f.flock(File::LOCK_UN)
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,52 @@
1
+ module CloudKit
2
+
3
+ # A response wrapper for CloudKit::Store
4
+ class Response
5
+ include Util
6
+
7
+ attr_reader :status, :meta, :content
8
+
9
+ # Create an instance of a Response.
10
+ def initialize(status, meta, content='')
11
+ @status = status; @meta = meta; @content = content
12
+ end
13
+
14
+ # Return the header value specified by key.
15
+ def [](key)
16
+ meta[key]
17
+ end
18
+
19
+ # Set the header specified by key to value.
20
+ def []=(key, value)
21
+ meta[key] = value
22
+ end
23
+
24
+ # Translate to the standard Rack representation: [status, headers, content]
25
+ def to_rack
26
+ meta['Content-Length'] = content.length.to_s
27
+ [status, meta, [content.to_s]]
28
+ end
29
+
30
+ # Parse and return the JSON content
31
+ def parsed_content
32
+ JSON.parse(content)
33
+ end
34
+
35
+ # Clear only the content of the response. Useful for HEAD requests.
36
+ def clear_content
37
+ @content = ''
38
+ end
39
+
40
+ # Return a response suitable for HEAD requests.
41
+ def head
42
+ response = self.dup
43
+ response.clear_content
44
+ response
45
+ end
46
+
47
+ # Return the ETag for this response without the surrounding quotes.
48
+ def etag
49
+ unquote(meta['ETag'])
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,84 @@
1
+ # A set of mixins for building CloudKit::Response objects.
2
+ module CloudKit::ResponseHelpers
3
+ def status_404
4
+ json_error_response(404, 'not found')
5
+ end
6
+
7
+ def status_405(methods)
8
+ response = json_error_response(405, 'method not allowed')
9
+ response['Allow'] = methods.join(', ')
10
+ response
11
+ end
12
+
13
+ def status_410
14
+ json_error_response(410, 'entity previously deleted')
15
+ end
16
+
17
+ def status_412
18
+ json_error_response(412, 'precondition failed')
19
+ end
20
+
21
+ def status_422
22
+ json_error_response(422, 'unprocessable entity')
23
+ end
24
+
25
+ def internal_server_error
26
+ json_error_response(500, 'unknown server error')
27
+ end
28
+
29
+ def data_required
30
+ json_error_response(400, 'data required')
31
+ end
32
+
33
+ def invalid_entity_type
34
+ json_error_response(400, 'valid entity type required')
35
+ end
36
+
37
+ def etag_required
38
+ json_error_response(400, 'etag required')
39
+ end
40
+
41
+ def allow(methods)
42
+ CloudKit::Response.new(
43
+ 200,
44
+ {'Allow' => methods.join(', '), 'Content-Type' => 'application/json'})
45
+ end
46
+
47
+ def response(status, content='', etag=nil, last_modified=nil, options={})
48
+ cache_control = options[:cache] == false ? 'no-cache' : 'proxy-revalidate'
49
+ etag = "\"#{etag}\"" if etag
50
+ headers = {}.filter_merge!(
51
+ 'Content-Type' => 'application/json',
52
+ 'Cache-Control' => cache_control,
53
+ 'Last-Modified' => last_modified,
54
+ 'Location' => options[:location],
55
+ 'ETag' => etag)
56
+ CloudKit::Response.new(status, headers, content)
57
+ end
58
+
59
+ def json_meta_response(uri, etag, last_modified)
60
+ json = json_metadata(uri, etag, last_modified)
61
+ response(200, json, nil, nil, :cache => false)
62
+ end
63
+
64
+ def json_create_response(uri, etag, last_modified)
65
+ json = json_metadata(uri, etag, last_modified)
66
+ response(201, json, nil, nil, {:cache => false, :location => uri})
67
+ end
68
+
69
+ def json_metadata(uri, etag, last_modified)
70
+ JSON.generate(
71
+ :ok => true,
72
+ :uri => uri,
73
+ :etag => etag,
74
+ :last_modified => last_modified)
75
+ end
76
+
77
+ def json_error(message)
78
+ "{\"error\":\"#{message}\"}"
79
+ end
80
+
81
+ def json_error_response(status, message)
82
+ response(status, json_error(message), nil, nil, :cache => false)
83
+ end
84
+ end