rackful 0.0.1
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/LICENSE.md +230 -0
- data/README.md +2 -0
- data/example/config.ru +35 -0
- data/lib/rackful/header_spoofing.rb +66 -0
- data/lib/rackful/method_spoofing.rb +104 -0
- data/lib/rackful/relative_location.rb +58 -0
- data/lib/rackful.rb +934 -0
- data/mkdoc.sh +6 -0
- data/rackful.gemspec +23 -0
- metadata +73 -0
data/lib/rackful.rb
ADDED
@@ -0,0 +1,934 @@
|
|
1
|
+
# Copyright ©2011-2012 Pieter van Beek <pieterb@sara.nl>
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
|
15
|
+
|
16
|
+
require 'rexml/document' # Used by HTTPStatus
|
17
|
+
require 'rack'
|
18
|
+
|
19
|
+
=begin markdown
|
20
|
+
Library for creating Rackful web services
|
21
|
+
|
22
|
+
Rackful
|
23
|
+
=======
|
24
|
+
|
25
|
+
Rationale
|
26
|
+
---------
|
27
|
+
|
28
|
+
Confronted with the task of implementing a Rackful web service in Ruby, I
|
29
|
+
checked out a number of existing libraries and frameworks, including
|
30
|
+
Ruby-on-Rails, and then decided to brew my own, the reason being that I couldn't
|
31
|
+
find a library or framework with all of the following properties:
|
32
|
+
|
33
|
+
* **Small** Some of these frameworks are really big. I need to get a job done in
|
34
|
+
time. If understanding the framework takes more time than writing my own, I
|
35
|
+
must at least feel confident that the framework I'm learning is more powerful
|
36
|
+
that what I can come up with by myself. Ruby-on-Rails is probably the biggest
|
37
|
+
framework out there, and it still lacks many features that are essential to
|
38
|
+
Rackful web service programming.
|
39
|
+
|
40
|
+
This library is small. You could read _all_ the source code in less than an
|
41
|
+
hour, and understand every detail.
|
42
|
+
|
43
|
+
* **No extensive tooling or code generation** Code generation has been the
|
44
|
+
subject of more than one flame-war over the years. Not much I can add to the
|
45
|
+
debate. <em>But still,</em> with a language as dynamic as Ruby, you just
|
46
|
+
shouldn't need code generation. Ever.
|
47
|
+
|
48
|
+
* **Full support for conditional requests** using `If-*:` request headers. Most
|
49
|
+
libraries' support is limited to `If-None-Match:` and `If-Modified-Since:`
|
50
|
+
headers, and only for `GET` and `HEAD` requests. For Rackful web services,
|
51
|
+
the `If-Match:` and `If-Unmodified-Since:` headers are at least as important,
|
52
|
+
particularly for unsafe methods like `PUT`, `POST`, `PATCH`, and `DELETE`.
|
53
|
+
|
54
|
+
This library fully supports the `ETag:` and `Last-Modified:` headers, and all
|
55
|
+
`If-*:` headers.
|
56
|
+
|
57
|
+
* **Resource centered** Some libraries claim Rackfulness, but at the same
|
58
|
+
time have a servet-like interface, which requires you to implement method
|
59
|
+
handles such as `doPOST(url)`. In these method handlers you have to find out
|
60
|
+
what resource is posted to, depending on the URL.
|
61
|
+
|
62
|
+
This library requires that you implement a Resource Factory which maps URIs
|
63
|
+
to resource Objects. These objects will then receive HTTP requests.
|
64
|
+
|
65
|
+
Hello World!
|
66
|
+
------------
|
67
|
+
|
68
|
+
Here's a working example of a simple Rackful server:
|
69
|
+
|
70
|
+
{include:file:example/config.ru}
|
71
|
+
|
72
|
+
This file is included in the distribution as `example/config.ru`.
|
73
|
+
If you go to the `example` directory and run `rackup`, you should see
|
74
|
+
something like this:
|
75
|
+
|
76
|
+
$> rackup
|
77
|
+
[2012-07-10 11:45:32] INFO WEBrick 1.3.1
|
78
|
+
[2012-07-10 11:45:32] INFO ruby 1.9.2 (2011-12-27) [java]
|
79
|
+
[2012-07-10 11:45:32] INFO WEBrick::HTTPServer#start: pid=5994 port=9292
|
80
|
+
|
81
|
+
Go with your browser to {http://localhost:9292/} and be greeted.
|
82
|
+
|
83
|
+
In this example, we implement `GET` and `PUT` requests for the resource at '/'. but
|
84
|
+
we get a few things for free:
|
85
|
+
|
86
|
+
### Free `OPTIONS` response:
|
87
|
+
|
88
|
+
Request:
|
89
|
+
|
90
|
+
OPTIONS / HTTP/1.1
|
91
|
+
Host: localhost:9292
|
92
|
+
|
93
|
+
Response:
|
94
|
+
|
95
|
+
HTTP/1.1 204 No Content
|
96
|
+
Allow: PUT, GET, HEAD, OPTIONS
|
97
|
+
Date: Tue, 10 Jul 2012 10:22:52 GMT
|
98
|
+
|
99
|
+
As you can see, the server accurately reports all available methods for the
|
100
|
+
resource. Notice the availability of the `HEAD` method; if you implement the
|
101
|
+
`GET` method, you'll get `HEAD` for free. It's still a good idea to explicitly
|
102
|
+
implement your own `HEAD` request handler, especially for expensive resources,
|
103
|
+
when responding to a `HEAD` request should be much more efficient than generating
|
104
|
+
a full `GET` response, and strip off the response body.
|
105
|
+
|
106
|
+
### Free conditional request handling:
|
107
|
+
|
108
|
+
Let's first get the current state of the resource, with this request:
|
109
|
+
|
110
|
+
GET / HTTP/1.1
|
111
|
+
Host: localhost:9292
|
112
|
+
|
113
|
+
Response:
|
114
|
+
|
115
|
+
HTTP/1.1 200 OK
|
116
|
+
Content-Type: text/plain
|
117
|
+
Content-Length: 12
|
118
|
+
ETag: "86fb269d190d2c85f6e0468ceca42a20"
|
119
|
+
Date: Tue, 10 Jul 2012 10:34:36 GMT
|
120
|
+
|
121
|
+
Hello world!
|
122
|
+
|
123
|
+
Now, we'd like to change the state of the resource, but only if it's still in
|
124
|
+
the state we last saw, to avoid the "lost update problem". To do that, we
|
125
|
+
produce an `If-Match:` header, with the entity tag of our last version:
|
126
|
+
|
127
|
+
PUT / HTTP/1.1
|
128
|
+
Host: localhost:9292
|
129
|
+
Content-Type: text/plain
|
130
|
+
Content-Length: 31
|
131
|
+
If-Match: "86fb269d190d2c85f6e0468ceca42a20"
|
132
|
+
|
133
|
+
All your base are belong to us.
|
134
|
+
|
135
|
+
Response:
|
136
|
+
|
137
|
+
HTTP/1.1 204 No Content
|
138
|
+
ETag: "920c1e9267f923c62b55a471c1d8a528"
|
139
|
+
Date: Tue, 10 Jul 2012 10:58:57 GMT
|
140
|
+
|
141
|
+
The response contains an `ETag:` header, with the _new_ entity tag of this
|
142
|
+
resource. When we replay this request, we get the following response:
|
143
|
+
|
144
|
+
HTTP/1.1 412 Precondition Failed
|
145
|
+
Content-Type: text/html; charset="UTF-8"
|
146
|
+
Date: Tue, 10 Jul 2012 11:06:54 GMT
|
147
|
+
|
148
|
+
[...]
|
149
|
+
<h1>HTTP/1.1 412 Precondition Failed</h1>
|
150
|
+
<p>If-Match: "86fb269d190d2c85f6e0468ceca42a20"</p>
|
151
|
+
[...]
|
152
|
+
|
153
|
+
The server returns with status <tt>412 Precondition Failed</tt>. In the HTML
|
154
|
+
response body, the server kindly points out exactly which precondition.
|
155
|
+
|
156
|
+
Further reading
|
157
|
+
---------------
|
158
|
+
* {Rackful::Server#initialize} for more information about your Resource Factory.
|
159
|
+
* {Rackful::Resource#etag} and {Rackful::Resource#last_modified} for more information on
|
160
|
+
conditional requests.
|
161
|
+
* {Rackful::Resource#do_METHOD} for more information about writing your own request
|
162
|
+
handlers.
|
163
|
+
* {Rackful::RelativeLocation} for more information about this piece of Rack middleware
|
164
|
+
which allows you to return relative and absolute paths in the `Location:`
|
165
|
+
response header, and why you'd want that.
|
166
|
+
|
167
|
+
Licensing
|
168
|
+
---------
|
169
|
+
Copyright ©2011-2012 Pieter van Beek <pieterb@sara.nl>
|
170
|
+
|
171
|
+
Licensed under the Apache License 2.0. You should have received a copy of the
|
172
|
+
license as part of this distribution.
|
173
|
+
@author Pieter van Beek <rackful@djinnit.com>
|
174
|
+
=end
|
175
|
+
module Rackful
|
176
|
+
|
177
|
+
|
178
|
+
=begin markdown
|
179
|
+
Subclass of {Rack::Request}, augmented for Rackful requests.
|
180
|
+
@since 0.0.1
|
181
|
+
=end
|
182
|
+
class Request < Rack::Request
|
183
|
+
|
184
|
+
|
185
|
+
=begin markdown
|
186
|
+
The resource factory for the current request.
|
187
|
+
@return [#[]]
|
188
|
+
@see Server#initialize
|
189
|
+
@since 0.0.1
|
190
|
+
=end
|
191
|
+
attr_reader :resource_factory
|
192
|
+
|
193
|
+
|
194
|
+
def initialize resource_factory, *args
|
195
|
+
super *args
|
196
|
+
@resource_factory = resource_factory
|
197
|
+
end
|
198
|
+
|
199
|
+
|
200
|
+
=begin markdown
|
201
|
+
The request currently being processed in the current thread.
|
202
|
+
|
203
|
+
In a multi-threaded server, multiple requests can be handled at one time.
|
204
|
+
This method returns the request object, created (and registered) by
|
205
|
+
{Server#call!}
|
206
|
+
@return [Request]
|
207
|
+
@since 0.0.1
|
208
|
+
=end
|
209
|
+
def self.current
|
210
|
+
Thread.current[:djinn_request]
|
211
|
+
end
|
212
|
+
|
213
|
+
|
214
|
+
=begin markdown
|
215
|
+
Assert all <tt>If-*</tt> request headers.
|
216
|
+
@return [void]
|
217
|
+
@raise [HTTPStatus] with one of the following status codes:
|
218
|
+
|
219
|
+
- `304 Not Modified`
|
220
|
+
- `400 Bad Request` Couldn't parse one or more <tt>If-*</tt> headers, or a
|
221
|
+
weak validator comparison was requested for methods other than `GET` or
|
222
|
+
`HEAD`.
|
223
|
+
- `404 Not Found`
|
224
|
+
- `412 Precondition Failed`
|
225
|
+
- `501 Not Implemented` in case of `If-Range:` header.
|
226
|
+
@see http://tools.ietf.org/html/rfc2616#section-13.3.3 RFC2616, section 13.3.3
|
227
|
+
for details about weak and strong validator comparison.
|
228
|
+
@todo Implement support for the `If-Range:` header.
|
229
|
+
@since 0.0.1
|
230
|
+
=end
|
231
|
+
def assert_if_headers resource
|
232
|
+
raise HTTPStatus, 'NOT_IMPLEMENTED If-Range: request header is not supported.' \
|
233
|
+
if @env.key? 'HTTP_IF_RANGE'
|
234
|
+
empty = resource.empty?
|
235
|
+
etag = ( ! empty && resource.respond_to?(:etag) ) ? resource.etag : nil
|
236
|
+
last_modified = ( ! empty && resource.respond_to?(:last_modified) ) ? resource.last_modified : nil
|
237
|
+
cond = {
|
238
|
+
:match => self.if_match,
|
239
|
+
:none_match => self.if_none_match,
|
240
|
+
:modified_since => self.if_modified_since,
|
241
|
+
:unmodified_since => self.if_unmodified_since
|
242
|
+
}
|
243
|
+
allow_weak = ['GET', 'HEAD'].include? self.request_method
|
244
|
+
if empty
|
245
|
+
if cond[:match]
|
246
|
+
raise HTTPStatus, "PRECONDITION_FAILED If-Match: #{@env['HTTP_IF_MATCH']}"
|
247
|
+
elsif cond[:unmodified_since]
|
248
|
+
raise HTTPStatus, "PRECONDITION_FAILED If-Unmodified-Since: #{@env['HTTP_IF_UNMODIFIED_SINCE']}"
|
249
|
+
elsif cond[:modified_since]
|
250
|
+
raise HTTPStatus, 'NOT_FOUND'
|
251
|
+
end
|
252
|
+
else
|
253
|
+
if cond[:none_match] && self.validate_etag( etag, cond[:none_match] )
|
254
|
+
raise HTTPStatus, "PRECONDITION_FAILED If-None-Match: #{@env['HTTP_IF_NONE_MATCH']}"
|
255
|
+
elsif cond[:match] && ! self.validate_etag( etag, cond[:match] )
|
256
|
+
raise HTTPStatus, "PRECONDITION_FAILED If-Match: #{@env['HTTP_IF_MATCH']}"
|
257
|
+
elsif cond[:unmodified_since]
|
258
|
+
if ! last_modified || cond[:unmodified_since] < last_modified[0]
|
259
|
+
raise HTTPStatus, "PRECONDITION_FAILED If-Unmodified-Since: #{@env['HTTP_IF_UNMODIFIED_SINCE']}"
|
260
|
+
elsif last_modified && ! last_modified[1] && ! allow_weak &&
|
261
|
+
cond[:unmodified_since] == last_modified[0]
|
262
|
+
raise HTTPStatus,
|
263
|
+
"PRECONDITION_FAILED If-Unmodified-Since: #{@env['HTTP_IF_UNMODIFIED_SINCE']}<br/>" +
|
264
|
+
"Modification time is a weak validator for this resource."
|
265
|
+
end
|
266
|
+
elsif cond[:modified_since]
|
267
|
+
if ! last_modified || cond[:modified_since] >= last_modified[0]
|
268
|
+
raise HTTPStatus, 'NOT_MODIFIED'
|
269
|
+
elsif last_modified && ! last_modified[1] && !allow_weak &&
|
270
|
+
cond[:modified_since] == last_modified[0]
|
271
|
+
raise HTTPStatus,
|
272
|
+
"PRECONDITION_FAILED If-Modified-Since: #{@env['HTTP_IF_MODIFIED_SINCE']}<br/>" +
|
273
|
+
"Modification time is a weak validator for this resource."
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
|
280
|
+
=begin markdown
|
281
|
+
Hash of acceptable media types and their qualities.
|
282
|
+
|
283
|
+
This method parses the HTTP/1.1 `Accept:` header. If no acceptable media
|
284
|
+
types are provided, an empty Hash is returned.
|
285
|
+
@return [Hash{media_type => quality}]
|
286
|
+
@since 0.0.1
|
287
|
+
=end
|
288
|
+
def accept
|
289
|
+
@env['djinn.accept'] ||= begin
|
290
|
+
Hash[
|
291
|
+
@env['HTTP_ACCEPT'].to_s.split(',').collect do
|
292
|
+
|entry|
|
293
|
+
type, *options = entry.delete(' ').split(';')
|
294
|
+
quality = 1
|
295
|
+
options.each { |e|
|
296
|
+
quality = e[2..-1].to_f if e.start_with? 'q='
|
297
|
+
}
|
298
|
+
[type, quality]
|
299
|
+
end
|
300
|
+
]
|
301
|
+
rescue
|
302
|
+
{}
|
303
|
+
end
|
304
|
+
end # def accept
|
305
|
+
|
306
|
+
|
307
|
+
=begin markdown
|
308
|
+
The best media type for the response body...
|
309
|
+
|
310
|
+
...given the client's `Accept:` header(s) and the available representations
|
311
|
+
in the server.
|
312
|
+
@param content_types [Hash{media_type => quality}]
|
313
|
+
indicating what media types can be provided by the server, with their
|
314
|
+
relative qualities.
|
315
|
+
@param require_match [Boolean]
|
316
|
+
Should this method throw an {HTTPStatus} exception
|
317
|
+
`406 Not Acceptable` if there's no match.
|
318
|
+
@return [String]
|
319
|
+
@raise [HTTPStatus] `406 Not Acceptable`
|
320
|
+
@todo This method and its documentation seem to mix **content type** and
|
321
|
+
**media type**. I think the implementation is good, only comparing
|
322
|
+
**media types**, so all references to **content types** should be
|
323
|
+
removed.
|
324
|
+
@since 0.0.1
|
325
|
+
=end
|
326
|
+
def best_content_type content_types, require_match = true
|
327
|
+
return content_types.sort_by(&:last).last[0] if self.accept.empty?
|
328
|
+
matches = []
|
329
|
+
self.accept.each {
|
330
|
+
|accept_type, accept_quality|
|
331
|
+
content_types.each {
|
332
|
+
|response_type, response_quality|
|
333
|
+
mime_type = response_type.split(';').first.strip
|
334
|
+
if File.fnmatch( accept_type, mime_type )
|
335
|
+
matches.push [ response_type, accept_quality * response_quality ]
|
336
|
+
end
|
337
|
+
}
|
338
|
+
}
|
339
|
+
if matches.empty?
|
340
|
+
raise HTTPStatus, 'NOT_ACCEPTABLE' if require_match
|
341
|
+
nil
|
342
|
+
else
|
343
|
+
matches.sort_by(&:last).last[0]
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
|
348
|
+
=begin markdown
|
349
|
+
@deprecated This method seems to be unused...
|
350
|
+
@since 0.0.1
|
351
|
+
=end
|
352
|
+
def truthy? parameter
|
353
|
+
value = self.GET[parameter] || ''
|
354
|
+
%r{\A(1|t(rue)?|y(es)?|on)\z}i === value
|
355
|
+
end
|
356
|
+
|
357
|
+
|
358
|
+
=begin markdown
|
359
|
+
@deprecated This method seems to be unused...
|
360
|
+
@since 0.0.1
|
361
|
+
=end
|
362
|
+
def falsy? parameter
|
363
|
+
value = self.GET[parameter] || ''
|
364
|
+
%r{\A(0|f(alse)?|n(o)?|off)\z}i === value
|
365
|
+
end
|
366
|
+
|
367
|
+
|
368
|
+
=begin markdown
|
369
|
+
@!method if_match()
|
370
|
+
Parses the HTTP/1.1 `If-Match:` header.
|
371
|
+
@return [nil, Array<String>]
|
372
|
+
@see http://tools.ietf.org/html/rfc2616#section-14.24 RFC2616, section 14.24
|
373
|
+
@see #if_none_match
|
374
|
+
@since 0.0.1
|
375
|
+
=end
|
376
|
+
def if_match none = false
|
377
|
+
header = @env["HTTP_IF_#{ none ? 'NONE_' : '' }MATCH"]
|
378
|
+
return nil unless header
|
379
|
+
envkey = "djinn.if_#{ none ? 'none_' : '' }match"
|
380
|
+
if %r{\A\s*\*\s*\z} === header
|
381
|
+
return [ '*' ]
|
382
|
+
elsif %r{\A(\s*(W/)?"([^"\\]|\\.)*"\s*,)+\z}m === ( header + ',' )
|
383
|
+
return header.scan( %r{(?:W/)?"(?:[^"\\]|\\.)*"}m )
|
384
|
+
end
|
385
|
+
raise HTTPStatus, "BAD_REQUEST Couldn't parse If-#{ none ? 'None-' : '' }Match: #{header}"
|
386
|
+
end
|
387
|
+
|
388
|
+
|
389
|
+
=begin markdown
|
390
|
+
Parses the HTTP/1.1 `If-None-Match:` header.
|
391
|
+
@return [nil, Array<String>]
|
392
|
+
@see http://tools.ietf.org/html/rfc2616#section-14.26 RFC2616, section 14.26
|
393
|
+
@see #if_match
|
394
|
+
@since 0.0.1
|
395
|
+
=end
|
396
|
+
def if_none_match
|
397
|
+
self.if_match true
|
398
|
+
end
|
399
|
+
|
400
|
+
|
401
|
+
=begin markdown
|
402
|
+
@!method if_modified_since()
|
403
|
+
@return [nil, Time]
|
404
|
+
@see http://tools.ietf.org/html/rfc2616#section-14.25 RFC2616, section 14.25
|
405
|
+
@see #if_unmodified_since
|
406
|
+
@since 0.0.1
|
407
|
+
=end
|
408
|
+
def if_modified_since unmodified = false
|
409
|
+
header = @env["HTTP_IF_#{ unmodified ? 'UN' : '' }MODIFIED_SINCE"]
|
410
|
+
return nil unless header
|
411
|
+
begin
|
412
|
+
header = Time.httpdate( header )
|
413
|
+
rescue ArgumentError
|
414
|
+
raise HTTPStatus, "BAD_REQUEST Couldn't parse If-#{ unmodified ? 'Unmodified' : 'Modified' }-Since: #{header}"
|
415
|
+
end
|
416
|
+
header
|
417
|
+
end
|
418
|
+
|
419
|
+
|
420
|
+
=begin markdown
|
421
|
+
@return [nil, Time]
|
422
|
+
@see http://tools.ietf.org/html/rfc2616#section-14.28 RFC2616, section 14.28
|
423
|
+
@see #if_modified_since
|
424
|
+
@since 0.0.1
|
425
|
+
=end
|
426
|
+
def if_unmodified_since
|
427
|
+
self.if_modified_since true
|
428
|
+
end
|
429
|
+
|
430
|
+
|
431
|
+
=begin markdown
|
432
|
+
Does any of the tags in `etags` match `etag`?
|
433
|
+
@param etag [#to_s]
|
434
|
+
@param etags [#to_a]
|
435
|
+
@example
|
436
|
+
etag = '"foo"'
|
437
|
+
etags = [ 'W/"foo"', '"bar"' ]
|
438
|
+
validate_etag etag, etags
|
439
|
+
#> true
|
440
|
+
@return [Boolean]
|
441
|
+
@see http://tools.ietf.org/html/rfc2616#section-13.3.3 RFC2616 section 13.3.3
|
442
|
+
for details about weak and strong validator comparison.
|
443
|
+
@since 0.0.1
|
444
|
+
=end
|
445
|
+
def validate_etag etag, etags
|
446
|
+
etag = etag.to_s
|
447
|
+
match = etags.to_a.detect do
|
448
|
+
|tag|
|
449
|
+
tag = tag.to_s
|
450
|
+
tag == '*' or
|
451
|
+
tag == etag or
|
452
|
+
'W/' + tag == etag or
|
453
|
+
'W/' + etag == tag
|
454
|
+
end
|
455
|
+
if match and
|
456
|
+
'*' != match and
|
457
|
+
'W/' == etag[0,2] || 'W/' == match[0,2] and
|
458
|
+
! [ 'HEAD', 'GET' ].include? self.request_method
|
459
|
+
raise HTTPStatus, "BAD_REQUEST Weak validators are only allowed for GET and HEAD requests."
|
460
|
+
end
|
461
|
+
!!match
|
462
|
+
end
|
463
|
+
|
464
|
+
|
465
|
+
end # class Request
|
466
|
+
|
467
|
+
|
468
|
+
=begin markdown
|
469
|
+
Mixin for resources served by {Server}.
|
470
|
+
|
471
|
+
{Server} helps you implement Rackful resource objects quickly in a couple
|
472
|
+
of ways.
|
473
|
+
Classes that include this module may implement a method `content_types`
|
474
|
+
for content negotiation. This method must return a Hash of
|
475
|
+
`mime-type => quality` pairs.
|
476
|
+
@see Server, ResourceFactory
|
477
|
+
@since 0.0.1
|
478
|
+
=end
|
479
|
+
module Resource
|
480
|
+
|
481
|
+
|
482
|
+
include Rack::Utils
|
483
|
+
|
484
|
+
|
485
|
+
=begin markdown
|
486
|
+
@!method do_METHOD( Request, Rack::Response )
|
487
|
+
HTTP/1.1 method handler.
|
488
|
+
|
489
|
+
To handle certain HTTP/1.1 request methods, resources must implement methods
|
490
|
+
called `do_<HTTP_METHOD>`.
|
491
|
+
@example Handling `GET` requests
|
492
|
+
def do_GET request, response
|
493
|
+
response['Content-Type'] = 'text/plain'
|
494
|
+
response.body = [ 'Hello world!' ]
|
495
|
+
end
|
496
|
+
@abstract
|
497
|
+
@return [void]
|
498
|
+
@raise [HTTPStatus, RuntimeError]
|
499
|
+
@since 0.0.1
|
500
|
+
=end
|
501
|
+
|
502
|
+
|
503
|
+
=begin markdown
|
504
|
+
The path of this resource.
|
505
|
+
@return [String]
|
506
|
+
@see #initialize
|
507
|
+
@since 0.0.1
|
508
|
+
=end
|
509
|
+
attr_reader :path
|
510
|
+
|
511
|
+
|
512
|
+
=begin markdown
|
513
|
+
@param path [#to_s] The path of this resource. This is a `path-absolute` as
|
514
|
+
defined in {http://tools.ietf.org/html/rfc3986#section-3.3 RFC3986, section 3.3}.
|
515
|
+
@see #path
|
516
|
+
@since 0.0.1
|
517
|
+
=end
|
518
|
+
def initialize path
|
519
|
+
@path = path.to_s
|
520
|
+
end
|
521
|
+
|
522
|
+
|
523
|
+
=begin markdown
|
524
|
+
Does this resource _exists_?
|
525
|
+
|
526
|
+
For example, a client can `PUT` to a URL that doesn't refer to a resource
|
527
|
+
yet. In that case, your {Server#resource_factory resource factory} can
|
528
|
+
produce an empty resource to to handle the `PUT` request. `HEAD` and `GET`
|
529
|
+
requests will still yield `404 Not Found`.
|
530
|
+
|
531
|
+
@return [Boolean] The default implementation returns `false`.
|
532
|
+
@since 0.0.1
|
533
|
+
=end
|
534
|
+
def empty?
|
535
|
+
false
|
536
|
+
end
|
537
|
+
|
538
|
+
|
539
|
+
=begin markdown
|
540
|
+
List of all HTTP/1.1 methods implemented by this resource.
|
541
|
+
|
542
|
+
This works by inspecting all the {#do_METHOD} methods this object implements.
|
543
|
+
@return [Array<Symbol>]
|
544
|
+
@since 0.0.1
|
545
|
+
=end
|
546
|
+
def http_methods
|
547
|
+
unless @djinn_resource_http_methods
|
548
|
+
@djinn_resource_http_methods = []
|
549
|
+
self.public_methods.each do
|
550
|
+
|public_method|
|
551
|
+
if ( match = /\Ado_([A-Z]+)\z/.match( public_method ) )
|
552
|
+
@djinn_resource_http_methods << match[1].to_sym
|
553
|
+
end
|
554
|
+
end
|
555
|
+
@djinn_resource_http_methods.delete :HEAD \
|
556
|
+
unless @djinn_resource_http_methods.include? :GET
|
557
|
+
end
|
558
|
+
@djinn_resource_http_methods
|
559
|
+
end
|
560
|
+
|
561
|
+
|
562
|
+
=begin markdown
|
563
|
+
Handles a HEAD request.
|
564
|
+
|
565
|
+
As a courtesy, this module implements a default handler for HEAD requests,
|
566
|
+
which calls {#do\_METHOD #do\_GET}, and then strips of the response body.
|
567
|
+
|
568
|
+
If this resource implements method `content_types`, then `response['Content-Type']`
|
569
|
+
will be set in the response object passed to {#do\_METHOD #do\_GET}.
|
570
|
+
|
571
|
+
Feel free to override this method at will.
|
572
|
+
@return [void]
|
573
|
+
@raise [HTTPStatus] `405 Method Not Allowed` if the resource doesn't implement the `GET` method.
|
574
|
+
@since 0.0.1
|
575
|
+
=end
|
576
|
+
def do_HEAD request, response
|
577
|
+
raise Rackful::HTTPStatus, 'METHOD_NOT_ALLOWED ' + self.http_methods.join( ' ' ) \
|
578
|
+
unless self.respond_to? :do_GET
|
579
|
+
self.do_GET request, response
|
580
|
+
response['Content-Length'] =
|
581
|
+
response.body.reduce(0) do
|
582
|
+
|memo, s| memo + bytesize(s)
|
583
|
+
end.to_s
|
584
|
+
response.body = []
|
585
|
+
end
|
586
|
+
|
587
|
+
|
588
|
+
=begin markdown
|
589
|
+
Handles an OPTIONS request.
|
590
|
+
|
591
|
+
As a courtesy, this module implements a default handler for OPTIONS
|
592
|
+
requests. It creates an `Allow:` header, listing all implemented HTTP/1.1
|
593
|
+
methods for this resource. By default, an `HTTP/1.1 204 No Content` is
|
594
|
+
returned (without an entity body).
|
595
|
+
|
596
|
+
Feel free to override this method at will.
|
597
|
+
@return [void]
|
598
|
+
@raise [HTTPStatus] `404 Not Found` if this resource is empty.
|
599
|
+
@since 0.0.1
|
600
|
+
=end
|
601
|
+
def do_OPTIONS request, response
|
602
|
+
raise Rackful::HTTPStatus, 'NOT_FOUND' if self.empty?
|
603
|
+
response.status = status_code :no_content
|
604
|
+
response.header['Allow'] = self.http_methods.join ', '
|
605
|
+
end
|
606
|
+
|
607
|
+
|
608
|
+
=begin markdown
|
609
|
+
@!attribute [r] etag
|
610
|
+
The ETag of this resource.
|
611
|
+
|
612
|
+
If your classes implement this method, then an `ETag:` response
|
613
|
+
header is generated automatically when appropriate. This allows clients to
|
614
|
+
perform conditional requests, by sending an `If-Match:` or
|
615
|
+
`If-None-Match:` request header. These conditions are then asserted
|
616
|
+
for you automatically.
|
617
|
+
|
618
|
+
Make sure your entity tag is a properly formatted string. In ABNF:
|
619
|
+
|
620
|
+
entity-tag = [ "W/" ] quoted-string
|
621
|
+
|
622
|
+
@abstract
|
623
|
+
@return [String]
|
624
|
+
@see http://tools.ietf.org/html/rfc2616#section-14.19 RFC2616 section 14.19
|
625
|
+
@since 0.0.1
|
626
|
+
=end
|
627
|
+
|
628
|
+
|
629
|
+
=begin markdown
|
630
|
+
@!attribute [r] last_modified
|
631
|
+
Last modification of this resource.
|
632
|
+
|
633
|
+
If your classes implement this method, then a `Last-Modified:` response
|
634
|
+
header is generated automatically when appropriate. This allows clients to
|
635
|
+
perform conditional requests, by sending an `If-Modified-Since:` or
|
636
|
+
`If-Unmodified-Since:` request header. These conditions are then asserted
|
637
|
+
for you automatically.
|
638
|
+
@abstract
|
639
|
+
@return [Array<(Time, Boolean)>] The timestamp, and a flag indicating if the
|
640
|
+
timestamp is a strong validator.
|
641
|
+
@see http://tools.ietf.org/html/rfc2616#section-14.29 RFC2616 section 14.29
|
642
|
+
@since 0.0.1
|
643
|
+
=end
|
644
|
+
|
645
|
+
|
646
|
+
=begin markdown
|
647
|
+
Wrapper around {#do_HEAD}
|
648
|
+
@private
|
649
|
+
@return [void]
|
650
|
+
@raise [HTTPStatus] `404 Not Found` if this resource is empty.
|
651
|
+
@since 0.0.1
|
652
|
+
=end
|
653
|
+
def http_HEAD request, response
|
654
|
+
raise Rackful::HTTPStatus, 'NOT_FOUND' if self.empty?
|
655
|
+
self.do_HEAD request, response
|
656
|
+
end
|
657
|
+
|
658
|
+
|
659
|
+
=begin markdown
|
660
|
+
Wrapper around {#do_METHOD #do_GET}
|
661
|
+
@private
|
662
|
+
@return [void]
|
663
|
+
@raise [HTTPStatus]
|
664
|
+
|
665
|
+
- `404 Not Found` if this resource is empty.
|
666
|
+
- `405 Method Not Allowed` if the resource doesn't implement the `GET` method.
|
667
|
+
@since 0.0.1
|
668
|
+
=end
|
669
|
+
def http_GET request, response
|
670
|
+
raise Rackful::HTTPStatus, 'NOT_FOUND' if self.empty?
|
671
|
+
raise Rackful::HTTPStatus, 'METHOD_NOT_ALLOWED ' + self.http_methods.join( ' ' ) \
|
672
|
+
unless self.respond_to? :do_GET
|
673
|
+
self.do_GET request, response
|
674
|
+
end
|
675
|
+
|
676
|
+
|
677
|
+
=begin markdown
|
678
|
+
Wrapper around {#do_METHOD #do_PUT}
|
679
|
+
@private
|
680
|
+
@return [void]
|
681
|
+
@raise [HTTPStatus] `405 Method Not Allowed` if the resource doesn't implement the `PUT` method.
|
682
|
+
@since 0.0.1
|
683
|
+
=end
|
684
|
+
def http_PUT request, response
|
685
|
+
raise Rackful::HTTPStatus, 'METHOD_NOT_ALLOWED ' + self.http_methods.join( ' ' ) \
|
686
|
+
unless self.respond_to? :do_PUT
|
687
|
+
self.do_PUT request, response
|
688
|
+
end
|
689
|
+
|
690
|
+
|
691
|
+
end # module Resource
|
692
|
+
|
693
|
+
|
694
|
+
=begin markdown
|
695
|
+
@todo documentation
|
696
|
+
@since 0.0.1
|
697
|
+
=end
|
698
|
+
class HTTPStatus < RuntimeError
|
699
|
+
|
700
|
+
|
701
|
+
include Rack::Utils
|
702
|
+
|
703
|
+
|
704
|
+
attr_reader :response
|
705
|
+
|
706
|
+
|
707
|
+
=begin markdown
|
708
|
+
@param message [String] `<status> [ <space> <message> ]`
|
709
|
+
@since 0.0.1
|
710
|
+
=end
|
711
|
+
def initialize( message )
|
712
|
+
@response = Rack::Response.new
|
713
|
+
matches = %r{\A(\S+)\s*(.*)\z}m.match(message.to_s)
|
714
|
+
status = status_code(matches[1].downcase.to_sym)
|
715
|
+
@response.status = status
|
716
|
+
message = matches[2]
|
717
|
+
case status
|
718
|
+
when 201, 301, 302, 303, 305, 307
|
719
|
+
message = message.split /\s+/
|
720
|
+
case message.length
|
721
|
+
when 0
|
722
|
+
message = ''
|
723
|
+
when 1
|
724
|
+
@response.header['Location'] = message[0]
|
725
|
+
message = "<p><a href=\"#{message[0]}\">#{escape_html(unescape message[0])}</a></p>"
|
726
|
+
else
|
727
|
+
message = '<ul>' + message.collect {
|
728
|
+
|url|
|
729
|
+
"\n<li><a href=\"#{url}\">#{escape_html(unescape url)}</a></li>"
|
730
|
+
}.join + '</ul>'
|
731
|
+
end
|
732
|
+
when 405 # Method not allowed
|
733
|
+
message = message.split /\s+/
|
734
|
+
@response.header['Allow'] = message.join ', '
|
735
|
+
message = '<h2>Allowed methods:</h2><ul><li>' +
|
736
|
+
message.join('</li><li>') + '</li></ul>'
|
737
|
+
when 406 # Unacceptable
|
738
|
+
message = message.split /\s+/
|
739
|
+
message = '<h2>Available representations:</h2><ul><li>' +
|
740
|
+
message.join('</li><li>') + '</li></ul>'
|
741
|
+
when 415 # Unsupported Media Type
|
742
|
+
message = message.split /\s+/
|
743
|
+
message = '<h2>Supported Media Types:</h2><ul><li>' +
|
744
|
+
message.join('</li><li>') + '</li></ul>'
|
745
|
+
end
|
746
|
+
super message
|
747
|
+
begin
|
748
|
+
REXML::Document.new \
|
749
|
+
'<?xml version="1.0" encoding="UTF-8" ?>' +
|
750
|
+
'<div>' + message + '</div>'
|
751
|
+
rescue
|
752
|
+
message = escape_html message
|
753
|
+
end
|
754
|
+
message = "<p>#{message}</p>" unless '<' == message[0, 1]
|
755
|
+
message = message.gsub( %r{\n}, "<br/>\n" )
|
756
|
+
@response.header['Content-Type'] = 'text/html; charset="UTF-8"'
|
757
|
+
@response.body = [ self.class.template.call( status, message ) ]
|
758
|
+
end
|
759
|
+
|
760
|
+
|
761
|
+
DEFAULT_TEMPLATE = lambda do
|
762
|
+
| status_code, xhtml_message |
|
763
|
+
status_code = status_code.to_i
|
764
|
+
xhtml_message = xhtml_message.to_s
|
765
|
+
<<EOS
|
766
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
767
|
+
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
768
|
+
<html xmlns="http://www.w3.org/1999/xhtml">
|
769
|
+
<head>
|
770
|
+
<title>HTTP/1.1 #{status_code.to_s} #{HTTP_STATUS_CODES[status_code]}</title>
|
771
|
+
</head>
|
772
|
+
<body>
|
773
|
+
<h1>HTTP/1.1 #{status_code.to_s} #{HTTP_STATUS_CODES[status_code]}</h1>
|
774
|
+
#{xhtml_message}
|
775
|
+
</body>
|
776
|
+
</html>
|
777
|
+
EOS
|
778
|
+
end
|
779
|
+
|
780
|
+
|
781
|
+
=begin markdown
|
782
|
+
Sets/gets the current template.
|
783
|
+
|
784
|
+
The passed block must accept two arguments:
|
785
|
+
|
786
|
+
1. *int* a status code
|
787
|
+
2. *string* an xhtml fragment
|
788
|
+
|
789
|
+
and return a string
|
790
|
+
@since 0.0.1
|
791
|
+
=end
|
792
|
+
def self.template(&block)
|
793
|
+
@template ||= ( block || DEFAULT_TEMPLATE )
|
794
|
+
end
|
795
|
+
|
796
|
+
|
797
|
+
end
|
798
|
+
|
799
|
+
|
800
|
+
=begin markdown
|
801
|
+
Rack compliant server class for implementing RESTful web services.
|
802
|
+
@since 0.0.1
|
803
|
+
=end
|
804
|
+
class Server
|
805
|
+
|
806
|
+
|
807
|
+
=begin markdown
|
808
|
+
An object responding thread safely to method `#[]`.
|
809
|
+
|
810
|
+
A {Server} has no knowledge, and makes no presumptions, about your URI namespace.
|
811
|
+
It requires a _Resource Factory_ which produces {Resource Resources} given
|
812
|
+
a certain absolute path.
|
813
|
+
|
814
|
+
The Resource Factory you provide need only implement one method, with signature
|
815
|
+
`Resource #[]( String path )`.
|
816
|
+
This method will be called with a URI-encoded path string, and must return a
|
817
|
+
{Resource}, or `nil` if there's no resource at the given path.
|
818
|
+
|
819
|
+
For example, if a Rackful client
|
820
|
+
tries to access a resource with URI {http://example.com/your/resource http://example.com/some/resource},
|
821
|
+
then your Resource Factory can expect to be called like this:
|
822
|
+
|
823
|
+
resource = resource_factory[ '/your/resource' ]
|
824
|
+
|
825
|
+
If there's no resource at the given path, but you'd still like to respond to
|
826
|
+
`POST` or `PUT` requests to this path, you must return an
|
827
|
+
{Resource#empty? empty resource}.
|
828
|
+
@return [#[]]
|
829
|
+
@see #initialize
|
830
|
+
@since 0.0.1
|
831
|
+
=end
|
832
|
+
attr_reader :resource_factory
|
833
|
+
|
834
|
+
|
835
|
+
=begin markdown
|
836
|
+
{include:Server#resource_factory}
|
837
|
+
@since 0.0.1
|
838
|
+
=end
|
839
|
+
def initialize(resource_factory)
|
840
|
+
super()
|
841
|
+
@resource_factory = resource_factory
|
842
|
+
end
|
843
|
+
|
844
|
+
|
845
|
+
=begin markdown
|
846
|
+
As required by the Rack specification.
|
847
|
+
|
848
|
+
For thread safety, this method clones `self`, which handles the request in
|
849
|
+
{#call!}. A similar approach is taken by the Sinatra library.
|
850
|
+
@return [Array<(status_code, response_headers, response_body)>]
|
851
|
+
@since 0.0.1
|
852
|
+
=end
|
853
|
+
def call(p_env)
|
854
|
+
start = Time.now
|
855
|
+
retval = dup.call! p_env
|
856
|
+
#$stderr.puts( 'Duration: ' + ( Time.now - start ).to_s )
|
857
|
+
retval
|
858
|
+
end
|
859
|
+
|
860
|
+
|
861
|
+
=begin markdown
|
862
|
+
@return [Array<(status_code, response_headers, response_body)>]
|
863
|
+
@since 0.0.1
|
864
|
+
=end
|
865
|
+
def call!(p_env)
|
866
|
+
request = Rackful::Request.new( resource_factory, p_env )
|
867
|
+
# See also Request::current():
|
868
|
+
Thread.current[:djinn_request] = request
|
869
|
+
begin
|
870
|
+
response = Rack::Response.new
|
871
|
+
begin
|
872
|
+
raise HTTPStatus, 'NOT_FOUND' \
|
873
|
+
unless resource = self.resource_factory[request.path]
|
874
|
+
response.header['Content-Location'] = request.base_url + resource.path \
|
875
|
+
unless resource.path == request.path
|
876
|
+
request.assert_if_headers resource
|
877
|
+
if resource.respond_to? :"http_#{request.request_method}"
|
878
|
+
resource.__send__( :"http_#{request.request_method}", request, response )
|
879
|
+
elsif resource.respond_to? :"do_#{request.request_method}"
|
880
|
+
resource.__send__( :"do_#{request.request_method}", request, response )
|
881
|
+
else
|
882
|
+
raise HTTPStatus, 'METHOD_NOT_ALLOWED ' + resource.http_methods.join( ' ' )
|
883
|
+
end
|
884
|
+
rescue HTTPStatus
|
885
|
+
response = $!.response
|
886
|
+
raise if 500 == response.status
|
887
|
+
# The next line fixes a small peculiarity in RFC2616: the response body of
|
888
|
+
# a `HEAD` request _must_ be empty, even for responses outside 2xx.
|
889
|
+
if request.head?
|
890
|
+
response.body = []
|
891
|
+
response['Content-Length'] = '0'
|
892
|
+
end
|
893
|
+
end
|
894
|
+
if 201 == response.status &&
|
895
|
+
( location = response['Location'] ) &&
|
896
|
+
( new_resource = request.resource_factory[location] ) &&
|
897
|
+
! new_resource.empty? \
|
898
|
+
or ( (200...300) === response.status ||
|
899
|
+
304 == response.status ) &&
|
900
|
+
! response['Location'] &&
|
901
|
+
( new_resource = request.resource_factory[request.path] ) &&
|
902
|
+
! new_resource.empty?
|
903
|
+
set_default_headers new_resource, response
|
904
|
+
end
|
905
|
+
response.finish
|
906
|
+
ensure
|
907
|
+
Thread.current[:djinn_request] = nil
|
908
|
+
end # begin
|
909
|
+
end
|
910
|
+
|
911
|
+
|
912
|
+
private
|
913
|
+
|
914
|
+
|
915
|
+
=begin markdown
|
916
|
+
Adds `ETag:` and `Last-Modified:` response headers.
|
917
|
+
@since 0.0.1
|
918
|
+
=end
|
919
|
+
def set_default_headers resource, response
|
920
|
+
if ! response.include?( 'ETag' ) &&
|
921
|
+
resource.respond_to?( :etag )
|
922
|
+
response['ETag'] = resource.etag
|
923
|
+
end
|
924
|
+
if ! response.include?( 'Last-Modified' ) &&
|
925
|
+
resource.respond_to?( :last_modified )
|
926
|
+
response['Last-Modified'] = resource.last_modified[0].httpdate
|
927
|
+
end
|
928
|
+
end
|
929
|
+
|
930
|
+
|
931
|
+
end # class Server
|
932
|
+
|
933
|
+
|
934
|
+
end # module Rackful
|