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.
- data/README.md +14 -2
- data/example/config.ru +19 -13
- data/example/config2.ru +41 -0
- data/lib/rackful/header_spoofing.rb +39 -32
- data/lib/rackful/method_spoofing.rb +56 -58
- data/lib/rackful/relative_location.rb +35 -21
- data/lib/rackful.rb +6 -934
- data/lib/rackful_http_status.rb +288 -0
- data/lib/rackful_path.rb +112 -0
- data/lib/rackful_request.rb +268 -0
- data/lib/rackful_resource.rb +454 -0
- data/lib/rackful_serializer.rb +318 -0
- data/lib/rackful_server.rb +124 -0
- data/rackful.gemspec +3 -1
- metadata +49 -52
@@ -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
|