cloudkit 0.9.0

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 (59) hide show
  1. data/CHANGES +2 -0
  2. data/COPYING +20 -0
  3. data/README +55 -0
  4. data/Rakefile +35 -0
  5. data/TODO +22 -0
  6. data/cloudkit.gemspec +82 -0
  7. data/doc/curl.html +329 -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 +87 -0
  13. data/doc/main.css +151 -0
  14. data/doc/rest-api.html +358 -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 +10 -0
  20. data/examples/6.ru +10 -0
  21. data/examples/TOC +17 -0
  22. data/lib/cloudkit.rb +74 -0
  23. data/lib/cloudkit/flash_session.rb +22 -0
  24. data/lib/cloudkit/oauth_filter.rb +273 -0
  25. data/lib/cloudkit/oauth_store.rb +56 -0
  26. data/lib/cloudkit/openid_filter.rb +198 -0
  27. data/lib/cloudkit/openid_store.rb +101 -0
  28. data/lib/cloudkit/rack/builder.rb +120 -0
  29. data/lib/cloudkit/rack/router.rb +20 -0
  30. data/lib/cloudkit/request.rb +159 -0
  31. data/lib/cloudkit/service.rb +135 -0
  32. data/lib/cloudkit/store.rb +459 -0
  33. data/lib/cloudkit/store/adapter.rb +9 -0
  34. data/lib/cloudkit/store/extraction_view.rb +57 -0
  35. data/lib/cloudkit/store/response.rb +51 -0
  36. data/lib/cloudkit/store/response_helpers.rb +72 -0
  37. data/lib/cloudkit/store/sql_adapter.rb +36 -0
  38. data/lib/cloudkit/templates/authorize_request_token.erb +19 -0
  39. data/lib/cloudkit/templates/oauth_descriptor.erb +43 -0
  40. data/lib/cloudkit/templates/oauth_meta.erb +8 -0
  41. data/lib/cloudkit/templates/openid_login.erb +31 -0
  42. data/lib/cloudkit/templates/request_authorization.erb +23 -0
  43. data/lib/cloudkit/templates/request_token_denied.erb +18 -0
  44. data/lib/cloudkit/user_store.rb +44 -0
  45. data/lib/cloudkit/util.rb +60 -0
  46. data/test/ext_test.rb +57 -0
  47. data/test/flash_session_test.rb +22 -0
  48. data/test/helper.rb +50 -0
  49. data/test/oauth_filter_test.rb +331 -0
  50. data/test/oauth_store_test.rb +12 -0
  51. data/test/openid_filter_test.rb +54 -0
  52. data/test/openid_store_test.rb +12 -0
  53. data/test/rack_builder_test.rb +41 -0
  54. data/test/request_test.rb +197 -0
  55. data/test/service_test.rb +718 -0
  56. data/test/store_test.rb +99 -0
  57. data/test/user_store_test.rb +12 -0
  58. data/test/util_test.rb +13 -0
  59. metadata +190 -0
