rackful 0.0.2 → 0.1.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.
@@ -0,0 +1,454 @@
1
+ # Required for parsing:
2
+ require 'rack'
3
+
4
+ # Required for running:
5
+
6
+
7
+ module Rackful
8
+
9
+ =begin markdown
10
+ Mixin for resources served by {Server}.
11
+
12
+ {Server} helps you implement Rackful resource objects quickly in a couple
13
+ of ways.
14
+ Classes that include this module may implement a method `content_types`
15
+ for content negotiation. This method must return a Hash of
16
+ `media-type => quality` pairs.
17
+ @see Server, ResourceFactory
18
+ @since 0.0.1
19
+ =end
20
+ module Resource
21
+
22
+
23
+ include Rack::Utils
24
+
25
+
26
+ def self.included(base)
27
+ base.extend ClassMethods
28
+ end
29
+
30
+
31
+ module ClassMethods
32
+
33
+
34
+ =begin markdown
35
+ Meta-programmer method.
36
+ @example Have your resource rendered in XML and JSON
37
+ class MyResource
38
+ add_serializer MyResource2XML
39
+ add_serializer MyResource2JSON, 0.5
40
+ end
41
+ @param serializer [Serializer]
42
+ @param quality [Float]
43
+ @return [self]
44
+ =end
45
+ def add_serializer serializer, quality = 1.0
46
+ quality = quality.to_f
47
+ quality = 1.0 if quality > 1.0
48
+ quality = 0.0 if quality < 0.0
49
+ # The single '@' on the following line is on purpose!
50
+ s = [serializer, quality]
51
+ serializer::CONTENT_TYPES.each {
52
+ |content_type|
53
+ self.serializers[content_type.to_s] = s
54
+ }
55
+ self
56
+ end
57
+
58
+
59
+ def serializers
60
+ @rackful_resource_serializers ||= {}
61
+ end
62
+
63
+
64
+ def all_serializers
65
+ @rackful_resource_all_serializers ||=
66
+ if self.superclass.respond_to?(:all_serializers)
67
+ self.superclass.all_serializers.merge( self.serializers ) do
68
+ |key, oldval, newval|
69
+ newval[1] >= oldval[1] ? newval : oldval
70
+ end
71
+ else
72
+ self.serializers
73
+ end
74
+ end
75
+
76
+
77
+ =begin markdown
78
+ Meta-programmer method.
79
+ @example Have your resource accept XML and JSON in `PUT` requests
80
+ class MyResource
81
+ add_parser XML2MyResource, :PUT
82
+ add_parser JSON2MyResource, :PUT
83
+ end
84
+ @param parser [Parser]
85
+ @param method [#to_sym]
86
+ @return [self]
87
+ =end
88
+ def add_media_type media_type, method = :PUT
89
+ method = method.to_sym
90
+ self.media_types[method] ||= []
91
+ self.media_types[method] << media_type
92
+ self
93
+ end
94
+
95
+
96
+ def media_types
97
+ @rackful_resource_media_types ||= {}
98
+ end
99
+
100
+
101
+ def all_media_types
102
+ @rackful_resource_all_media_types ||=
103
+ if self.superclass.respond_to?(:all_media_types)
104
+ self.superclass.all_media_types.merge( self.media_types ) do
105
+ |key, oldval, newval|
106
+ oldval + newval
107
+ end
108
+ else
109
+ self.media_types
110
+ end
111
+ end
112
+
113
+
114
+ =begin markdown
115
+ The best media type for the response body, given the current HTTP request.
116
+ @param accept [Hash]
117
+ @param require_match [Boolean]
118
+ @return [String] content-type
119
+ @raise [HTTP406NotAcceptable] if `require_match` is `true` and no match was found.
120
+ @since 0.1.0
121
+ =end
122
+ def best_content_type accept, require_match = true
123
+ if accept.empty?
124
+ return self.all_serializers.values.sort_by(&:last).last[0]::CONTENT_TYPES[0]
125
+ end
126
+ matches = []
127
+ accept.each_pair {
128
+ |accept_media_type, accept_quality|
129
+ self.all_serializers.each_pair {
130
+ |content_type, v|
131
+ quality = v[1]
132
+ media_type = content_type.split(';').first.strip
133
+ if File.fnmatch( accept_media_type, media_type )
134
+ matches << [ content_type, accept_quality * quality ]
135
+ end
136
+ }
137
+ }
138
+ if matches.empty?
139
+ if require_match
140
+ raise( HTTP406NotAcceptable, self.all_serializers.keys() )
141
+ else
142
+ return self.all_serializers.values.sort_by(&:last).last[0]::CONTENT_TYPES[0]
143
+ end
144
+ end
145
+ matches.sort_by(&:last).last[0]
146
+ end
147
+
148
+
149
+ # =begin markdown
150
+ # @param content_type [String]
151
+ # @param method [#to_s]
152
+ # @return [Serializer]
153
+ # =end
154
+ # def parser request
155
+ # method = request.request_method.upcase.to_sym
156
+ # if !parsers[method] || !parsers[method][request.media_type]
157
+ # raise HTTP415UnsupportedMediaType, ( parsers[method] ? parsers[method].keys : [] )
158
+ # end
159
+ # parsers[method][request.media_type].new( request )
160
+ # end
161
+
162
+
163
+ end # module ClassMethods
164
+
165
+
166
+ =begin markdown
167
+ @return [Serializer]
168
+ =end
169
+ def serializer content_type
170
+ @rackful_resource_serializers ||= {}
171
+ @rackful_resource_serializers[content_type] ||=
172
+ self.class.all_serializers[content_type][0].new( self, content_type )
173
+ end
174
+
175
+
176
+ =begin markdown
177
+ @!method to_struct()
178
+ @return [#to_json, #each_pair]
179
+ =end
180
+
181
+ =begin markdown
182
+ @!method do_METHOD( Request, Rack::Response )
183
+ HTTP/1.1 method handler.
184
+
185
+ To handle certain HTTP/1.1 request methods, resources must implement methods
186
+ called `do_<HTTP_METHOD>`.
187
+ @example Handling `PATCH` requests
188
+ def do_PATCH request, response
189
+ response['Content-Type'] = 'text/plain'
190
+ response.body = [ 'Hello world!' ]
191
+ end
192
+ @abstract
193
+ @return [void]
194
+ @raise [HTTPStatus, RuntimeError]
195
+ @since 0.0.1
196
+ =end
197
+
198
+
199
+ =begin markdown
200
+ The path of this resource.
201
+ @return [Rackful::Path]
202
+ @see #initialize
203
+ @since 0.0.1
204
+ =end
205
+ attr_reader :path
206
+
207
+
208
+ def path= path
209
+ @path = Path.new(path.to_s)
210
+ end
211
+
212
+
213
+ def title
214
+ ( '/' == self.path ) ?
215
+ Request.current.host :
216
+ File.basename(self.path).to_path.unescape
217
+ end
218
+
219
+
220
+ def requested?
221
+ self.path.slashify == Request.current.path.slashify
222
+ end
223
+
224
+ =begin markdown
225
+ Does this resource _exists_?
226
+
227
+ For example, a client can `PUT` to a URL that doesn't refer to a resource
228
+ yet. In that case, your {Server#resource_factory resource factory} can
229
+ produce an empty resource to to handle the `PUT` request. `HEAD` and `GET`
230
+ requests will still yield `404 Not Found`.
231
+
232
+ @return [Boolean] The default implementation returns `false`.
233
+ @since 0.0.1
234
+ =end
235
+ def empty?
236
+ false
237
+ end
238
+
239
+
240
+ def to_rackful
241
+ self
242
+ end
243
+
244
+
245
+ =begin markdown
246
+ @!attribute [r] get_etag
247
+ The ETag of this resource.
248
+
249
+ If your classes implement this method, then an `ETag:` response
250
+ header is generated automatically when appropriate. This allows clients to
251
+ perform conditional requests, by sending an `If-Match:` or
252
+ `If-None-Match:` request header. These conditions are then asserted
253
+ for you automatically.
254
+
255
+ Make sure your entity tag is a properly formatted string. In ABNF:
256
+
257
+ entity-tag = [ "W/" ] quoted-string
258
+ quoted-string = ( <"> *(qdtext | quoted-pair ) <"> )
259
+ qdtext = <any TEXT except <">>
260
+ quoted-pair = "\" CHAR
261
+
262
+ @abstract
263
+ @return [String]
264
+ @see http://tools.ietf.org/html/rfc2616#section-14.19 RFC2616 section 14.19
265
+ @since 0.0.1
266
+ =end
267
+
268
+
269
+ =begin markdown
270
+ @!attribute [r] get_last_modified
271
+ Last modification of this resource.
272
+
273
+ If your classes implement this method, then a `Last-Modified:` response
274
+ header is generated automatically when appropriate. This allows clients to
275
+ perform conditional requests, by sending an `If-Modified-Since:` or
276
+ `If-Unmodified-Since:` request header. These conditions are then asserted
277
+ for you automatically.
278
+ @abstract
279
+ @return [Array<(Time, Boolean)>] The timestamp, and a flag indicating if the
280
+ timestamp is a strong validator.
281
+ @see http://tools.ietf.org/html/rfc2616#section-14.29 RFC2616 section 14.29
282
+ @since 0.0.1
283
+ =end
284
+
285
+
286
+ =begin markdown
287
+ @!method destroy()
288
+ @return [Hash, nil] an optional header hash.
289
+ =end
290
+
291
+
292
+ =begin markdown
293
+ List of all HTTP/1.1 methods implemented by this resource.
294
+
295
+ This works by inspecting all the {#do_METHOD} methods this object implements.
296
+ @return [Array<Symbol>]
297
+ @since 0.0.1
298
+ @private
299
+ =end
300
+ def http_methods
301
+ r = []
302
+ if self.empty?
303
+ self.class.all_media_types
304
+ else
305
+ r.merge! [ :OPTIONS, :HEAD, :GET ]
306
+ r << :DELETE if self.respond_to?( :destroy )
307
+ end
308
+ self.public_instance_methods.each do
309
+ |instance_method|
310
+ if /\Ado_([A-Z])+\z/ === instance_method
311
+ r << $1.to_sym
312
+ end
313
+ end
314
+ r
315
+ end
316
+
317
+
318
+ =begin markdown
319
+ Handles an OPTIONS request.
320
+
321
+ As a courtesy, this module implements a default handler for OPTIONS
322
+ requests. It creates an `Allow:` header, listing all implemented HTTP/1.1
323
+ methods for this resource. By default, an `HTTP/1.1 204 No Content` is
324
+ returned (without an entity body).
325
+
326
+ Feel free to override this method at will.
327
+ @return [void]
328
+ @raise [HTTP404NotFound] `404 Not Found` if this resource is empty.
329
+ @since 0.0.1
330
+ =end
331
+ def http_OPTIONS request, response
332
+ raise HTTP404NotFound if self.empty?
333
+ response.status = status_code :no_content
334
+ response.header['Allow'] = self.http_methods.join ', '
335
+ end
336
+
337
+
338
+ =begin markdown
339
+ Handles a HEAD request.
340
+
341
+ This default handler for HEAD requests calls {#http\_GET}, and
342
+ then strips off the response body.
343
+
344
+ Feel free to override this method at will.
345
+ @return [self]
346
+ @since 0.0.1
347
+ =end
348
+ def http_HEAD request, response
349
+ self.http_GET request, response
350
+ response['Content-Length'] =
351
+ response.body.reduce(0) do
352
+ |memo, s| memo + bytesize(s)
353
+ end.to_s
354
+ # Is this really necessary? Doesn't Rack automatically strip the response
355
+ # body for HEAD requests?
356
+ response.body = []
357
+ end
358
+
359
+
360
+ =begin markdown
361
+ @private
362
+ @return [void]
363
+ @raise [HTTP404NotFound, HTTP405MethodNotAllowed]
364
+ @since 0.0.1
365
+ =end
366
+ def http_GET request, response
367
+ raise HTTP404NotFound if self.empty?
368
+ # May throw HTTP406NotAcceptable:
369
+ content_type = self.class.best_content_type( request.accept )
370
+ response['Content-Type'] = content_type
371
+ response.status = status_code( :ok )
372
+ response.headers.merge! self.default_headers
373
+ # May throw HTTP405MethodNotAllowed:
374
+ serializer = self.serializer( content_type )
375
+ if serializer.respond_to? :headers
376
+ response.headers.merge!( serializer.headers )
377
+ end
378
+ response.body = serializer
379
+ end
380
+
381
+
382
+ =begin markdown
383
+ Wrapper around {#do_METHOD #do_GET}
384
+ @private
385
+ @return [void]
386
+ @raise [HTTP404NotFound, HTTP405MethodNotAllowed]
387
+ @since 0.0.1
388
+ =end
389
+ def http_DELETE request, response
390
+ raise HTTP404NotFound if self.empty?
391
+ response.status = status_code( :no_content )
392
+ if headers = self.destroy
393
+ response.headers.merge! headers
394
+ end
395
+ end
396
+
397
+
398
+ =begin markdown
399
+ @private
400
+ @return [void]
401
+ @raise [HTTP415UnsupportedMediaType] `405 Method Not Allowed` if the resource doesn't implement the `PUT` method.
402
+ @since 0.0.1
403
+ =end
404
+ def http_PUT request, response
405
+ raise HTTP405MethodNotAllowed unless self.respond_to? :do_PUT
406
+ unless self.class.media_types[:PUT] &&
407
+ self.class.media_types[:PUT].include?( request.media_type )
408
+ raise HTTP415UnsupportedMediaType, self.class.media_types[:PUT]
409
+ end
410
+ response.status = status_code( self.empty? ? :created : :no_content )
411
+ self.do_PUT( request, response )
412
+ response.headers.merge! self.default_headers
413
+ end
414
+
415
+
416
+ =begin markdown
417
+ Wrapper around {#do_METHOD #do_PUT}
418
+ @private
419
+ @return [void]
420
+ @raise [HTTPStatus] `405 Method Not Allowed` if the resource doesn't implement the `PUT` method.
421
+ @since 0.0.1
422
+ =end
423
+ def http_method request, response
424
+ method = request.request_method.to_sym
425
+ if ! self.respond_to?( :"do_#{method}" )
426
+ raise HTTP405MethodNotAllowed, self.http_methods
427
+ end
428
+ if ( request.content_length ||
429
+ 'chunked' == request.env['HTTP_TRANSFER_ENCODING'] ) and
430
+ ! self.class.media_types[method] ||
431
+ ! self.class.media_types[method].include?( request.media_type )
432
+ raise HTTP415UnsupportedMediaType, self.class.media_types[method]
433
+ end
434
+ self.send( :"do_#{method}", request, response )
435
+ end
436
+
437
+
438
+ =begin markdown
439
+ Adds `ETag:` and `Last-Modified:` response headers.
440
+ @since 0.0.1
441
+ =end
442
+ def default_headers
443
+ r = {}
444
+ r['ETag'] = self.get_etag \
445
+ if self.respond_to?( :get_etag )
446
+ r['Last-Modified'] = self.get_last_modified[0].httpdate \
447
+ if self.respond_to?( :get_last_modified )
448
+ r
449
+ end
450
+
451
+
452
+ end # module Resource
453
+
454
+ end # module Rackful