rackful 0.0.2 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/rackful.rb CHANGED
@@ -1,934 +1,6 @@
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
1
+ require 'rackful_path.rb'
2
+ require 'rackful_request.rb'
3
+ require 'rackful_serializer.rb'
4
+ require 'rackful_resource.rb'
5
+ require 'rackful_http_status.rb'
6
+ require 'rackful_server.rb'