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,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