@@ -0,0 +1,135 @@
1
+ module CloudKit
2
+
3
+ # A CloudKit Service is Rack middleware providing a REST/HTTP 1.1 interface to
4
+ # a Store. Its primary purpose is initialize and adapt a Store for use in a
5
+ # Rack middleware stack.
6
+ #
7
+ # ==Examples
8
+ #
9
+ # A rackup file exposing _items_ and _things_ as REST collections:
10
+ # require 'cloudkit'
11
+ # expose :items, :things
12
+ #
13
+ # The same as above, adding OpenID and OAuth/Discovery:
14
+ # require 'cloudkit'
15
+ # contain :items, :things
16
+ #
17
+ # An explicit setup, without using the Rack::Builder shortcuts:
18
+ # require 'cloudkit'
19
+ # use Rack::Session::Pool
20
+ # use CloudKit::OAuthFilter
21
+ # use CloudKit::OpenIDFilter
22
+ # use CloudKit::Service, :collections => [:items, :things]
23
+ # run lambda{|env| [200, {}, ['Hello']]}
24
+ #
25
+ # For more examples, including the use of different storage implementations,
26
+ # see the Table of Contents in the examples directory.
27
+ class Service
28
+ include Util
29
+ include ResponseHelpers
30
+
31
+ @@lock = Mutex.new
32
+
33
+ def initialize(app, options)
34
+ @app = app
35
+ @collections = options[:collections]
36
+ end
37
+
38
+ def call(env)
39
+ @@lock.synchronize do
40
+ @store = Store.new(
41
+ :adapter => SQLAdapter.new(env[storage_uri_key]),
42
+ :collections => @collections)
43
+ end unless @store
44
+
45
+ request = Request.new(env)
46
+ unless bypass?(request)
47
+ return auth_config_error if (request.using_auth? && auth_missing?(request))
48
+ return not_implemented unless @store.implements?(request.request_method)
49
+ send(request.request_method.downcase, request) rescue internal_server_error
50
+ else
51
+ @app.call(env)
52
+ end
53
+ end
54
+
55
+ protected
56
+
57
+ def get(request)
58
+ response = @store.get(
59
+ request.path_info,
60
+ {}.filter_merge!(
61
+ :remote_user => request.current_user,
62
+ :offset => request['offset'],
63
+ :limit => request['limit']))
64
+ response['Link'] = link_header(request) if @store.resource_uri?(request.path_info)
65
+ response.to_rack
66
+ end
67
+
68
+ def post(request)
69
+ if tunnel_methods.include?(request['_method'].try(:upcase))
70
+ return send(request['_method'].downcase)
71
+ end
72
+ @store.post(
73
+ request.path_info,
74
+ {:json => request.body.string}.filter_merge!(
75
+ :remote_user => request.current_user)).to_rack
76
+ end
77
+
78
+ def put(request)
79
+ @store.put(
80
+ request.path_info,
81
+ {:json => request.body.string}.filter_merge!(
82
+ :remote_user => request.current_user,
83
+ :etag => request.if_match)).to_rack
84
+ end
85
+
86
+ def delete(request)
87
+ @store.delete(
88
+ request.path_info,
89
+ {}.filter_merge!(
90
+ :remote_user => request.current_user,
91
+ :etag => request.if_match)).to_rack
92
+ end
93
+
94
+ def head(request)
95
+ response = @store.head(
96
+ request.path_info,
97
+ {}.filter_merge!(
98
+ :remote_user => request.current_user,
99
+ :offset => request['offset'],
100
+ :limit => request['limit']))
101
+ response['Link'] = link_header(request) if @store.resource_uri?(request.path_info)
102
+ response.to_rack
103
+ end
104
+
105
+ def options(request)
106
+ @store.options(request.path_info).to_rack
107
+ end
108
+
109
+ def link_header(request)
110
+ base_url = "#{request.scheme}://#{request.env['HTTP_HOST']}#{request.path_info}"
111
+ "<#{base_url}/versions>; rel=\"http://joncrosby.me/cloudkit/1.0/rel/versions\""
112
+ end
113
+
114
+ def auth_missing?(request)
115
+ request.current_user == nil
116
+ end
117
+
118
+ def tunnel_methods
119
+ ['PUT', 'DELETE', 'OPTIONS', 'HEAD', 'TRACE']
120
+ end
121
+
122
+ def not_implemented
123
+ json_error_response(501, 'not implemented').to_rack
124
+ end
125
+
126
+ def auth_config_error
127
+ json_error_response(500, 'server auth misconfigured').to_rack
128
+ end
129
+
130
+ def bypass?(request)
131
+ collection = @collections.detect{|type| request.path_info.match("/#{type.to_s}")}
132
+ !collection && !@store.meta_uri?(request.path_info)
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,459 @@
1
+ module CloudKit
2
+
3
+ # A functional storage interface with HTTP semantics and pluggable adapters.
4
+ class Store
5
+ include CloudKit::Util
6
+ include ResponseHelpers
7
+
8
+ # Initialize a new Store, creating its schema if needed. All resources in a
9
+ # Store are automatically versioned.
10
+ #
11
+ # ===Options
12
+ # - :adapter - Optional. An instance of Adapter. Defaults to in-memory SQLite.
13
+ # - :collections - Array of resource collections to manage.
14
+ # - :views - Optional. Array of views to be updated based on JSON content.
15
+ #
16
+ # ===Example
17
+ # store = CloudKit::Store.new(:collections => [:foos, :bars])
18
+ #
19
+ # ===Example
20
+ # adapter = CloudKit::SQLAdapter.new('mysql://user:pass@localhost/my_db')
21
+ # fruit_color_view = CloudKit::ExtractionView.new(
22
+ # :fruits_by_color_and_season,
23
+ # :observe => :fruits,
24
+ # :extract => [:color, :season])
25
+ # store = CloudKit::Store.new(
26
+ # :adapter => adapter,
27
+ # :collections => [:foos, :fruits],
28
+ # :views => [fruit_color_view])
29
+ #
30
+ # See also: Adapter, ExtractionView, Response
31
+ #
32
+ def initialize(options)
33
+ @db = options[:adapter] || SQLAdapter.new
34
+ @collections = options[:collections]
35
+ @views = options[:views]
36
+ @views.each {|view| view.initialize_storage(@db)} if @views
37
+ end
38
+
39
+ # Retrieve a resource or collection of resources based on a URI.
40
+ #
41
+ # ===Parameters
42
+ # - uri - URI of the resource or collection to retrieve.
43
+ # - options - See below.
44
+ #
45
+ # ===Options
46
+ # - :remote_user - Optional. Scopes the dataset if provided.
47
+ # - :limit - Optional. Default is unlimited. Limit the number of records returned by a collection request.
48
+ # - :offset - Optional. Start the list of resources in a collection at offset (0-based).
49
+ # - :any - Optional. Not a literal ":any", but any key or keys defined as extrations from a view.
50
+ #
51
+ # ===URI Types
52
+ # /cloudkit-meta
53
+ # /{collection}
54
+ # /{collection}/{uuid}
55
+ # /{collection}/{uuid}/versions
56
+ # /{collection}/{uuid}/versions/{etag}
57
+ # /{view}
58
+ #
59
+ # ===Examples
60
+ # get('/cloudkit-meta')
61
+ # get('/foos')
62
+ # get('/foos', :remote_user => 'coltrane')
63
+ # get('/foos', :limit => 100, :offset => 200)
64
+ # get('/foos/123')
65
+ # get('/foos/123/versions')
66
+ # get('/foos/123/versions/abc')
67
+ # get('/shiny_foos', :color => 'green')
68
+ #
69
+ # See also: {REST API}[http://getcloudkit.com/rest-api.html]
70
+ #
71
+ def get(uri, options={})
72
+ return invalid_entity_type if !valid_collection_type?(collection_type(uri))
73
+ return meta if meta_uri?(uri)
74
+ return resource_collection(uri, options) if resource_collection_uri?(uri)
75
+ return resource(uri, options) if resource_uri?(uri)
76
+ return version_collection(uri, options) if version_collection_uri?(uri)
77
+ return resource_version(uri, options) if resource_version_uri?(uri)
78
+ return view(uri, options) if view_uri?(uri)
79
+ status_404
80
+ end
81
+
82
+ # Retrieve the same items as the get method, minus the content/body. Using
83
+ # this method on a single resource URI performs a slight optimization due
84
+ # to the way CloudKit stores its ETags and Last-Modified information on
85
+ # write.
86
+ def head(uri, options={})
87
+ return invalid_entity_type unless @collections.include?(collection_type(uri))
88
+ if resource_uri?(uri) || resource_version_uri?(uri)
89
+ # ETag and Last-Modified are already stored for single items, so a slight
90
+ # optimization can be made for HEAD requests.
91
+ result = @db[store_key].
92
+ select(:etag, :last_modified, :deleted).
93
+ filter(options.merge(:uri => uri))
94
+ if result.any?
95
+ result = result.first
96
+ return status_410.head if result[:deleted]
97
+ return response(200, '', result[:etag], result[:last_modified])
98
+ end
99
+ status_404.head
100
+ else
101
+ get(uri, options).head
102
+ end
103
+ end
104
+
105
+ # Update or create a resource at the specified URI. If the resource already
106
+ # exists, an :etag option is required.
107
+ def put(uri, options={})
108
+ methods = methods_for_uri(uri)
109
+ return status_405(methods) unless methods.include?('PUT')
110
+ return invalid_entity_type unless @collections.include?(collection_type(uri))
111
+ return data_required unless options[:json]
112
+ current_resource = resource(uri, options.excluding(:json, :etag, :remote_user))
113
+ return update_resource(uri, options) if current_resource.status == 200
114
+ create_resource(uri, options)
115
+ end
116
+
117
+ # Create a resource in a given collection.
118
+ def post(uri, options={})
119
+ methods = methods_for_uri(uri)
120
+ return status_405(methods) unless methods.include?('POST')
121
+ return invalid_entity_type unless @collections.include?(collection_type(uri))
122
+ return data_required unless options[:json]
123
+ uri = "#{collection_uri_fragment(uri)}/#{UUID.generate}"
124
+ create_resource(uri, options)
125
+ end
126
+
127
+ # Delete the resource specified by the URI. Requires the :etag option.
128
+ def delete(uri, options={})
129
+ methods = methods_for_uri(uri)
130
+ return status_405(methods) unless methods.include?('DELETE')
131
+ return invalid_entity_type unless @collections.include?(collection_type(uri))
132
+ return etag_required unless options[:etag]
133
+ original = @db[store_key].
134
+ filter(options.excluding(:etag).merge(:uri => uri))
135
+ if original.any?
136
+ item = original.first
137
+ return status_404 unless item[:remote_user] == options[:remote_user]
138
+ return status_410 if item[:deleted]
139
+ return status_412 if item[:etag] != options[:etag]
140
+ version_uri = ''
141
+ @db.transaction do
142
+ version_uri = "#{item[:uri]}/versions/#{item[:etag]}"
143
+ original.update(:uri => version_uri)
144
+ @db[store_key].insert(
145
+ :uri => item[:uri],
146
+ :collection_reference => item[:collection_reference],
147
+ :resource_reference => item[:resource_reference],
148
+ :remote_user => item[:remote_user],
149
+ :content => item[:content],
150
+ :deleted => true)
151
+ unmap(uri)
152
+ end
153
+ return json_meta_response(200, version_uri, item[:etag], item[:last_modified])
154
+ end
155
+ status_404
156
+ end
157
+
158
+ # Build a response containing the allowed methods for a given URI.
159
+ def options(uri)
160
+ methods = methods_for_uri(uri)
161
+ allow(methods)
162
+ end
163
+
164
+ # Return a list of allowed methods for a given URI.
165
+ def methods_for_uri(uri)
166
+ if meta_uri?(uri)
167
+ meta_methods
168
+ elsif resource_collection_uri?(uri)
169
+ resource_collection_methods
170
+ elsif resource_uri?(uri)
171
+ resource_methods
172
+ elsif version_collection_uri?(uri)
173
+ version_collection_methods
174
+ elsif resource_version_uri?(uri)
175
+ resource_version_methods
176
+ end
177
+ end
178
+
179
+ # Return the list of methods allowed for the cloudkit-meta URI.
180
+ def meta_methods
181
+ @meta_methods ||= http_methods.excluding('POST', 'PUT', 'DELETE')
182
+ end
183
+
184
+ # Return the list of methods allowed for a resource collection.
185
+ def resource_collection_methods
186
+ @resource_collection_methods ||= http_methods.excluding('PUT', 'DELETE')
187
+ end
188
+
189
+ # Return the list of methods allowed on an individual resource.
190
+ def resource_methods
191
+ @resource_methods ||= http_methods.excluding('POST')
192
+ end
193
+
194
+ # Return the list of methods allowed on a version history collection.
195
+ def version_collection_methods
196
+ @version_collection_methods ||= http_methods.excluding('POST', 'PUT', 'DELETE')
197
+ end
198
+
199
+ # Return the list of methods allowed on a resource version.
200
+ def resource_version_methods
201
+ @resource_version_methods ||= http_methods.excluding('POST', 'PUT', 'DELETE')
202
+ end
203
+
204
+ # Return true if this store implements a given HTTP method.
205
+ def implements?(http_method)
206
+ http_methods.include?(http_method.upcase)
207
+ end
208
+
209
+ # Return the list of HTTP methods supported by this Store.
210
+ def http_methods
211
+ ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS']
212
+ end
213
+
214
+ # Return the resource collection URI fragment.
215
+ # Example: collection_uri_fragment('/foos/123') => '/foos
216
+ def collection_uri_fragment(uri)
217
+ "/#{uri_components(uri)[0]}" rescue nil
218
+ end
219
+
220
+ # Return the resource collection referenced by a URI.
221
+ # Example: collection_type('/foos/123') => :foos
222
+ def collection_type(uri)
223
+ uri_components(uri)[0].to_sym rescue nil
224
+ end
225
+
226
+ # Return the URI for the current version of a resource.
227
+ # Example: current_resource_uri('/foos/123/versions/abc') => '/foos/123'
228
+ def current_resource_uri(uri)
229
+ "/#{uri_components(uri)[0..1].join('/')}" rescue nil
230
+ end
231
+
232
+ # Splits a URI into its components
233
+ def uri_components(uri)
234
+ uri.split('/').reject{|x| x == '' || x == nil} rescue []
235
+ end
236
+
237
+ # Returns true if URI matches /cloudkit-meta
238
+ def meta_uri?(uri)
239
+ c = uri_components(uri)
240
+ return c.size == 1 && c[0] == 'cloudkit-meta'
241
+ end
242
+
243
+ # Returns true if URI matches /{collection}
244
+ def resource_collection_uri?(uri)
245
+ c = uri_components(uri)
246
+ return c.size == 1 && @collections.include?(c[0].to_sym)
247
+ end
248
+
249
+ # Returns true if URI matches /{collection}/{uuid}
250
+ def resource_uri?(uri)
251
+ c = uri_components(uri)
252
+ return c.size == 2 && @collections.include?(c[0].to_sym)
253
+ end
254
+
255
+ # Returns true if URI matches /{collection}/{uuid}/versions
256
+ def version_collection_uri?(uri)
257
+ c = uri_components(uri)
258
+ return c.size == 3 && @collections.include?(c[0].to_sym) && c[2] == 'versions'
259
+ end
260
+
261
+ # Returns true if URI matches /{collection}/{uuid}/versions/{etag}
262
+ def resource_version_uri?(uri)
263
+ c = uri_components(uri)
264
+ return c.size == 4 && @collections.include?(c[0].to_sym) && c[2] == 'versions'
265
+ end
266
+
267
+ # Returns true if URI matches /{view}
268
+ def view_uri?(uri)
269
+ c = uri_components(uri)
270
+ return c.size == 1 && @views && @views.map{|v| v.name}.include?(c[0].to_sym)
271
+ end
272
+
273
+ # Return an array containing the response for each URI in a list.
274
+ def resolve_uris(uris)
275
+ result = []
276
+ uris.each do |uri|
277
+ result << get(uri)
278
+ end
279
+ result
280
+ end
281
+
282
+ # Clear all contents of the store. Used mostly for testing.
283
+ def reset!
284
+ @db.schema.keys.each do |table|
285
+ @db[table].delete
286
+ end
287
+ end
288
+
289
+ # Return the version number of this Store.
290
+ def version; 1; end
291
+
292
+ protected
293
+
294
+ # Return the list of collections managed by this Store.
295
+ def meta
296
+ json = JSON.generate(:uris => @collections.map{|t| "/#{t}"})
297
+ response(200, json, build_etag(json))
298
+ end
299
+
300
+ # Return a list of resource URIs for the given collection URI.
301
+ def resource_collection(uri, options)
302
+ result = @db[store_key].
303
+ select(:uri, :last_modified).
304
+ filter(options.excluding(:offset, :limit).merge(:deleted => false)).
305
+ filter(:collection_reference => collection_uri_fragment(uri)).
306
+ filter('resource_reference = uri').
307
+ reverse_order(:id)
308
+ bundle_collection_result(uri, options, result)
309
+ end
310
+
311
+ # Return the resource for the given URI. Return 404 if not found or if
312
+ # protected and unauthorized, 410 if authorized but deleted.
313
+ def resource(uri, options)
314
+ result = @db[store_key].
315
+ select(:content, :etag, :last_modified, :deleted).
316
+ filter(options.merge!(:uri => uri))
317
+ if result.any?
318
+ result = result.first
319
+ return status_410 if result[:deleted]
320
+ return response(200, result[:content], result[:etag], result[:last_modified])
321
+ end
322
+ status_404
323
+ end
324
+
325
+ # Return a collection of URIs for all versions of a resource including the
326
+ #current version. Sorted by Last-Modified date in descending order.
327
+ def version_collection(uri, options)
328
+ found = @db[store_key].
329
+ select(:uri).
330
+ filter(options.excluding(:offset, :limit).merge(
331
+ :uri => current_resource_uri(uri)))
332
+ return status_404 unless found.any?
333
+ result = @db[store_key].
334
+ select(:uri, :last_modified).
335
+ filter(:resource_reference => current_resource_uri(uri)).
336
+ filter(options.excluding(:offset, :limit).merge(:deleted => false)).
337
+ reverse_order(:id)
338
+ bundle_collection_result(uri, options, result)
339
+ end
340
+
341
+ # Return a specific version of a resource.
342
+ def resource_version(uri, options)
343
+ result = @db[store_key].
344
+ select(:content, :etag, :last_modified).
345
+ filter(options.merge(:uri => uri))
346
+ return status_404 unless result.any?
347
+ result = result.first
348
+ response(200, result[:content], result[:etag], result[:last_modified])
349
+ end
350
+
351
+ # Return a list of URIs for all resources matching the list of key value
352
+ # pairs provided in the options arg.
353
+ def view(uri, options)
354
+ result = @db[collection_type(uri)].
355
+ select(:uri).
356
+ filter(options.excluding(:offset, :limit))
357
+ bundle_collection_result(uri, options, result)
358
+ end
359
+
360
+ # Create a resource at the specified URI.
361
+ def create_resource(uri, options)
362
+ data = JSON.parse(options[:json]) rescue (return status_422)
363
+ etag = UUID.generate
364
+ last_modified = timestamp
365
+ @db[store_key].insert(
366
+ :uri => uri,
367
+ :collection_reference => collection_uri_fragment(uri),
368
+ :resource_reference => uri,
369
+ :etag => etag,
370
+ :last_modified => last_modified,
371
+ :remote_user => options[:remote_user],
372
+ :content => options[:json])
373
+ map(uri, data)
374
+ json_meta_response(201, uri, etag, last_modified)
375
+ end
376
+
377
+ # Update the resource at the specified URI. Requires the :etag option.
378
+ def update_resource(uri, options)
379
+ data = JSON.parse(options[:json]) rescue (return status_422)
380
+ original = @db[store_key].
381
+ filter(options.excluding(:json, :etag).merge(:uri => uri))
382
+ if original.any?
383
+ item = original.first
384
+ return status_404 unless item[:remote_user] == options[:remote_user]
385
+ return etag_required unless options[:etag]
386
+ return status_412 unless options[:etag] == item[:etag]
387
+ etag = UUID.generate
388
+ last_modified = timestamp
389
+ @db.transaction do
390
+ original.update(:uri => "#{uri}/versions/#{item[:etag]}")
391
+ @db[store_key].insert(
392
+ :uri => uri,
393
+ :collection_reference => item[:collection_reference],
394
+ :resource_reference => item[:resource_reference],
395
+ :etag => etag,
396
+ :last_modified => last_modified,
397
+ :remote_user => options[:remote_user],
398
+ :content => options[:json])
399
+ end
400
+ map(uri, data)
401
+ return json_meta_response(200, uri, etag, last_modified)
402
+ end
403
+ status_404
404
+ end
405
+
406
+ # Bundle a collection of results as a list of URIs for the response.
407
+ def bundle_collection_result(uri, options, result)
408
+ total = result.count
409
+ offset = options[:offset].try(:to_i) || 0
410
+ max = options[:limit] ? offset + options[:limit].to_i : total
411
+ list = result.all[offset...max].map{|r| r[:uri]}
412
+ json = uri_list(list, total, offset)
413
+ last_modified = result.first[:last_modified] if result.any?
414
+ response(200, json, build_etag(json), last_modified)
415
+ end
416
+
417
+ # Generate a JSON URI list.
418
+ def uri_list(list, total, offset)
419
+ JSON.generate(:total => total, :offset => offset, :uris => list)
420
+ end
421
+
422
+ # Build an ETag for a collection. ETags are generated on write as an
423
+ # optimization for GETs. This method is used for collections of resources
424
+ # where the optimization is not practical.
425
+ def build_etag(data)
426
+ MD5::md5(data.to_s).hexdigest
427
+ end
428
+
429
+ # Returns true if the collection type represents a view.
430
+ def is_view?(collection_type)
431
+ @views && @views.map{|v| v.name}.include?(collection_type)
432
+ end
433
+
434
+ # Returns true if the collection type is valid for this Store.
435
+ def valid_collection_type?(collection_type)
436
+ @collections.include?(collection_type) ||
437
+ is_view?(collection_type) ||
438
+ collection_type.to_s == 'cloudkit-meta'
439
+ end
440
+
441
+ # Delegates the mapping of data from a resource into a view.
442
+ def map(uri, data)
443
+ @views.each{|view| view.map(@db, collection_type(uri), uri, data)} if @views
444
+ end
445
+
446
+ # Delegates removal of view data.
447
+ def unmap(uri)
448
+ @views.each{|view| view.unmap(@db, collection_type(uri), uri)} if @views
449
+ end
450
+
451
+ # Return a HTTP date representing 'now.'
452
+ def timestamp
453
+ Time.now.httpdate
454
+ end
455
+
456
+ # Return the adapter instance used by this Store.
457
+ def db; @db; end
458
+ end
459
+ end