webmachine 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (63) hide show
  1. data/Gemfile +11 -3
  2. data/README.md +55 -27
  3. data/lib/webmachine/adapters/mongrel.rb +84 -0
  4. data/lib/webmachine/adapters/webrick.rb +12 -3
  5. data/lib/webmachine/adapters.rb +1 -7
  6. data/lib/webmachine/configuration.rb +30 -0
  7. data/lib/webmachine/decision/conneg.rb +7 -72
  8. data/lib/webmachine/decision/flow.rb +13 -11
  9. data/lib/webmachine/decision/fsm.rb +1 -9
  10. data/lib/webmachine/decision/helpers.rb +27 -7
  11. data/lib/webmachine/errors.rb +1 -0
  12. data/lib/webmachine/headers.rb +12 -3
  13. data/lib/webmachine/locale/en.yml +2 -2
  14. data/lib/webmachine/media_type.rb +117 -0
  15. data/lib/webmachine/resource/callbacks.rb +9 -0
  16. data/lib/webmachine/streaming.rb +3 -3
  17. data/lib/webmachine/version.rb +1 -1
  18. data/lib/webmachine.rb +3 -1
  19. data/pkg/webmachine-0.1.0/Gemfile +16 -0
  20. data/pkg/webmachine-0.1.0/Guardfile +11 -0
  21. data/pkg/webmachine-0.1.0/README.md +90 -0
  22. data/pkg/webmachine-0.1.0/Rakefile +31 -0
  23. data/pkg/webmachine-0.1.0/examples/webrick.rb +19 -0
  24. data/pkg/webmachine-0.1.0/lib/webmachine/adapters/webrick.rb +74 -0
  25. data/pkg/webmachine-0.1.0/lib/webmachine/adapters.rb +15 -0
  26. data/pkg/webmachine-0.1.0/lib/webmachine/decision/conneg.rb +304 -0
  27. data/pkg/webmachine-0.1.0/lib/webmachine/decision/flow.rb +502 -0
  28. data/pkg/webmachine-0.1.0/lib/webmachine/decision/fsm.rb +79 -0
  29. data/pkg/webmachine-0.1.0/lib/webmachine/decision/helpers.rb +80 -0
  30. data/pkg/webmachine-0.1.0/lib/webmachine/decision.rb +12 -0
  31. data/pkg/webmachine-0.1.0/lib/webmachine/dispatcher/route.rb +85 -0
  32. data/pkg/webmachine-0.1.0/lib/webmachine/dispatcher.rb +40 -0
  33. data/pkg/webmachine-0.1.0/lib/webmachine/errors.rb +37 -0
  34. data/pkg/webmachine-0.1.0/lib/webmachine/headers.rb +16 -0
  35. data/pkg/webmachine-0.1.0/lib/webmachine/locale/en.yml +28 -0
  36. data/pkg/webmachine-0.1.0/lib/webmachine/request.rb +56 -0
  37. data/pkg/webmachine-0.1.0/lib/webmachine/resource/callbacks.rb +362 -0
  38. data/pkg/webmachine-0.1.0/lib/webmachine/resource/encodings.rb +36 -0
  39. data/pkg/webmachine-0.1.0/lib/webmachine/resource.rb +48 -0
  40. data/pkg/webmachine-0.1.0/lib/webmachine/response.rb +49 -0
  41. data/pkg/webmachine-0.1.0/lib/webmachine/streaming.rb +27 -0
  42. data/pkg/webmachine-0.1.0/lib/webmachine/translation.rb +11 -0
  43. data/pkg/webmachine-0.1.0/lib/webmachine/version.rb +4 -0
  44. data/pkg/webmachine-0.1.0/lib/webmachine.rb +19 -0
  45. data/pkg/webmachine-0.1.0/spec/spec_helper.rb +13 -0
  46. data/pkg/webmachine-0.1.0/spec/tests.org +57 -0
  47. data/pkg/webmachine-0.1.0/spec/webmachine/decision/conneg_spec.rb +152 -0
  48. data/pkg/webmachine-0.1.0/spec/webmachine/decision/flow_spec.rb +1030 -0
  49. data/pkg/webmachine-0.1.0/spec/webmachine/dispatcher/route_spec.rb +109 -0
  50. data/pkg/webmachine-0.1.0/spec/webmachine/dispatcher_spec.rb +34 -0
  51. data/pkg/webmachine-0.1.0/spec/webmachine/headers_spec.rb +19 -0
  52. data/pkg/webmachine-0.1.0/spec/webmachine/request_spec.rb +24 -0
  53. data/pkg/webmachine-0.1.0/webmachine.gemspec +44 -0
  54. data/pkg/webmachine-0.1.0.gem +0 -0
  55. data/spec/webmachine/configuration_spec.rb +27 -0
  56. data/spec/webmachine/decision/conneg_spec.rb +18 -11
  57. data/spec/webmachine/decision/flow_spec.rb +2 -0
  58. data/spec/webmachine/decision/helpers_spec.rb +105 -0
  59. data/spec/webmachine/errors_spec.rb +13 -0
  60. data/spec/webmachine/headers_spec.rb +2 -1
  61. data/spec/webmachine/media_type_spec.rb +78 -0
  62. data/webmachine.gemspec +4 -1
  63. metadata +69 -11
