webmachine 0.1.0 → 0.2.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.
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