cloudkit-jruby 0.11.2

Sign up to get free protection for your applications and to get access to all the features.
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,20 @@
1
+ module Rack #:nodoc:
2
+
3
+ # A minimal router providing just what is needed for the OAuth and OpenID
4
+ # filters.
5
+ class Router
6
+
7
+ # Create an instance of Router to match on method, path and params.
8
+ def initialize(method, path, params=[])
9
+ @method = method.to_s.upcase; @path = path; @params = params
10
+ end
11
+
12
+ # By overriding the case comparison operator, we can match routes in a case
13
+ # statement.
14
+ #
15
+ # See also: CloudKit::Util#r, CloudKit::Request#match?
16
+ def ===(request)
17
+ request.match?(@method, @path, @params)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,177 @@
1
+ module CloudKit
2
+
3
+ # A subclass of Rack::Request providing CloudKit-specific features.
4
+ class Request < Rack::Request
5
+ include CloudKit::Util
6
+ alias_method :cloudkit_params, :params
7
+
8
+ def initialize(env)
9
+ super(env)
10
+ end
11
+
12
+ # Return a merged set of both standard params and OAuth header params.
13
+ def params
14
+ @cloudkit_params ||= cloudkit_params.merge(oauth_header_params)
15
+ end
16
+
17
+ # Return the JSON content from the request body
18
+ def json
19
+ self.body.rewind
20
+ raw = self.body.read
21
+ # extract the json from the body to avoid tunneled _method param from being parsed as json
22
+ (matches = raw.match(/(\{.*\})/)) ? matches[1] : raw
23
+ end
24
+
25
+ # Return a CloudKit::URI instance representing the rack request's path info.
26
+ def uri
27
+ @uri ||= CloudKit::URI.new(self.path_info)
28
+ end
29
+
30
+ # Return true if method, path, and required_params match.
31
+ def match?(method, path, required_params=[])
32
+ (request_method == method) &&
33
+ path_info.match(path.gsub(':id', '*')) && # just enough to work for now
34
+ param_match?(required_params)
35
+ end
36
+
37
+ # Return true of the array of required params match the request params. If
38
+ # a hash in passed in for a param, its value is also used in the match.
39
+ def param_match?(required_params)
40
+ required_params.all? do |required_param|
41
+ case required_param
42
+ when Hash
43
+ key = required_param.keys.first
44
+ return false unless params.has_key? key
45
+ return false unless params[key] == required_param[key]
46
+ when String
47
+ return false unless params.has_key? required_param
48
+ else
49
+ false
50
+ end
51
+ true
52
+ end
53
+ end
54
+
55
+ # Return OAuth header params in a hash.
56
+ def oauth_header_params
57
+ # This is a copy of the same method from the OAuth gem.
58
+ # TODO: Refactor the OAuth gem so that this method is available via a
59
+ # mixin, outside of the request proxy context.
60
+ %w( X-HTTP_AUTHORIZATION Authorization HTTP_AUTHORIZATION ).each do |header|
61
+ next unless @env.include?(header)
62
+ header = @env[header]
63
+ next unless header[0,6] == 'OAuth '
64
+ oauth_param_string = header[6,header.length].split(/[,=]/)
65
+ oauth_param_string.map!{|v| unescape(v.strip)}
66
+ oauth_param_string.map!{|v| v =~ /^\".*\"$/ ? v[1..-2] : v}
67
+ oauth_params = Hash[*oauth_param_string.flatten]
68
+ oauth_params.reject!{|k,v| k !~ /^oauth_/}
69
+ return oauth_params
70
+ end
71
+ return {}
72
+ end
73
+
74
+ # Unescape a value according to the OAuth spec.
75
+ def unescape(value)
76
+ ::URI.unescape(value.gsub('+', '%2B'))
77
+ end
78
+
79
+ # Return the last path element in the request URI.
80
+ def last_path_element
81
+ path_element(-1)
82
+ end
83
+
84
+ # Return a specific path element
85
+ def path_element(index)
86
+ path_info.split('/')[index] rescue nil
87
+ end
88
+
89
+ # Return an array containing one entry for each piece of upstream
90
+ # middleware. This is in the same spirit as Via headers in HTTP, but does
91
+ # not use the header because the transition from one piece of middleware to
92
+ # the next does not use HTTP.
93
+ def via
94
+ @env[CLOUDKIT_VIA].split(', ') rescue []
95
+ end
96
+
97
+ # Return parsed contents of an If-Match header.
98
+ #
99
+ # Note: Only a single ETag is useful in the context of CloudKit, so a list
100
+ # is treated as one ETag; the result of using the wrong ETag or a list of
101
+ # ETags is the same in the context of PUT and DELETE where If-Match
102
+ # headers are required.
103
+ def if_match
104
+ etag = @env['HTTP_IF_MATCH']
105
+ return nil unless etag
106
+ etag.strip!
107
+ etag = unquote(etag)
108
+ return nil if etag == '*'
109
+ etag
110
+ end
111
+
112
+ # Add a via entry to the Rack environment.
113
+ def inject_via(key)
114
+ items = via << key
115
+ @env[CLOUDKIT_VIA] = items.join(', ')
116
+ end
117
+
118
+ # Return the current user URI.
119
+ def current_user
120
+ return nil unless @env[CLOUDKIT_AUTH_KEY] && @env[CLOUDKIT_AUTH_KEY] != ''
121
+ @env[CLOUDKIT_AUTH_KEY]
122
+ end
123
+
124
+ # Set the current user URI.
125
+ def current_user=(user)
126
+ @env[CLOUDKIT_AUTH_KEY] = user
127
+ end
128
+
129
+ # Return true if authentication is being used.
130
+ def using_auth?
131
+ @env[CLOUDKIT_AUTH_PRESENCE] != nil
132
+ end
133
+
134
+ # Report to downstream middleware that authentication is in use.
135
+ def announce_auth(via)
136
+ inject_via(via)
137
+ @env[CLOUDKIT_AUTH_PRESENCE] = 1
138
+ end
139
+
140
+ # Return the session associated with this request.
141
+ def session
142
+ @env['rack.session']
143
+ end
144
+
145
+ # Return the login URL for this request. This is stashed in the Rack
146
+ # environment so the OpenID and OAuth middleware can cooperate during the
147
+ # token authorization step in the OAuth flow.
148
+ def login_url
149
+ @env[CLOUDKIT_LOGIN_URL] || '/login'
150
+ end
151
+
152
+ # Set the login url for this request.
153
+ def login_url=(url)
154
+ @env[CLOUDKIT_LOGIN_URL] = url
155
+ end
156
+
157
+ # Return the logout URL for this request.
158
+ def logout_url
159
+ @env[CLOUDKIT_LOGOUT_URL] || '/logout'
160
+ end
161
+
162
+ # Set the logout URL for this request.
163
+ def logout_url=(url)
164
+ @env[CLOUDKIT_LOGOUT_URL] = url
165
+ end
166
+
167
+ # Return the flash session for this request.
168
+ def flash
169
+ session[CLOUDKIT_FLASH] ||= CloudKit::FlashSession.new
170
+ end
171
+
172
+ # Return the host and scheme
173
+ def domain_root
174
+ "#{scheme}://#{@env['HTTP_HOST']}"
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,162 @@
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 to 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, {'Content-Type' => 'text/html'}, ['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 ResponseHelpers
29
+
30
+ @@lock = Mutex.new
31
+
32
+ attr_reader :store
33
+
34
+ def initialize(app, options)
35
+ @app = app
36
+ @collections = options[:collections]
37
+ end
38
+
39
+ def call(env)
40
+ @@lock.synchronize do
41
+ @store = Store.new(:collections => @collections)
42
+ end unless @store
43
+
44
+ request = Request.new(env)
45
+ unless bypass?(request)
46
+ return auth_config_error if (request.using_auth? && auth_missing?(request))
47
+ return not_implemented unless @store.implements?(request.request_method)
48
+ send(request.request_method.downcase, request) rescue internal_server_error.to_rack
49
+ else
50
+ @app.call(env)
51
+ end
52
+ end
53
+
54
+ protected
55
+
56
+ def get(request)
57
+ response = @store.get(
58
+ request.uri,
59
+ {}.filter_merge!(
60
+ :remote_user => request.current_user,
61
+ :offset => request['offset'],
62
+ :limit => request['limit']))
63
+ inject_link_headers(request, response)
64
+ response.to_rack
65
+ end
66
+
67
+ def post(request)
68
+ if tunnel_methods.include?(request['_method'].try(:upcase))
69
+ return send(request['_method'].downcase, request)
70
+ end
71
+ response = @store.post(
72
+ request.uri,
73
+ {:json => request.json}.filter_merge!(
74
+ :remote_user => request.current_user))
75
+ update_location_header(request, response)
76
+ response.to_rack
77
+ end
78
+
79
+ def put(request)
80
+ response = @store.put(
81
+ request.uri,
82
+ {:json => request.json}.filter_merge!(
83
+ :remote_user => request.current_user,
84
+ :etag => request.if_match))
85
+ update_location_header(request, response)
86
+ response.to_rack
87
+ end
88
+
89
+ def delete(request)
90
+ @store.delete(
91
+ request.uri,
92
+ {}.filter_merge!(
93
+ :remote_user => request.current_user,
94
+ :etag => request.if_match)).to_rack
95
+ end
96
+
97
+ def head(request)
98
+ response = @store.head(
99
+ request.uri,
100
+ {}.filter_merge!(
101
+ :remote_user => request.current_user,
102
+ :offset => request['offset'],
103
+ :limit => request['limit']))
104
+ inject_link_headers(request, response)
105
+ response.to_rack
106
+ end
107
+
108
+ def options(request)
109
+ @store.options(request.uri).to_rack
110
+ end
111
+
112
+ def inject_link_headers(request, response)
113
+ response['Link'] = versions_link_header(request) if request.uri.resource_uri?
114
+ response['Link'] = resolved_link_header(request) if request.uri.resource_collection_uri?
115
+ response['Link'] = index_link_header(request) if request.uri.resolved_resource_collection_uri?
116
+ response['Link'] = resolved_link_header(request) if request.uri.version_collection_uri?
117
+ response['Link'] = index_link_header(request) if request.uri.resolved_version_collection_uri?
118
+ end
119
+
120
+ def versions_link_header(request)
121
+ base_url = "#{request.domain_root}#{request.path_info}"
122
+ "<#{base_url}/versions>; rel=\"http://joncrosby.me/cloudkit/1.0/rel/versions\""
123
+ end
124
+
125
+ def resolved_link_header(request)
126
+ base_url = "#{request.domain_root}#{request.path_info}"
127
+ "<#{base_url}/_resolved>; rel=\"http://joncrosby.me/cloudkit/1.0/rel/resolved\""
128
+ end
129
+
130
+ def index_link_header(request)
131
+ index_path = request.path_info.sub(/\/_resolved(\/)*$/, '')
132
+ base_url = "#{request.domain_root}#{index_path}"
133
+ "<#{base_url}>; rel=\"index\""
134
+ end
135
+
136
+ def update_location_header(request, response)
137
+ return unless response['Location']
138
+ response['Location'] = "#{request.domain_root}#{response['Location']}"
139
+ end
140
+
141
+ def auth_missing?(request)
142
+ request.current_user == nil
143
+ end
144
+
145
+ def tunnel_methods
146
+ ['PUT', 'DELETE', 'OPTIONS', 'HEAD', 'TRACE']
147
+ end
148
+
149
+ def not_implemented
150
+ json_error_response(501, 'not implemented').to_rack
151
+ end
152
+
153
+ def auth_config_error
154
+ json_error_response(500, 'server auth misconfigured').to_rack
155
+ end
156
+
157
+ def bypass?(request)
158
+ collection = @collections.detect{|type| request.path_info.match("/#{type.to_s}")}
159
+ !collection && !request.uri.meta_uri?
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,349 @@
1
+ module CloudKit
2
+
3
+ # A functional storage interface with HTTP semantics and pluggable adapters.
4
+ class Store
5
+ include ResponseHelpers
6
+ include CloudKit::Util
7
+
8
+ # Initialize a new Store. All resources in a Store are automatically
9
+ # versioned.
10
+ #
11
+ # ===Options
12
+ # - :collections - Array of resource collections to manage.
13
+ #
14
+ # ===Example
15
+ # store = CloudKit::Store.new(:collections => [:foos, :bars])
16
+ #
17
+ # See also: Response
18
+ #
19
+ def initialize(options)
20
+ CloudKit.setup_storage_adapter unless CloudKit.storage_adapter
21
+ @collections = options[:collections]
22
+ end
23
+
24
+ # Retrieve a resource or collection of resources based on a URI.
25
+ #
26
+ # ===Parameters
27
+ # - uri - URI of the resource or collection to retrieve.
28
+ # - options - See below.
29
+ #
30
+ # ===Options
31
+ # - :remote_user - Optional. Scopes the dataset if provided.
32
+ # - :limit - Optional. Default is unlimited. Limit the number of records returned by a collection request.
33
+ # - :offset - Optional. Start the list of resources in a collection at offset (0-based).
34
+ # - :any - Optional. Not a literal ":any", but any key or keys that are top level JSON keys. This is a starting point for future JSONPath/JSONQuery support.
35
+ #
36
+ # ===URI Types
37
+ # /cloudkit-meta
38
+ # /{collection}
39
+ # /{collection}/_resolved
40
+ # /{collection}/{uuid}
41
+ # /{collection}/{uuid}/versions
42
+ # /{collection}/{uuid}/versions/_resolved
43
+ # /{collection}/{uuid}/versions/{etag}
44
+ #
45
+ # ===Examples
46
+ # get('/cloudkit-meta')
47
+ # get('/foos')
48
+ # get('/foos', :remote_user => 'coltrane')
49
+ # get('/foos', :limit => 100, :offset => 200)
50
+ # get('/foos/123')
51
+ # get('/foos/123/versions')
52
+ # get('/foos/123/versions/abc')
53
+ #
54
+ # See also: {REST API}[http://getcloudkit.com/rest-api.html]
55
+ #
56
+ def get(uri, options={})
57
+ return invalid_entity_type if !valid_collection_type?(uri.collection_type)
58
+ return meta if uri.meta_uri?
59
+ return resource_collection(uri, options) if uri.resource_collection_uri?
60
+ return resolved_resource_collection(uri, options) if uri.resolved_resource_collection_uri?
61
+ return resource(uri, options) if uri.resource_uri?
62
+ return version_collection(uri, options) if uri.version_collection_uri?
63
+ return resolved_version_collection(uri, options) if uri.resolved_version_collection_uri?
64
+ return resource_version(uri, options) if uri.resource_version_uri?
65
+ status_404
66
+ end
67
+
68
+ # Retrieve the same items as the get method, minus the content/body. Using
69
+ # this method on a single resource URI performs a slight optimization due
70
+ # to the way CloudKit stores its ETags and Last-Modified information on
71
+ # write.
72
+ def head(uri, options={})
73
+ return invalid_entity_type unless @collections.include?(uri.collection_type)
74
+ if uri.resource_uri? || uri.resource_version_uri?
75
+ # ETag and Last-Modified are already stored for single items, so a slight
76
+ # optimization can be made for HEAD requests.
77
+ result = CloudKit::Resource.first(options.merge(:uri => uri.string))
78
+ return status_404.head unless result
79
+ return status_410.head if result.deleted?
80
+ return response(200, '', result.etag, result.last_modified)
81
+ else
82
+ get(uri, options).head
83
+ end
84
+ end
85
+
86
+ # Update or create a resource at the specified URI. If the resource already
87
+ # exists, an :etag option is required.
88
+ def put(uri, options={})
89
+ methods = methods_for_uri(uri)
90
+ return status_405(methods) unless methods.include?('PUT')
91
+ return invalid_entity_type unless @collections.include?(uri.collection_type)
92
+ return data_required unless options[:json]
93
+ current_resource = resource(uri, options.excluding(:json, :etag, :remote_user))
94
+ return update_resource(uri, options) if current_resource.status == 200
95
+ return current_resource if current_resource.status == 410
96
+ create_resource(uri, options)
97
+ end
98
+
99
+ # Create a resource in a given collection.
100
+ def post(uri, options={})
101
+ methods = methods_for_uri(uri)
102
+ return status_405(methods) unless methods.include?('POST')
103
+ return invalid_entity_type unless @collections.include?(uri.collection_type)
104
+ return data_required unless options[:json]
105
+ create_resource(uri, options)
106
+ end
107
+
108
+ # Delete the resource specified by the URI. Requires the :etag option.
109
+ def delete(uri, options={})
110
+ methods = methods_for_uri(uri)
111
+ return status_405(methods) unless methods.include?('DELETE')
112
+ return invalid_entity_type unless @collections.include?(uri.collection_type)
113
+ return etag_required unless options[:etag]
114
+ resource = CloudKit::Resource.first(options.excluding(:etag).merge(:uri => uri.string))
115
+ return status_404 unless (resource && (resource.remote_user == options[:remote_user]))
116
+ return status_410 if resource.deleted?
117
+ return status_412 if resource.etag != options[:etag]
118
+
119
+ resource.delete
120
+ archived_resource = resource.previous_version
121
+ return json_meta_response(archived_resource.uri.string, archived_resource.etag, resource.last_modified)
122
+ end
123
+
124
+ # Build a response containing the allowed methods for a given URI.
125
+ def options(uri)
126
+ methods = methods_for_uri(uri)
127
+ allow(methods)
128
+ end
129
+
130
+ # Return a list of allowed methods for a given URI.
131
+ def methods_for_uri(uri)
132
+ return meta_methods if uri.meta_uri?
133
+ return resource_collection_methods if uri.resource_collection_uri?
134
+ return resolved_resource_collection_methods if uri.resolved_resource_collection_uri?
135
+ return resource_methods if uri.resource_uri?
136
+ return version_collection_methods if uri.version_collection_uri?
137
+ return resolved_version_collection_methods if uri.resolved_version_collection_uri?
138
+ return resource_version_methods if uri.resource_version_uri?
139
+ end
140
+
141
+ # Return the list of methods allowed for the cloudkit-meta URI.
142
+ def meta_methods
143
+ @meta_methods ||= http_methods.excluding('POST', 'PUT', 'DELETE')
144
+ end
145
+
146
+ # Return the list of methods allowed for a resource collection.
147
+ def resource_collection_methods
148
+ @resource_collection_methods ||= http_methods.excluding('PUT', 'DELETE')
149
+ end
150
+
151
+ # Return the list of methods allowed on a resolved resource collection.
152
+ def resolved_resource_collection_methods
153
+ @resolved_resource_collection_methods ||= http_methods.excluding('POST', 'PUT', 'DELETE')
154
+ end
155
+
156
+ # Return the list of methods allowed on an individual resource.
157
+ def resource_methods
158
+ @resource_methods ||= http_methods.excluding('POST')
159
+ end
160
+
161
+ # Return the list of methods allowed on a version history collection.
162
+ def version_collection_methods
163
+ @version_collection_methods ||= http_methods.excluding('POST', 'PUT', 'DELETE')
164
+ end
165
+
166
+ # Return the list of methods allowed on a resolved version history collection.
167
+ def resolved_version_collection_methods
168
+ @resolved_version_collection_methods ||= http_methods.excluding('POST', 'PUT', 'DELETE')
169
+ end
170
+
171
+ # Return the list of methods allowed on a resource version.
172
+ def resource_version_methods
173
+ @resource_version_methods ||= http_methods.excluding('POST', 'PUT', 'DELETE')
174
+ end
175
+
176
+ # Return true if this store implements a given HTTP method.
177
+ def implements?(http_method)
178
+ http_methods.include?(http_method.upcase)
179
+ end
180
+
181
+ # Return the list of HTTP methods supported by this Store.
182
+ def http_methods
183
+ ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS']
184
+ end
185
+
186
+ # Return an array containing the response for each URI in a list.
187
+ def resolve_uris(uris) # TODO - remove if no longer needed
188
+ result = []
189
+ uris.each do |uri|
190
+ result << get(uri)
191
+ end
192
+ result
193
+ end
194
+
195
+ def storage_adapter
196
+ CloudKit.storage_adapter
197
+ end
198
+
199
+ # Return the version number of this Store.
200
+ def version; 1; end
201
+
202
+ protected
203
+
204
+ # Return the list of collections managed by this Store.
205
+ def meta
206
+ json = JSON.generate(:uris => @collections.map{|t| "/#{t}"})
207
+ response(200, json, build_etag(json))
208
+ end
209
+
210
+ # Return a list of resource URIs for the given collection URI. Sorted by
211
+ # Last-Modified date in descending order.
212
+ def resource_collection(uri, options)
213
+ filter = options.excluding(:offset, :limit).merge(
214
+ :deleted => false,
215
+ :collection_reference => uri.collection_uri_fragment,
216
+ :archived => false)
217
+ result = CloudKit::Resource.current(filter)
218
+ bundle_collection_result(uri.string, options, result)
219
+ end
220
+
221
+ # Return all documents and their associated metadata for the given
222
+ # collection URI.
223
+ def resolved_resource_collection(uri, options)
224
+ result = CloudKit::Resource.current(
225
+ options.excluding(:offset, :limit).merge(
226
+ :collection_reference => uri.collection_uri_fragment))
227
+ bundle_resolved_collection_result(uri, options, result)
228
+ end
229
+
230
+ # Return the resource for the given URI. Return 404 if not found or if
231
+ # protected and unauthorized, 410 if authorized but deleted.
232
+ def resource(uri, options)
233
+ if resource = CloudKit::Resource.first(options.merge!(:uri => uri.string))
234
+ return status_410 if resource.deleted?
235
+ return response(200, resource.json, resource.etag, resource.last_modified)
236
+ end
237
+ status_404
238
+ end
239
+
240
+ # Return a collection of URIs for all versions of a resource including the
241
+ # current version. Sorted by Last-Modified date in descending order.
242
+ def version_collection(uri, options)
243
+ found = CloudKit::Resource.first(
244
+ options.excluding(:offset, :limit).merge(
245
+ :uri => uri.current_resource_uri))
246
+ return status_404 unless found
247
+ result = CloudKit::Resource.all( # TODO - just use found.versions
248
+ options.excluding(:offset, :limit).merge(
249
+ :resource_reference => uri.current_resource_uri,
250
+ :deleted => false))
251
+ bundle_collection_result(uri.string, options, result)
252
+ end
253
+
254
+ # Return all document versions and their associated metadata for a given
255
+ # resource including the current version. Sorted by Last-Modified date in
256
+ # descending order.
257
+ def resolved_version_collection(uri, options)
258
+ found = CloudKit::Resource.first(
259
+ options.excluding(:offset, :limit).merge(
260
+ :uri => uri.current_resource_uri))
261
+ return status_404 unless found
262
+ result = CloudKit::Resource.all(
263
+ options.excluding(:offset, :limit).merge(
264
+ :resource_reference => uri.current_resource_uri,
265
+ :deleted => false))
266
+ bundle_resolved_collection_result(uri, options, result)
267
+ end
268
+
269
+ # Return a specific version of a resource.
270
+ def resource_version(uri, options)
271
+ result = CloudKit::Resource.first(options.merge(:uri => uri.string))
272
+ return status_404 unless result
273
+ response(200, result.json, result.etag, result.last_modified)
274
+ end
275
+
276
+ # Create a resource at the specified URI.
277
+ def create_resource(uri, options)
278
+ JSON.parse(options[:json]) rescue (return status_422)
279
+ resource = CloudKit::Resource.create(uri, options[:json], options[:remote_user])
280
+ json_create_response(resource.uri.string, resource.etag, resource.last_modified)
281
+ end
282
+
283
+ # Update the resource at the specified URI. Requires the :etag option.
284
+ def update_resource(uri, options)
285
+ JSON.parse(options[:json]) rescue (return status_422)
286
+ resource = CloudKit::Resource.first(
287
+ options.excluding(:json, :etag).merge(:uri => uri.string))
288
+ return status_404 unless (resource && (resource.remote_user == options[:remote_user]))
289
+ return etag_required unless options[:etag]
290
+ return status_412 unless options[:etag] == resource.etag
291
+ resource.update(options[:json])
292
+ return json_meta_response(uri.string, resource.etag, resource.last_modified)
293
+ end
294
+
295
+ # Bundle a collection of results as a list of URIs for the response.
296
+ def bundle_collection_result(uri, options, result)
297
+ total = result.size
298
+ offset = options[:offset].try(:to_i) || 0
299
+ max = options[:limit] ? offset + options[:limit].to_i : total
300
+ list = result.to_a[offset...max].map{|r| r.uri}
301
+ json = uri_list(list, total, offset)
302
+ last_modified = result.first.try(:last_modified) if result.any?
303
+ response(200, json, build_etag(json), last_modified)
304
+ end
305
+
306
+ # Bundle a collection of results as a list of documents and the associated
307
+ # metadata (last_modified, uri, etag) that would have accompanied a response
308
+ # to their singular request.
309
+ def bundle_resolved_collection_result(uri, options, result)
310
+ total = result.size
311
+ offset = options[:offset].try(:to_i) || 0
312
+ max = options[:limit] ? offset + options[:limit].to_i : total
313
+ list = result.to_a[offset...max]
314
+ json = resource_list(list, total, offset)
315
+ last_modified = result.first.last_modified if result.any?
316
+ response(200, json, build_etag(json), last_modified)
317
+ end
318
+
319
+ # Generate a JSON URI list.
320
+ def uri_list(list, total, offset)
321
+ JSON.generate(:total => total, :offset => offset, :uris => list.map { |u| u.string })
322
+ end
323
+
324
+ # Generate a JSON document list.
325
+ def resource_list(list, total, offset)
326
+ results = []
327
+ list.each do |resource|
328
+ results << {
329
+ :uri => resource.uri.string,
330
+ :etag => resource.etag,
331
+ :last_modified => resource.last_modified,
332
+ :document => resource.json}
333
+ end
334
+ JSON.generate(:total => total, :offset => offset, :documents => results)
335
+ end
336
+
337
+ # Build an ETag for a collection. ETags are generated on write as an
338
+ # optimization for GETs. This method is used for collections of resources
339
+ # where the optimization is not practical.
340
+ def build_etag(data)
341
+ Digest::MD5.hexdigest(data.to_s)
342
+ end
343
+
344
+ # Returns true if the collection type is valid for this Store.
345
+ def valid_collection_type?(collection_type)
346
+ @collections.include?(collection_type) || collection_type.to_s == 'cloudkit-meta'
347
+ end
348
+ end
349
+ end