@@ -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
@@ -0,0 +1,85 @@
1
+ require 'webmachine/resource'
2
+ require 'webmachine/translation'
3
+
4
+ module Webmachine
5
+ module Dispatcher
6
+ # Pairs URIs with {Resource} classes in the {Dispatcher}. To
7
+ # create routes, use {Dispatcher#add_route}.
8
+ class Route
9
+ # @return [Class] the resource this route will dispatch to, a
10
+ # subclass of {Resource}
11
+ attr_reader :resource
12
+
13
+ # When used in a path specification, will match all remaining
14
+ # segments
15
+ MATCH_ALL = '*'.freeze
16
+
17
+ # Creates a new Route that will associate a pattern to a
18
+ # {Resource}.
19
+ # @param [Array<String|Symbol>] path_spec a list of path
20
+ # segments (String) and identifiers (Symbol) to bind.
21
+ # Strings will be simply matched for equality. Symbols in
22
+ # the path spec will be extracted into {Request#path_info} for use
23
+ # inside your {Resource}. The special segment {MATCH_ALL} will match
24
+ # all remaining segments.
25
+ # @param [Class] resource the {Resource} to dispatch to
26
+ # @param [Hash] bindings additional information to add to
27
+ # {Request#path_info} when this route matches
28
+ # @see Dispatcher#add_route
29
+ def initialize(path_spec, resource, bindings={})
30
+ @path_spec, @resource, @bindings = path_spec, resource, bindings
31
+ raise ArgumentError, t('not_resource_class', :class => resource.name) unless resource < Resource
32
+ end
33
+
34
+ # Determines whether the given request matches this route and
35
+ # should be dispatched to the {#resource}.
36
+ # @param [Reqeust] request the request object
37
+ def match?(request)
38
+ tokens = request.uri.path.match(/^\/(.*)/)[1].split('/')
39
+ bind(tokens, {})
40
+ end
41
+
42
+ # Decorates the request with information about the dispatch
43
+ # route, including path bindings.
44
+ # @param [Request] request the request object
45
+ def apply(request)
46
+ request.disp_path = request.uri.path.match(/^\/(.*)/)[1]
47
+ request.path_info = @bindings.dup
48
+ tokens = request.disp_path.split('/')
49
+ depth, trailing = bind(tokens, request.path_info)
50
+ request.path_tokens = trailing || []
51
+ end
52
+
53
+ private
54
+ # Attempts to match the path spec against the path tokens, while
55
+ # accumulating variable bindings.
56
+ # @param [Array<String>] tokens the list of path segments
57
+ # @param [Hash] bindings where path bindings will be stored
58
+ # @return [Fixnum, Array<Fixnum, Array>, false] either the depth
59
+ # that the path matched at, the depth and tokens matched by
60
+ # {MATCH_ALL}, or false if it didn't match.
61
+ def bind(tokens, bindings)
62
+ depth = 0
63
+ spec = @path_spec
64
+ loop do
65
+ case
66
+ when spec.empty? && tokens.empty?
67
+ return depth
68
+ when spec == [MATCH_ALL]
69
+ return [depth, tokens]
70
+ when tokens.empty?
71
+ return false
72
+ when Symbol === spec.first
73
+ bindings[spec.first] = tokens.first
74
+ when spec.first == tokens.first
75
+ else
76
+ return false
77
+ end
78
+ spec = spec[1..-1]
79
+ tokens = tokens[1..-1]
80
+ depth += 1
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,40 @@
1
+ require 'webmachine/decision'
2
+ require 'webmachine/dispatcher/route'
3
+
4
+ module Webmachine
5
+ # Handles dispatching incoming requests to the proper registered
6
+ # resources and initializing the decision logic.
7
+ module Dispatcher
8
+ extend self
9
+ @routes = []
10
+
11
+ # Adds a route to the dispatch list. Routes will be matched in the
12
+ # order they are added.
13
+ # @see Route#new
14
+ def add_route(*args)
15
+ @routes << Route.new(*args)
16
+ end
17
+
18
+ # Dispatches a request to the appropriate {Resource} in the
19
+ # dispatch list. If a matching resource is not found, a "404 Not
20
+ # Found" will be rendered.
21
+ # @param [Request] request the request object
22
+ # @param [Response] response the response object
23
+ def dispatch(request, response)
24
+ route = @routes.find {|r| r.match?(request) }
25
+ if route
26
+ resource = route.resource.new(request, response)
27
+ route.apply(request)
28
+ Webmachine::Decision::FSM.new(resource, request, response).run
29
+ else
30
+ Webmachine.render_error(404, request, response)
31
+ end
32
+ end
33
+
34
+ # Resets, removing all routes. Useful for testing or reloading the
35
+ # application.
36
+ def reset
37
+ @routes = []
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,37 @@
1
+ require 'webmachine/translation'
2
+ require 'webmachine/version'
3
+
4
+ module Webmachine
5
+ extend Translation
6
+
7
+ # Renders a standard error message body for the response. The
8
+ # standard messages are defined in localization files.
9
+ # @param [Fixnum] code the response status code
10
+ # @param [Request] req the request object
11
+ # @param [Response] req the response object
12
+ # @param [Hash] options keys to override the defaults when rendering
13
+ # the response body
14
+ def self.render_error(code, req, res, options={})
15
+ unless res.body
16
+ title, message = t(["errors.#{code}.title", "errors.#{code}.message"],
17
+ { :method => req.method,
18
+ :error => res.error}.merge(options))
19
+ res.body = t("errors.standard_body",
20
+ {:title => title,
21
+ :message => message,
22
+ :version => Webmachine::SERVER_STRING}.merge(options))
23
+ res.headers['Content-Type'] = "text/html"
24
+ end
25
+ end
26
+
27
+ # Superclass of all errors generated by Webmachine.
28
+ class Error < ::StandardError; end
29
+
30
+ # Raised when the resource violates specific constraints on its API.
31
+ class InvalidResource < Error; end
32
+
33
+ # Raised when the client has submitted an invalid request, e.g. in
34
+ # the case where a request header is improperly formed. Raising this
35
+ # exception will result in a 400 response.
36
+ class MalformedRequest < Error; end
37
+ end
@@ -0,0 +1,16 @@
1
+ module Webmachine
2
+ # Case-insensitive Hash of request headers
3
+ class Headers < ::Hash
4
+ def [](key)
5
+ super key.to_s.downcase
6
+ end
7
+
8
+ def []=(key,value)
9
+ super key.to_s.downcase, value
10
+ end
11
+
12
+ def grep(pattern)
13
+ self.class[select { |k,_| pattern === k }]
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,28 @@
1
+ en:
2
+ webmachine:
3
+ errors:
4
+ standard_body: "<!DOCTYPE html><html>
5
+ <head><title>%{title}</title></head>
6
+ <body><h1>%{title}</h1><p>%{message}</p>
7
+ <address>%{version} server</address></body></html>"
8
+ "400":
9
+ title: 400 Malformed Request
10
+ message: The request was malformed and could not be processed.
11
+ "404":
12
+ title: 404 Not Found
13
+ message: The requested document was not found on this server.
14
+ "500":
15
+ title: 500 Internal Server Error
16
+ message: "The server encountered an error while processing this request: <pre>%{error}</pre>"
17
+ "501":
18
+ title: 501 Not Implemented
19
+ message: "The server does not support the %{method} method."
20
+ "503":
21
+ title: 503 Service Unavailable
22
+ message: The server is currently unable to handl the request due to a temporary overloading or maintenance of the server.
23
+ create_path_nil: "post_is_create? returned true but create_path is nil! Define the create_path method in %{class}"
24
+ do_redirect: "Response had do_redirect but no Location header."
25
+ fsm_broke: "Decision FSM returned an unexpected value %{result} from decision %{state}."
26
+ invalid_media_type: "Invalid media type specified in Accept header: %{type}"
27
+ not_resource_class: "%{class} is not a subclass of Webmachine::Resource"
28
+ process_post_invalid: "process_post returned %{result}"
@@ -0,0 +1,56 @@
1
+ require 'forwardable'
2
+
3
+ module Webmachine
4
+ # This represents a single HTTP request sent from a client.
5
+ class Request
6
+ extend Forwardable
7
+ attr_reader :method, :uri, :headers, :body
8
+ attr_accessor :disp_path, :path_info, :path_tokens
9
+
10
+ def initialize(meth, uri, headers, body)
11
+ @method, @uri, @headers, @body = meth, uri, headers, body
12
+ end
13
+
14
+ def_delegators :headers, :[]
15
+
16
+ # @private
17
+ def method_missing(m, *args)
18
+ if m.to_s =~ /^(?:[a-z0-9])+(?:_[a-z0-9]+)*$/i
19
+ # Access headers more easily as underscored methods.
20
+ self[m.to_s.tr('_', '-')]
21
+ else
22
+ super
23
+ end
24
+ end
25
+
26
+ # Whether the request body is present.
27
+ def has_body?
28
+ !(body.nil? || body.empty?)
29
+ end
30
+
31
+ # The root URI for the request, ignoring path and query. This is
32
+ # useful for calculating relative paths to resources.
33
+ # @return [URI]
34
+ def base_uri
35
+ @base_uri ||= uri.dup.tap do |u|
36
+ u.path = "/"
37
+ u.query = nil
38
+ end
39
+ end
40
+
41
+ # Returns a hash of query parameters (they come after the ? in the
42
+ # URI). Note that this does NOT work in the same way as Rails,
43
+ # i.e. it does not support nested arrays and hashes.
44
+ # @return [Hash] query parameters
45
+ def query
46
+ unless @query
47
+ @query = {}
48
+ uri.query.split(/&/).each do |kv|
49
+ k, v = URI.unescape(kv).split(/=/)
50
+ @query[k] = v if k && v
51
+ end
52
+ end
53
+ @query
54
+ end
55
+ end
56
+ end