webmachine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,502 @@
1
+ require 'time'
2
+ require 'digest/md5'
3
+ require 'webmachine/decision/conneg'
4
+ require 'webmachine/translation'
5
+
6
+ module Webmachine
7
+ module Decision
8
+ # This module encapsulates all of the decisions in Webmachine's
9
+ # flow-chart. These invoke {Resource} {Callbacks} to determine the
10
+ # appropriate response code, headers, and body for the response.
11
+ #
12
+ # This module is included into {FSM}, which drives the processing
13
+ # of the chart.
14
+ # @see http://webmachine.basho.com/images/http-headers-status-v3.png
15
+ module Flow
16
+ # Version of the flow diagram
17
+ VERSION = 3
18
+
19
+ # The first state in flow diagram
20
+ START = :b13
21
+
22
+ # Separate content-negotiation logic from flow diagram.
23
+ include Conneg
24
+
25
+ # Extract error strings into locale files
26
+ include Translation
27
+
28
+ # Handles standard decisions where halting is allowed
29
+ def decision_test(test, value, iftrue, iffalse)
30
+ case test
31
+ when value
32
+ iftrue
33
+ when Fixnum # Allows callbacks to "halt" with a given response code
34
+ test
35
+ else
36
+ iffalse
37
+ end
38
+ end
39
+
40
+ # Service available?
41
+ def b13
42
+ decision_test(resource.service_available?, true, :b12, 503)
43
+ end
44
+
45
+ # Known method?
46
+ def b12
47
+ decision_test(resource.known_methods.include?(request.method), true, :b11, 501)
48
+ end
49
+
50
+ # URI too long?
51
+ def b11
52
+ decision_test(resource.uri_too_long?(request.uri), true, 414, :b10)
53
+ end
54
+
55
+ # Method allowed?
56
+ def b10
57
+ if resource.allowed_methods.include?(request.method)
58
+ :b9
59
+ else
60
+ response.headers["Allow"] = resource.allowed_methods.join(", ")
61
+ 405
62
+ end
63
+ end
64
+
65
+ # Content-MD5 present?
66
+ def b9
67
+ request.content_md5 ? :b9a : :b9b
68
+ end
69
+
70
+ # Content-MD5 valid?
71
+ def b9a
72
+ case valid = resource.validate_content_checksum
73
+ when Fixnum
74
+ valid
75
+ when true
76
+ :b9b
77
+ when false
78
+ response.body = "Content-MD5 header does not match request body."
79
+ 400
80
+ else # not_validated
81
+ if request.content_md5 == Digest::MD5.hexdigest(request.body)
82
+ :b9b
83
+ else
84
+ response.body = "Content-MD5 header does not match request body."
85
+ 400
86
+ end
87
+ end
88
+ end
89
+
90
+ # Malformed?
91
+ def b9b
92
+ decision_test(resource.malformed_request?, true, 400, :b8)
93
+ end
94
+
95
+ # Authorized?
96
+ def b8
97
+ result = resource.is_authorized?(request.authorization)
98
+ case result
99
+ when true
100
+ :b7
101
+ when Fixnum
102
+ result
103
+ when String
104
+ response.headers['WWW-Authenticate'] = result
105
+ 401
106
+ else
107
+ 401
108
+ end
109
+ end
110
+
111
+ # Forbidden?
112
+ def b7
113
+ decision_test(resource.forbidden?, true, 403, :b6)
114
+ end
115
+
116
+ # Okay Content-* Headers?
117
+ def b6
118
+ decision_test(resource.valid_content_headers?(request.headers.grep(/content-/)), true, :b5, 501)
119
+ end
120
+
121
+ # Known Content-Type?
122
+ def b5
123
+ decision_test(resource.known_content_type?(request.content_type), true, :b4, 415)
124
+ end
125
+
126
+ # Req Entity Too Large?
127
+ def b4
128
+ decision_test(resource.valid_entity_length?(request.content_length), true, :b3, 413)
129
+ end
130
+
131
+ # OPTIONS?
132
+ def b3
133
+ if request.method == "OPTIONS"
134
+ response.headers.merge!(resource.options)
135
+ 200
136
+ else
137
+ :c3
138
+ end
139
+ end
140
+
141
+ # Accept exists?
142
+ def c3
143
+ if !request.accept
144
+ metadata['Content-Type'] = MediaType.parse(resource.content_types_provided.first.first)
145
+ :d4
146
+ else
147
+ :c4
148
+ end
149
+ end
150
+
151
+ # Acceptable media type available?
152
+ def c4
153
+ types = resource.content_types_provided.map {|pair| pair.first }
154
+ chosen_type = choose_media_type(types, request.accept)
155
+ if !chosen_type
156
+ 406
157
+ else
158
+ metadata['Content-Type'] = chosen_type
159
+ :d4
160
+ end
161
+ end
162
+
163
+ # Accept-Language exists?
164
+ def d4
165
+ if !request.accept_language
166
+ choose_language(resource.languages_provided, "*") ? :e5 : 406
167
+ else
168
+ :d5
169
+ end
170
+ end
171
+
172
+ # Acceptable language available?
173
+ def d5
174
+ choose_language(resource.languages_provided, request.accept_language) ? :e5 : 406
175
+ end
176
+
177
+ # Accept-Charset exists?
178
+ def e5
179
+ if !request.accept_charset
180
+ choose_charset(resource.charsets_provided, "*") ? :f6 : 406
181
+ else
182
+ :e6
183
+ end
184
+ end
185
+
186
+ # Acceptable Charset available?
187
+ def e6
188
+ choose_charset(resource.charsets_provided, request.accept_charset) ? :f6 : 406
189
+ end
190
+
191
+ # Accept-Encoding exists?
192
+ # (also, set content-type header here, now that charset is chosen)
193
+ def f6
194
+ chosen_type = metadata['Content-Type']
195
+ if chosen_charset = metadata['Charset']
196
+ chosen_type.params['charset'] = chosen_charset
197
+ end
198
+ response.headers['Content-Type'] = chosen_type.to_s
199
+ if !request.accept_encoding
200
+ choose_encoding(resource.encodings_provided, "identity;q=1.0,*;q=0.5") ? :g7 : 406
201
+ else
202
+ :f7
203
+ end
204
+ end
205
+
206
+ # Acceptable encoding available?
207
+ def f7
208
+ choose_encoding(resource.encodings_provided, request.accept_encoding) ? :g7 : 406
209
+ end
210
+
211
+ # Resource exists?
212
+ def g7
213
+ # This is the first place after all conneg, so set Vary here
214
+ response.headers['Vary'] = variances.join(", ") if variances.any?
215
+ decision_test(resource.resource_exists?, true, :g8, :h7)
216
+ end
217
+
218
+ # If-Match exists?
219
+ def g8
220
+ request.if_match ? :g9 : :h10
221
+ end
222
+
223
+ # If-Match: * exists?
224
+ def g9
225
+ request.if_match == "*" ? :h10 : :g11
226
+ end
227
+
228
+ # ETag in If-Match
229
+ def g11
230
+ request_etags = request.if_match.split(/\s*,\s*/).map {|etag| unquote_header(etag) }
231
+ request_etags.include?(resource.generate_etag) ? :h10 : 412
232
+ end
233
+
234
+ # If-Match exists?
235
+ def h7
236
+ (request.if_match && unquote_header(request.if_match) == '*') ? 412 : :i7
237
+ end
238
+
239
+ # If-Unmodified-Since exists?
240
+ def h10
241
+ request.if_unmodified_since ? :h11 : :i12
242
+ end
243
+
244
+ # If-Unmodified-Since is valid date?
245
+ def h11
246
+ begin
247
+ date = Time.httpdate(request.if_unmodified_since)
248
+ metadata['If-Unmodified-Since'] = date
249
+ rescue ArgumentError
250
+ :i12
251
+ else
252
+ :h12
253
+ end
254
+ end
255
+
256
+ # Last-Modified > I-UM-S?
257
+ def h12
258
+ resource.last_modified > metadata['If-Unmodified-Since'] ? 412 : :i12
259
+ end
260
+
261
+ # Moved permanently? (apply PUT to different URI)
262
+ def i4
263
+ case uri = resource.moved_permanently?
264
+ when String, URI
265
+ response.headers["Location"] = uri.to_s
266
+ 301
267
+ when Fixnum
268
+ uri
269
+ else
270
+ :p3
271
+ end
272
+ end
273
+
274
+ # PUT?
275
+ def i7
276
+ request.method == "PUT" ? :i4 : :k7
277
+ end
278
+
279
+ # If-none-match exists?
280
+ def i12
281
+ request.if_none_match ? :i13 : :l13
282
+ end
283
+
284
+ # If-none-match: * exists?
285
+ def i13
286
+ request.if_none_match == "*" ? :j18 : :k13
287
+ end
288
+
289
+ # GET or HEAD?
290
+ def j18
291
+ %w{GET HEAD}.include?(request.method) ? 304 : 412
292
+ end
293
+
294
+ # Moved permanently?
295
+ def k5
296
+ case uri = resource.moved_permanently?
297
+ when String, URI
298
+ response.headers["Location"] = uri.to_s
299
+ 301
300
+ when Fixnum
301
+ uri
302
+ else
303
+ :l5
304
+ end
305
+ end
306
+
307
+ # Previously existed?
308
+ def k7
309
+ decision_test(resource.previously_existed?, true, :k5, :l7)
310
+ end
311
+
312
+ # Etag in if-none-match?
313
+ def k13
314
+ request_etags = request.if_none_match.split(/\s*,\s*/).map {|etag| unquote_header(etag) }
315
+ request_etags.include?(resource.generate_etag) ? :j18 : :l13
316
+ end
317
+
318
+ # Moved temporarily?
319
+ def l5
320
+ case uri = resource.moved_temporarily?
321
+ when String, URI
322
+ response.headers["Location"] = uri.to_s
323
+ 307
324
+ when Fixnum
325
+ uri
326
+ else
327
+ :m5
328
+ end
329
+ end
330
+
331
+ # POST?
332
+ def l7
333
+ request.method == "POST" ? :m7 : 404
334
+ end
335
+
336
+ # If-Modified-Since exists?
337
+ def l13
338
+ request.if_modified_since ? :l14 : :m16
339
+ end
340
+
341
+ # IMS is valid date?
342
+ def l14
343
+ begin
344
+ date = Time.httpdate(request.if_modified_since)
345
+ metadata['If-Modified-Since'] = date
346
+ rescue ArgumentError
347
+ :m16
348
+ else
349
+ :l15
350
+ end
351
+ end
352
+
353
+ # IMS > Now?
354
+ def l15
355
+ metadata['If-Modified-Since'] > Time.now ? :m16 : :l17
356
+ end
357
+
358
+ # Last-Modified > IMS?
359
+ def l17
360
+ resource.last_modified.nil? || resource.last_modified > metadata['If-Modified-Since'] ? :m16 : 304
361
+ end
362
+
363
+ # POST?
364
+ def m5
365
+ request.method == "POST" ? :n5 : 410
366
+ end
367
+
368
+ # Server allows POST to missing resource?
369
+ def m7
370
+ decision_test(resource.allow_missing_post?, true, :n11, 404)
371
+ end
372
+
373
+ # DELETE?
374
+ def m16
375
+ request.method == "DELETE" ? :m20 : :n16
376
+ end
377
+
378
+ # DELETE enacted immediately? (Also where DELETE is forced.)
379
+ def m20
380
+ decision_test(resource.delete_resource, true, :m20b, 500)
381
+ end
382
+
383
+ def m20b
384
+ decision_test(resource.delete_completed?, true, :o20, 202)
385
+ end
386
+
387
+ # Server allows POST to missing resource?
388
+ def n5
389
+ decision_test(resource.allow_missing_post?, true, :n11, 410)
390
+ end
391
+
392
+ # Redirect?
393
+ def n11
394
+ # Stage1
395
+ if resource.post_is_create?
396
+ case uri = resource.create_path
397
+ when nil
398
+ raise InvalidResource, t('create_path_nil', :class => resource.class)
399
+ when URI, String
400
+ base_uri = resource.base_uri || request.base_uri
401
+ new_uri = URI.join(base_uri.to_s, uri)
402
+ request.disp_path = new_uri.path
403
+ response.headers['Location'] = new_uri.to_s
404
+ result = accept_helper
405
+ return result if Fixnum === result
406
+ end
407
+ else
408
+ case result = resource.process_post
409
+ when true
410
+ encode_body_if_set
411
+ when Fixnum
412
+ return result
413
+ else
414
+ raise InvalidResource, t('process_post_invalid', :result => result)
415
+ end
416
+ end
417
+ if response.is_redirect?
418
+ if response.headers['Location']
419
+ 303
420
+ else
421
+ raise InvalidResource, t('do_redirect')
422
+ end
423
+ else
424
+ :p11
425
+ end
426
+ end
427
+
428
+ # POST?
429
+ def n16
430
+ request.method == "POST" ? :n11 : :o16
431
+ end
432
+
433
+ # Conflict?
434
+ def o14
435
+ if resource.is_conflict?
436
+ 409
437
+ else
438
+ res = accept_helper
439
+ (Fixnum === res) ? res : :p11
440
+ end
441
+ end
442
+
443
+ # PUT?
444
+ def o16
445
+ request.method == "PUT" ? :o14 : :o18
446
+ end
447
+
448
+ # Multiple representations?
449
+ # Also where body generation for GET and HEAD is done.
450
+ def o18
451
+ if request.method =~ /^(GET|HEAD)$/
452
+ if etag = resource.generate_etag
453
+ response.headers['ETag'] = ensure_quoted_header(etag)
454
+ end
455
+ if last_modified = resource.last_modified
456
+ response.headers['Last-Modified'] = last_modified.httpdate
457
+ end
458
+ if expires = resource.expires
459
+ response.headers['Expires'] = expires.httpdate
460
+ end
461
+ content_type = metadata['Content-Type']
462
+ handler = resource.content_types_provided.find {|ct, _| content_type.type_matches?(MediaType.parse(ct)) }.last
463
+ result = resource.send(handler)
464
+ if Fixnum === result
465
+ result
466
+ else
467
+ response.body = result
468
+ encode_body
469
+ :o18b
470
+ end
471
+ else
472
+ :o18b
473
+ end
474
+ end
475
+
476
+ # Multiple choices?
477
+ def o18b
478
+ decision_test(resource.multiple_choices?, true, 300, 200)
479
+ end
480
+
481
+ # Response includes an entity?
482
+ def o20
483
+ has_response_body? ? :o18 : 204
484
+ end
485
+
486
+ # Conflict?
487
+ def p3
488
+ if resource.is_conflict?
489
+ 409
490
+ else
491
+ res = accept_helper
492
+ (Fixnum === res) ? res : :p11
493
+ end
494
+ end
495
+
496
+ # New resource?
497
+ def p11
498
+ !response.headers["Location"] ? :o20 : 201
499
+ end
500
+ end
501
+ end
502
+ end
@@ -0,0 +1,79 @@
1
+ require 'webmachine/decision/helpers'
2
+ require 'webmachine/decision/fsm'
3
+ require 'webmachine/translation'
4
+
5
+ module Webmachine
6
+ module Decision
7
+ # Implements the finite-state machine described by the Webmachine
8
+ # sequence diagram.
9
+ class FSM
10
+ include Flow
11
+ include Helpers
12
+ include Translation
13
+
14
+ attr_reader :resource, :request, :response, :metadata
15
+
16
+ def initialize(resource, request, response)
17
+ @resource, @request, @response = resource, request, response
18
+ @metadata = {}
19
+ end
20
+
21
+ # Processes the request, iteratively invoking the decision methods in {Flow}.
22
+ def run
23
+ begin
24
+ state = Flow::START
25
+ loop do
26
+ response.trace << state
27
+ result = send(state)
28
+ case result
29
+ when Fixnum # Response code
30
+ respond(result)
31
+ break
32
+ when Symbol # Next state
33
+ state = result
34
+ else # You bwoke it
35
+ raise InvalidResource, t('fsm_broke', :state => state, :result => result.inspect)
36
+ end
37
+ end
38
+ rescue MalformedRequest => malformed
39
+ Webmachine.render_error(400, request, response, :message => malformed.message)
40
+ respond(400)
41
+ rescue => e # Handle all exceptions without crashing the server
42
+ error_response(e, state)
43
+ end
44
+ end
45
+
46
+ private
47
+ def respond(code, headers={})
48
+ response.headers.merge!(headers)
49
+ end_time = Time.now
50
+ case code
51
+ when 404
52
+ Webmachine.render_error(code, request, response)
53
+ when 304
54
+ response.headers.delete('Content-Type')
55
+ if etag = resource.generate_etag
56
+ response.headers['ETag'] = ensure_quoted_header(etag)
57
+ end
58
+ if expires = resource.expires
59
+ response.headers['Expires'] = expires.httpdate
60
+ end
61
+ if modified = resource.last_modified
62
+ response.headers['Last-Modified'] = modified.httpdate
63
+ end
64
+ end
65
+ response.code = code
66
+ resource.finish_request
67
+ # TODO: add logging/tracing
68
+ end
69
+
70
+ # Renders a 500 error by capturing the exception information.
71
+ def error_response(exception, state)
72
+ response.error = [exception.message, exception.backtrace].flatten.join("\n ")
73
+ response.end_state = state
74
+ Webmachine.render_error(500, request, response)
75
+ respond(500)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,80 @@
1
+ require 'webmachine/streaming'
2
+ module Webmachine
3
+ module Decision
4
+ # Methods that assist the Decision {Flow}.
5
+ module Helpers
6
+ QUOTED = /^"(.*)"$/
7
+
8
+ # Determines if the response has a body/entity set.
9
+ def has_response_body?
10
+ !response.body.nil? && !response.body.empty?
11
+ end
12
+
13
+ # If the response body exists, encode it.
14
+ # @see #encode_body
15
+ def encode_body_if_set
16
+ encode_body if has_response_body?
17
+ end
18
+
19
+ # Encodes the body in the selected charset and encoding.
20
+ def encode_body
21
+ body = response.body
22
+ chosen_charset = metadata['Charset']
23
+ chosen_encoding = metadata['Content-Encoding']
24
+ charsetter = resource.charsets_provided && resource.charsets_provided.find {|c,_| c == chosen_charset }.last || :charset_nop
25
+ encoder = resource.encodings_provided[chosen_encoding]
26
+ response.body = case body
27
+ when String # 1.8 treats Strings as Enumerable
28
+ resource.send(encoder, resource.send(charsetter, body))
29
+ when Enumerable
30
+ EnumerableEncoder.new(resource, encoder, charsetter, body)
31
+ when body.respond_to?(:call)
32
+ CallableEncoder.new(resource, encoder, charsetter, body)
33
+ else
34
+ resource.send(encoder, resource.send(charsetter, body))
35
+ end
36
+ end
37
+
38
+ # Ensures that a header is quoted (like ETag)
39
+ def ensure_quoted_header(value)
40
+ if value =~ QUOTED
41
+ value
42
+ else
43
+ '"' << value << '"'
44
+ end
45
+ end
46
+
47
+ # Unquotes request headers (like ETag)
48
+ def unquote_header(value)
49
+ if value =~ QUOTED
50
+ $1
51
+ else
52
+ value
53
+ end
54
+ end
55
+
56
+ # Assists in receiving request bodies
57
+ def accept_helper
58
+ content_type = request.content_type || 'application/octet-stream'
59
+ mt = Conneg::MediaType.parse(content_type)
60
+ metadata['mediaparams'] = mt.params
61
+ acceptable = resource.content_types_accepted.find {|ct, _| mt.type_matches?(Conneg::MediaType.parse(ct)) }
62
+ if acceptable
63
+ resource.send(acceptable.last)
64
+ else
65
+ 415
66
+ end
67
+ end
68
+
69
+ # Computes the entries for the 'Vary' response header
70
+ def variances
71
+ resource.variances.tap do |v|
72
+ v.unshift "Accept-Language" if resource.languages_provided.size > 1
73
+ v.unshift "Accept-Charset" if resource.charsets_provided && resource.charsets_provided.size > 1
74
+ v.unshift "Accept-Encoding" if resource.encodings_provided.size > 1
75
+ v.unshift "Accept" if resource.content_types_provided.size > 1
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,12 @@
1
+ require 'webmachine/decision/helpers'
2
+ require 'webmachine/decision/conneg'
3
+ require 'webmachine/decision/flow'
4
+ require 'webmachine/decision/fsm'
5
+
6
+ module Webmachine
7
+ # This module encapsulates the logic related to delivering the
8
+ # proper HTTP response, given the constraints of the {Request} and
9
+ # the {Resource} which is being processed.
10
+ module Decision
11
+ end
12
+ end