webmachine 0.1.0

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