rackful 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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