/lib/thingfish/handler/default.rb
Ruby | 381 lines | 231 code | 76 blank | 74 comment | 15 complexity | 09fb601214d76646932e152afc0bc726 MD5 | raw file
Possible License(s): BSD-3-Clause
1#!/usr/bin/env ruby
2
3require 'pp'
4require 'time'
5require 'uuidtools'
6require 'thingfish'
7require 'thingfish/constants'
8require 'thingfish/handler'
9require 'thingfish/mixins'
10
11
12# The default top-level handler for the thingfish daemon. This handler provides
13# five basic services:
14#
15# 1. Top-level index <GET />
16# 2. Upload a new resource <POST />
17# 3. Fetch a resource by UUID <GET /51a01b12-b706-11db-a0e3-cb820d1598b5>
18# 4. Overwrite a resource by UUID <PUT /51a01b12-b706-11db-a0e3-cb820d1598b5>
19# 5. Delete a resource by UUID <DELETE /51a01b12-b706-11db-a0e3-cb820d1598b5>
20#
21# This constitutes the core of the data-storage functionality of ThingFish.
22#
23# == Version
24#
25# $Id$
26#
27# == Authors
28#
29# * Michael Granger <ged@FaerieMUD.org>
30# * Mahlon E. Smith <mahlon@martini.nu>
31#
32# :include: LICENSE
33#
34#---
35#
36# Please see the file LICENSE in the top-level directory for licensing details.
37#
38class ThingFish::DefaultHandler < ThingFish::Handler
39
40 include ThingFish::Constants,
41 ThingFish::Constants::Patterns,
42 ThingFish::Loggable,
43 ThingFish::StaticResourcesHandler
44
45 # Config defaults
46 CONFIG_DEFAULTS = {
47 :html_index => 'index.rhtml',
48 :cache_expiration => 30.minutes,
49 :resource_dir => nil, # Use the ThingFish::Handler default
50 }
51
52
53 #################################################################
54 ### I N S T A N C E M E T H O D S
55 #################################################################
56
57 ### Set up a new DefaultHandler
58 def initialize( path, options={} )
59 super( path, CONFIG_DEFAULTS.merge(options) )
60 end
61
62
63 ######
64 public
65 ######
66
67 ### Handler API: Handle a GET request
68 def handle_get_request( path_info, request, response )
69 case path_info
70
71 # If this is a request to the root, handle it ourselves
72 when ''
73 self.log.debug "Handling an index request"
74 self.handle_index_fetch_request( request, response )
75
76 # Likewise for a request to <a uuid>
77 when UUID_URL
78 self.log.debug "Handling a UUID request"
79 uuid = parse_uuid( $1 )
80 self.handle_resource_fetch_request( request, response, uuid )
81
82 # Fall through to the static handler for GET requests
83 else
84 self.log.debug "No GET handler for %p; leaving the response alone" %
85 [ path_info ]
86 end
87
88 end
89
90
91 ### Handler API: Handle a POST request
92 def handle_post_request( path_info, request, response )
93 case path_info
94 when ''
95 self.handle_create_request( request, response )
96
97 when UUID_URL
98 self.build_method_not_allowed_response( response, :POST,
99 %w{GET PUT DELETE} )
100
101 else
102 self.log.debug "No POST handler for %p, falling through" % path_info
103 end
104 end
105
106
107 ### Handler API: Handle a PUT request
108 def handle_put_request( path_info, request, response )
109 case path_info
110 when ''
111 self.build_method_not_allowed_response( response, :PUT,
112 %w{GET POST} )
113
114 when UUID_URL
115 # :TODO: Implement multipart update
116 raise ThingFish::RequestError, "Multipart update not currently supported" if
117 request.has_multipart_body?
118 uuid = parse_uuid( $1 )
119 self.handle_update_uuid_request( request, response, uuid )
120
121 else
122 self.log.debug "No PUT handler for %p, falling through" % path_info
123 end
124 end
125
126
127 ### Handler API: Handle a DELETE request
128 def handle_delete_request( path_info, request, response )
129 case path_info
130 when ''
131 self.build_method_not_allowed_response( response, :DELETE, %w{GET POST} )
132
133 when UUID_URL
134 uuid = parse_uuid( $1 )
135 self.handle_delete_uuid_request( request, response, uuid )
136
137 else
138 self.log.debug "No DELETE handler for %p, falling through" % path_info
139 end
140 end
141
142
143 ### Make body content for an HTML response (HTML filter API)
144 def make_html_content( body, request, response )
145 self.log.debug "Loading index resource %p" % [@options[:html_index]]
146 content = self.get_erb_resource( @options[:html_index] )
147 handler_index_sections = self.get_handler_index_sections
148
149 return content.result( binding() )
150 end
151
152
153 #########
154 protected
155 #########
156
157 ### Make the content for the handler section of the index page.
158 def make_index_content( uri )
159 tmpl = self.get_erb_resource( "index_content.rhtml" )
160 return tmpl.result( binding() )
161 end
162
163
164 ### Iterate over the loaded handlers and ask each for any content it wants shown
165 ### on the index HTML page.
166 def get_handler_index_sections
167 self.log.debug "Fetching index sections for all registered handlers"
168 handlers = self.daemon.urimap.map.sort_by {|uri,h| uri }
169 return handlers.collect do |uri,handlers|
170 self.log.debug " collecting index content from %p (%p)" %
171 [handlers.collect {|h| h.class.name}, uri]
172 handlers.
173 select {|h| h.is_a?(ThingFish::Handler) }.
174 collect {|h| h.make_index_content( uri ) }
175 end.flatten.compact
176 end
177
178
179 ### Handle a request to fetch the index (GET to /)
180 def handle_index_fetch_request( request, response )
181 response.data[:title] = 'Version ' + ThingFish::VERSION
182 response.data[:tagline] = 'Feed me.'
183
184 response.content_type = RUBY_MIMETYPE
185 response.body = {
186 'version' => ThingFish::VERSION,
187 'handlers' => self.daemon.handler_info,
188 'filters' => self.daemon.filter_info,
189 }
190 response.status = HTTP::OK
191 end
192
193
194 ### Handle fetching a file by UUID (GET to /{uuid})
195 def handle_resource_fetch_request( request, response, uuid )
196 if @filestore.has_file?( uuid )
197
198 # Try to send a NOT MODIFIED response
199 if self.can_send_cached_response?( request, uuid )
200 self.log.info "Client has a cached copy of %s" % [uuid]
201 response.status = HTTP::NOT_MODIFIED
202 self.add_cache_headers( response, uuid )
203 return
204
205 else
206 self.log.info "Sending resource %s" % [uuid]
207 response.status = HTTP::OK
208 response.content_type = @metastore[ uuid ].format
209 self.add_cache_headers( response, uuid )
210
211 # Add content disposition headers
212 self.add_content_disposition( request, response, uuid )
213
214 # Send an OK status with the Content-length set to the
215 # size of the resource
216 response.headers[ :content_length ] = @filestore.size( uuid )
217
218 self.log.info "Setting response body to the resource IO"
219 response.body = @filestore.fetch_io( uuid )
220 end
221
222 else
223 response.status = HTTP::NOT_FOUND
224 response.content_type = 'text/plain'
225 response.body = "UUID '#{uuid}' not found in the filestore"
226 end
227 end
228
229
230 ### Handle a request to create a new resource with the request body as
231 ### data (POST to /)
232 def handle_create_request( request, response )
233
234 if request.bodies.length > 1
235 self.log.error "Can't handle multipart request (%p)" % [ request.bodies ]
236 raise ThingFish::NotImplementedError, "multipart upload not implemented"
237 end
238
239 uuid = nil
240
241 # Store the primary resource
242 body, metadata = request.bodies.to_a.flatten
243 uuid = self.daemon.store_resource( body, metadata )
244
245 # Store any related resources, linked to the primary
246 self.daemon.store_related_resources( body, uuid, request )
247
248 response.status = HTTP::CREATED
249 response.headers[:location] = '/' + uuid.to_s
250 response.content_type = RUBY_MIMETYPE
251 response.body = @metastore.get_properties( uuid )
252
253 rescue ThingFish::FileStoreQuotaError => err
254 self.log.error "Quota error while creating a resource: %s" % [ err.message ]
255 raise ThingFish::RequestEntityTooLargeError, err.message
256 end
257
258
259 ### Handle updating a file by UUID
260 def handle_update_uuid_request( request, response, uuid )
261
262 if request.bodies.length > 1
263 self.log.error "Can't handle multipart request" % [ request.bodies ]
264 raise ThingFish::NotImplementedError, "multipart upload not implemented"
265 end
266
267 # :TODO: Handle slow/big uploads by returning '202 Accepted' and spawning
268 # a handler thread?
269 new_resource = ! @filestore.has_file?( uuid )
270 body, metadata = request.bodies.to_a.flatten
271 self.daemon.store_resource( body, metadata, uuid )
272
273 # Purge any old related resources, then store any new ones linked to the primary
274 self.daemon.purge_related_resources( uuid )
275 self.daemon.store_related_resources( body, uuid, request )
276
277 response.content_type = RUBY_MIMETYPE
278 response.body = @metastore.get_properties( uuid )
279
280 if new_resource
281 response.status = HTTP::CREATED
282 response.headers[:location] = '/' + uuid.to_s
283 else
284 response.status = HTTP::OK
285 end
286 rescue ThingFish::FileStoreQuotaError => err
287 self.log.error "Quota error while updating a resource: %s" % [ err.message ]
288 raise ThingFish::RequestEntityTooLargeError, err.message
289 end
290
291
292 ### Handle deleting a file by UUID
293 def handle_delete_uuid_request( request, response, uuid )
294 if @filestore.has_file?( uuid )
295 @filestore.delete( uuid )
296 @metastore.delete_resource( uuid )
297 response.status = HTTP::OK
298 response.content_type = 'text/plain'
299 response.body = "Resource '#{uuid}' deleted"
300 else
301 response.status = HTTP::NOT_FOUND
302 response.content_type = 'text/plain'
303 response.body = "Resource '#{uuid}' not found"
304 end
305 end
306
307
308 ### Returns true if the given +request+'s headers indicate that the local copy
309 ### of the data corresponding to the specified +uuid+ are cached remotely, and
310 ### the client can just use the cached version. This usually means that the
311 ### handler will send a 304 NOT MODIFIED.
312 def can_send_cached_response?( request, uuid )
313 metadata = @metastore[ uuid ]
314 return request.is_cached_by_client?( metadata.checksum, metadata.modified )
315 end
316
317
318 ### Add cache control headers to the given +response+ for the specified +uuid+.
319 def add_cache_headers( response, uuid )
320 self.log.debug "Adding cache headers to response for %s" % [uuid]
321
322 response.headers[ :etag ] = %q{"%s"} % [@metastore[ uuid ].checksum]
323 response.headers[ :expires ] = 1.year.from_now.httpdate
324 end
325
326
327 ### Add content disposition handlers to the given +response+ for the
328 ### specified +uuid+ if the 'attach' query argument exists. As described in
329 ### RFC 2183, this is an optional, but convenient header when using UUID-keyed
330 ### resources.
331 def add_content_disposition( request, response, uuid )
332 return unless request.query_args.has_key?( 'attach' )
333
334 disposition = []
335 disposition << 'attachment'
336
337 if (( filename = request.query_args['attach'] || @metastore[ uuid ].title ))
338 disposition << %{filename="%s"} % [ filename ]
339 end
340
341 if (( modtime = @metastore[ uuid ].modified ))
342 modtime = Time.parse( modtime ) unless modtime.is_a?( Time )
343 disposition << %{modification-date="%s"} % [ modtime.rfc822 ]
344 end
345
346 response.headers[ :content_disposition ] = disposition.join('; ')
347 end
348
349
350
351 #######
352 private
353 #######
354
355 ### A more-efficient version of UUIDTools' UUID parser -- see
356 ### experiments/bench-uuid-parse.rb in the subversion source.
357 def parse_uuid( uuid_string )
358 unless match = UUID_PATTERN.match( uuid_string )
359 raise ArgumentError, "Invalid UUID %p." % [uuid_string]
360 end
361
362 uuid_components = match.captures
363
364 time_low = uuid_components[0].to_i( 16 )
365 time_mid = uuid_components[1].to_i( 16 )
366 time_hi_and_version = uuid_components[2].to_i( 16 )
367 clock_seq_hi_and_reserved = uuid_components[3].to_i( 16 )
368 clock_seq_low = uuid_components[4].to_i( 16 )
369
370 nodes = []
371 0.step( 11, 2 ) do |i|
372 nodes << uuid_components[5][ i, 2 ].to_i( 16 )
373 end
374
375 return UUIDTools::UUID.new( time_low, time_mid, time_hi_and_version,
376 clock_seq_hi_and_reserved, clock_seq_low, nodes )
377 end
378
379end # ThingFish::DefaultHandler
380
381# vim: set nosta noet ts=4 sw=4: