webmachine 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,362 @@
1
+ module Webmachine
2
+ class Resource
3
+ # These
4
+ module Callbacks
5
+ # Does the resource exist? Returning a falsey value (false or nil)
6
+ # will result in a '404 Not Found' response. Defaults to true.
7
+ # @return [true,false] Whether the resource exists
8
+ # @api callback
9
+ def resource_exists?
10
+ true
11
+ end
12
+
13
+ # Is the resource available? Returning a falsey value (false or
14
+ # nil) will result in a '503 Service Not Available'
15
+ # response. Defaults to true. If the resource is only temporarily
16
+ # not available, add a 'Retry-After' response header in the body
17
+ # of the method.
18
+ # @return [true,false]
19
+ # @api callback
20
+ def service_available?
21
+ true
22
+ end
23
+
24
+ # Is the client or request authorized? Returning anything other than true
25
+ # will result in a '401 Unauthorized' response. Defaults to
26
+ # true. If a String is returned, it will be used as the value in
27
+ # the WWW-Authenticate header, which can also be set manually.
28
+ # @param [String] authorization_header The contents of the
29
+ # 'Authorization' header sent by the client, if present.
30
+ # @return [true,false,String] Whether the client is authorized,
31
+ # and if not, the WWW-Authenticate header when a String.
32
+ # @api callback
33
+ def is_authorized?(authorization_header = nil)
34
+ true
35
+ end
36
+
37
+ # Is the request or client forbidden? Returning a truthy value
38
+ # (true or non-nil) will result in a '403 Forbidden' response.
39
+ # Defaults to false.
40
+ # @return [true,false] Whether the client or request is forbidden.
41
+ # @api callback
42
+ def forbidden?
43
+ false
44
+ end
45
+
46
+ # If the resource accepts POST requests to nonexistent resources,
47
+ # then this should return true. Defaults to false.
48
+ # @return [true,false] Whether to accept POST requests to missing
49
+ # resources.
50
+ # @api callback
51
+ def allow_missing_post?
52
+ false
53
+ end
54
+
55
+ # If the request is malformed, this should return true, which will
56
+ # result in a '400 Malformed Request' response. Defaults to false.
57
+ # @return [true,false] Whether the request is malformed.
58
+ # @api callback
59
+ def malformed_request?
60
+ false
61
+ end
62
+
63
+ # If the URI is too long to be processed, this should return true,
64
+ # which will result in a '414 Request URI Too Long'
65
+ # response. Defaults to false.
66
+ # @param [URI] uri the request URI
67
+ # @return [true,false] Whether the request URI is too long.
68
+ # @api callback
69
+ def uri_too_long?(uri = nil)
70
+ false
71
+ end
72
+
73
+ # If the Content-Type on PUT or POST is unknown, this should
74
+ # return false, which will result in a '415 Unsupported Media
75
+ # Type' response. Defaults to true.
76
+ # @param [String] content_type the 'Content-Type' header sent by
77
+ # the client
78
+ # @return [true,false] Whether the passed media type is known or
79
+ # accepted
80
+ # @api callback
81
+ def known_content_type?(content_type = nil)
82
+ true
83
+ end
84
+
85
+ # If the request includes any invalid Content-* headers, this
86
+ # should return false, which will result in a '501 Not
87
+ # Implemented' response. Defaults to false.
88
+ # @param [Hash] content_headers Request headers that begin with
89
+ # 'Content-'
90
+ # @return [true,false] Whether the Content-* headers are invalid
91
+ # or unsupported
92
+ # @api callback
93
+ def valid_content_headers?(content_headers = nil)
94
+ true
95
+ end
96
+
97
+ # If the entity length on PUT or POST is invalid, this should
98
+ # return false, which will result in a '413 Request Entity Too
99
+ # Large' response. Defaults to true.
100
+ # @param [Fixnum] length the size of the request body (entity)
101
+ # @return [true,false] Whether the body is a valid length (not too
102
+ # large)
103
+ # @api callback
104
+ def valid_entity_length?(length = nil)
105
+ true
106
+ end
107
+
108
+ # If the OPTIONS method is supported and is used, this method
109
+ # should return a Hash of headers that should appear in the
110
+ # response. Defaults to {}.
111
+ # @return [Hash] headers to appear in the response
112
+ # @api callback
113
+ def options
114
+ {}
115
+ end
116
+
117
+ # HTTP methods that are allowed on this resource. This must return
118
+ # an Array of Strings in all capitals. Defaults to ['GET','HEAD'].
119
+ # @return [Array<String>] allowed methods on this resource
120
+ # @api callback
121
+ def allowed_methods
122
+ ['GET', 'HEAD']
123
+ end
124
+
125
+ # HTTP methods that are known to the resource. Like
126
+ # {#allowed_methods}, this must return an Array of Strings in
127
+ # all capitals. Default includes all standard HTTP methods. One
128
+ # could override this callback to allow additional methods,
129
+ # e.g. WebDAV.
130
+ # @return [Array<String>] known methods
131
+ # @api callback
132
+ def known_methods
133
+ ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'TRACE', 'CONNECT', 'OPTIONS']
134
+ end
135
+
136
+ # This method is called when a DELETE request should be enacted,
137
+ # and should return true if the deletion succeeded. Defaults to false.
138
+ # @return [true,false] Whether the deletion succeeded.
139
+ # @api callback
140
+ def delete_resource
141
+ false
142
+ end
143
+
144
+ # This method is called after a successful call to
145
+ # {#delete_resource} and should return false if the deletion was
146
+ # accepted but cannot yet be guaranteed to have finished. Defaults
147
+ # to true.
148
+ # @return [true,false] Whether the deletion completed
149
+ # @api callback
150
+ def delete_completed?
151
+ true
152
+ end
153
+
154
+ # If POST requests should be treated as a request to put content
155
+ # into a (potentially new) resource as opposed to a generic
156
+ # submission for processing, then this method should return
157
+ # true. If it does return true, then {#create_path} will be called
158
+ # and the rest of the request will be treated much like a PUT to
159
+ # the path returned by that call. Default is false.
160
+ # @return [true,false] Whether POST creates a new resource
161
+ # @api callback
162
+ def post_is_create?
163
+ false
164
+ end
165
+
166
+ # This will be called on a POST request if post_is_create? returns
167
+ # true. The path returned should be a valid URI part following the
168
+ # dispatcher prefix. That path will replace the previous one in
169
+ # the return value of {Request#disp_path} for all subsequent
170
+ # resource function calls in the course of this request.
171
+ # @return [String, URI] the path to the new resource
172
+ # @api callback
173
+ def create_path
174
+ nil
175
+ end
176
+
177
+ # This will be called after {#create_path} but before setting the
178
+ # Location response header, and is used to determine the root
179
+ # URI of the new resource. Default is nil, which uses the URI of
180
+ # the request as the base.
181
+ # @return [String, URI, nil]
182
+ # @api callback
183
+ def base_uri
184
+ nil
185
+ end
186
+
187
+ # If post_is_create? returns false, then this will be called to
188
+ # process any POST request. If it succeeds, it should return true.
189
+ # @return [true,false,Fixnum] Whether the POST was successfully
190
+ # processed, or an alternate response code
191
+ # @api callback
192
+ def process_post
193
+ false
194
+ end
195
+
196
+ # This should return an array of pairs where each pair is of the
197
+ # form [mediatype, :handler] where mediatype is a String of
198
+ # Content-Type format and :handler is a Symbol naming the method
199
+ # which can provide a resource representation in that media
200
+ # type. For example, if a client request includes an 'Accept'
201
+ # header with a value that does not appear as a first element in
202
+ # any of the return pairs, then a '406 Not Acceptable' will be
203
+ # sent. Default is [['text/html', :to_html]].
204
+ # @return an array of mediatype/handler pairs
205
+ # @api callback
206
+ def content_types_provided
207
+ [['text/html', :to_html]]
208
+ end
209
+
210
+ # Similarly to content_types_provided, this should return an array
211
+ # of mediatype/handler pairs, except that it is for incoming
212
+ # resource representations -- for example, PUT requests. Handler
213
+ # functions usually want to use {Request#body} to access the
214
+ # incoming entity.
215
+ # @return [Array] an array of mediatype/handler pairs
216
+ # @api callback
217
+ def content_types_accepted
218
+ []
219
+ end
220
+
221
+ # If this is anything other than nil, it must be an array of pairs
222
+ # where each pair is of the form Charset, Converter where Charset
223
+ # is a string naming a charset and Converter is an arity-1 method
224
+ # in the resource which will be called on the produced body in a
225
+ # GET and ensure that it is in Charset.
226
+ # @return [nil, Array] The provided character sets and encoder
227
+ # methods, or nothing.
228
+ # @api callback
229
+ def charsets_provided
230
+ nil
231
+ end
232
+
233
+ # This should return a list of language tags provided by the
234
+ # resource. Default is the empty Array, in which the content is
235
+ # in no specific language.
236
+ # @return [Array<String>] a list of provided languages
237
+ # @api callback
238
+ def languages_provided
239
+ []
240
+ end
241
+
242
+ # This should return a hash of encodings mapped to encoding
243
+ # methods for Content-Encodings your resource wants to
244
+ # provide. The encoding will be applied to the response body
245
+ # automatically by Webmachine. A number of built-in encodings
246
+ # are provided in the {Encodings} module. Default includes only
247
+ # the 'identity' encoding.
248
+ # @return [Hash] a hash of encodings and encoder methods/procs
249
+ # @api callback
250
+ # @see Encodings
251
+ def encodings_provided
252
+ {"identity" => :encode_identity }
253
+ end
254
+
255
+ # If this method is implemented, it should return a list of
256
+ # strings with header names that should be included in a given
257
+ # response's Vary header. The standard conneg headers (Accept,
258
+ # Accept-Encoding, Accept-Charset, Accept-Language) do not need to
259
+ # be specified here as Webmachine will add the correct elements of
260
+ # those automatically depending on resource behavior. Default is
261
+ # [].
262
+ # @api callback
263
+ # @return [Array<String>] a list of variance headers
264
+ def variances
265
+ []
266
+ end
267
+
268
+ # If this returns true, the client will receive a '409 Conflict'
269
+ # response. This is only called for PUT requests. Default is false.
270
+ # @api callback
271
+ # @return [true,false] whether the submitted entity is in conflict
272
+ # with the current state of the resource
273
+ def is_conflict?
274
+ false
275
+ end
276
+
277
+ # If this returns true, then it is assumed that multiple
278
+ # representations of the response are possible and a single one
279
+ # cannot be automatically chosen, so a 300 Multiple Choices will
280
+ # be sent instead of a 200. Default is false.
281
+ # @api callback
282
+ # @return [true,false] whether the multiple representations are
283
+ # possible
284
+ def multiple_choices?
285
+ false
286
+ end
287
+
288
+ # If this resource is known to have existed previously, this
289
+ # method should return true. Default is false.
290
+ # @api callback
291
+ # @return [true,false] whether the resource existed previously
292
+ def previously_existed?
293
+ false
294
+ end
295
+
296
+ # If this resource has moved to a new location permanently, this
297
+ # method should return the new location as a String or
298
+ # URI. Default is to return false.
299
+ # @api callback
300
+ # @return [String,URI,false] the new location of the resource, or
301
+ # false
302
+ def moved_permanently?
303
+ false
304
+ end
305
+
306
+ # If this resource has moved to a new location temporarily, this
307
+ # method should return the new location as a String or
308
+ # URI. Default is to return false.
309
+ # @api callback
310
+ # @return [String,URI,false] the new location of the resource, or
311
+ # false
312
+ def moved_temporarily?
313
+ false
314
+ end
315
+
316
+ # This method should return the last modified date/time of the
317
+ # resource which will be added as the Last-Modified header in the
318
+ # response and used in negotiating conditional requests. Default
319
+ # is nil.
320
+ # @api callback
321
+ # @return [Time,DateTime,Date,nil] the last modified time
322
+ def last_modified
323
+ nil
324
+ end
325
+
326
+ # If the resource expires, this method should return the date/time
327
+ # it expires. Default is nil.
328
+ # @api callback
329
+ # @return [Time,DateTime,Date,nil] the expiration time
330
+ def expires
331
+ nil
332
+ end
333
+
334
+ # If this returns a value, it will be used as the value of the
335
+ # ETag header and for comparison in conditional requests. Default
336
+ # is nil.
337
+ # @api callback
338
+ # @return [String,nil] the entity tag for this resource
339
+ def generate_etag
340
+ nil
341
+ end
342
+
343
+ # This method is called just before the final response is
344
+ # constructed and sent. The return value is ignored, so any effect
345
+ # of this method must be by modifying the response.
346
+ # @api callback
347
+ def finish_request; end
348
+
349
+ # This method is called when verifying the Content-MD5 header
350
+ # against the request body. To do your own validation, implement
351
+ # it in this callback, returning true or false. To bypass header
352
+ # validation, simply return true. Default is nil, which will
353
+ # invoke Webmachine's default validation.
354
+ # @api callback
355
+ # @return [true,false,nil] Whether the Content-MD5 header
356
+ # validates against the request body
357
+ def validate_content_checksum
358
+ nil
359
+ end
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,36 @@
1
+ require 'zlib'
2
+ require 'stringio'
3
+
4
+ module Webmachine
5
+ class Resource
6
+ # This module implements standard Content-Encodings that you might
7
+ # want to use in your {Resource}. To use one, simply return it in
8
+ # the hash from {Callbacks#encodings_provided}.
9
+ module Encodings
10
+ # The 'identity' encoding, which does no compression.
11
+ def encode_identity(data)
12
+ data
13
+ end
14
+
15
+ # The 'deflate' encoding, which uses libz's DEFLATE compression.
16
+ def encode_deflate(data)
17
+ # The deflate options were borrowed from Rack and Mongrel1.
18
+ Zlib::Deflate.deflate(data, *[Zlib::DEFAULT_COMPRESSION,
19
+ # drop the zlib header which causes both Safari and IE to choke
20
+ -Zlib::MAX_WBITS,
21
+ Zlib::DEF_MEM_LEVEL,
22
+ Zlib::DEFAULT_STRATEGY
23
+ ])
24
+ end
25
+
26
+ # The 'gzip' encoding, which uses GNU Zip (via libz).
27
+ # @note Because of the header/checksum requirements, gzip cannot
28
+ # be used on streamed responses.
29
+ def encode_gzip(data)
30
+ "".tap do |out|
31
+ Zlib::GzipWriter.wrap(StringIO.new(out)){|gz| gz << data }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end