clutterbuck-router 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,103 @@
1
+ This is a fairly minimal, unobtrusive request routing library, part of the
2
+ Clutterbuck Web Application Construction Kit.
3
+
4
+
5
+ # Installation
6
+
7
+ It's a gem:
8
+
9
+ gem install clutterbuck-router
10
+
11
+ There's also the wonders of [the Gemfile](http://bundler.io):
12
+
13
+ gem 'clutterbuck-router'
14
+
15
+ If you're the sturdy type that likes to run from git:
16
+
17
+ rake install
18
+
19
+ Or, if you've eschewed the convenience of Rubygems entirely, then you
20
+ presumably know what to do already.
21
+
22
+
23
+ # Usage
24
+
25
+ Load the code:
26
+
27
+ require 'clutterbuck-router'
28
+
29
+ Then include {Clutterbuck::Router} in any class you wish to be a Rack
30
+ application using the Clutterbuck router:
31
+
32
+ class ExampleApp
33
+ include Clutterbuck::Router
34
+ end
35
+
36
+ Now, you define the routes you wish the application to respond to:
37
+
38
+ class ExampleApp
39
+ include Clutterbuck::Router
40
+
41
+ get '/' do
42
+ [200, [["Content-Type", "text/plain"]], ["Ohai!"]]
43
+ end
44
+
45
+ post %r{^/mail/([^/]+)$} do |path_opt|
46
+ [200, [["Content-Type", "text/plain"]],
47
+ ["You posted #{env['rack.input'].read} to #{path_opt}"]
48
+ ]
49
+ end
50
+ end
51
+
52
+ The above example pretty much demonstrates all the features that
53
+ {Clutterbuck::Router} provides. You define routes by means of methods named
54
+ after the HTTP verb to respond to, and the path is either a string or a
55
+ regex. If you specify a string, then the path provided must match *exactly*
56
+ the request path. If you specify a regex, then the first route which
57
+ matches the request path and method gets run, with any captured
58
+ subexpressions (ie "the bits in the parentheses") get passed as arguments to
59
+ the block.
60
+
61
+ The `env` method returns the request's Rack environment; apart from that,
62
+ you're on your own as far as interacting with Rack itself -- you have to
63
+ parse out the query params and request body, and return your own response
64
+ array in the Rack-compatible format. If that all sounds like too much work,
65
+ you might want to look at `clutterbuck-request` and/or
66
+ `clutterbuck-response` to get syntactic sugar to help with those parts of
67
+ your app.
68
+
69
+ Once your app is crafted to your liking, you can put it into a `config.ru`:
70
+
71
+ require 'example_app'
72
+
73
+ use ExampleApp
74
+
75
+ Fire that up via `rackup`, and you're off and running.
76
+
77
+
78
+ # Contributing
79
+
80
+ Bug reports should be sent to the [Github issue
81
+ tracker](https://github.com/mpalmer/clutterbuck-router/issues), or
82
+ [e-mailed](mailto:theshed+clutterbuck@hezmatt.org). Patches can be sent as a
83
+ Github pull request, or [e-mailed](mailto:theshed+clutterbuck@hezmatt.org).
84
+
85
+
86
+ # Licence
87
+
88
+ Unless otherwise stated, everything in this repo is covered by the following
89
+ copyright notice:
90
+
91
+ Copyright (C) 2015 Matt Palmer <matt@hezmatt.org>
92
+
93
+ This program is free software: you can redistribute it and/or modify it
94
+ under the terms of the GNU General Public License version 3, as
95
+ published by the Free Software Foundation.
96
+
97
+ This program is distributed in the hope that it will be useful,
98
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
99
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
100
+ GNU General Public License for more details.
101
+
102
+ You should have received a copy of the GNU General Public License
103
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
@@ -0,0 +1,33 @@
1
+ require 'git-version-bump' rescue nil
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "clutterbuck-router"
5
+
6
+ s.version = GVB.version rescue "0.0.0.1.NOGVB"
7
+ s.date = GVB.date rescue Time.now.strftime("%Y-%m-%d")
8
+
9
+ s.platform = Gem::Platform::RUBY
10
+
11
+ s.summary = "Rack-based minimal request router"
12
+
13
+ s.authors = ["Matt Palmer"]
14
+ s.email = ["theshed+clutterbuck@hezmatt.org"]
15
+ s.homepage = "http://theshed.hezmatt.org/clutterbuck"
16
+
17
+ s.files = `git ls-files -z`.split("\0").reject { |f| f =~ /^(G|spec|Rakefile)/ }
18
+
19
+ s.required_ruby_version = ">= 1.9.3"
20
+
21
+ s.add_runtime_dependency "rack", "~> 1.5"
22
+
23
+ s.add_development_dependency 'bundler'
24
+ s.add_development_dependency 'github-release'
25
+ s.add_development_dependency 'guard-spork'
26
+ s.add_development_dependency 'guard-rspec'
27
+ s.add_development_dependency 'rake', '~> 10.4', '>= 10.4.2'
28
+ # Needed for guard
29
+ s.add_development_dependency 'rb-inotify', '~> 0.9'
30
+ s.add_development_dependency 'redcarpet'
31
+ s.add_development_dependency 'rspec'
32
+ s.add_development_dependency 'yard'
33
+ end
data/config.ru ADDED
@@ -0,0 +1,4 @@
1
+ require 'pp'
2
+
3
+ run proc { |env| pp env }
4
+
data/lib/.gitkeep ADDED
File without changes
@@ -0,0 +1 @@
1
+ require 'clutterbuck/router'
@@ -0,0 +1,269 @@
1
+ require 'clutterbuck/router/route'
2
+
3
+ #:nodoc:
4
+ module Clutterbuck; end
5
+
6
+ # A minimal router for Rack-compatible web applications.
7
+ #
8
+ # Does request routing, and *only* request routing. You specify
9
+ # routes as strings (exact match) or regexes, and then when the
10
+ # app gets a request, the appropriate route gets executed.
11
+ #
12
+ module Clutterbuck::Router
13
+ #:nodoc:
14
+ # Signals that the router got a 404. Should never escape the app call.
15
+ #
16
+ class NotFoundError < StandardError; end
17
+
18
+ #:nodoc:
19
+ # Signals that the router got a 405. Should never escape the app call.
20
+ #
21
+ class MethodNotAllowedError < StandardError; end
22
+
23
+ # All of the methods to define the app's routing behaviour are defined
24
+ # on the class, because that's where the config lives. Instances of the
25
+ # app class are created to handle requests.
26
+ #
27
+ module ClassMethods
28
+ # Process a request from the app.
29
+ #
30
+ def call(env)
31
+ self.new(env).call
32
+ end
33
+
34
+ # @!macro handler_args
35
+ # See {.add_handler} for all the gory details.
36
+ #
37
+ # @param path [String, Regexp]
38
+ #
39
+ # @param block [Proc]
40
+ #
41
+ # @return void
42
+ #
43
+
44
+ # Define a handler for `GET <path>` and `HEAD <path>` requests.
45
+ #
46
+ # @macro handler_args
47
+ #
48
+ def get(path, &block)
49
+ add_handler('GET', path, &block)
50
+ add_handler('HEAD', path, &block)
51
+ end
52
+
53
+ # Define a handler for `PUT <path>` requests.
54
+ #
55
+ # @macro handler_args
56
+ #
57
+ def put(path, &block)
58
+ add_handler('PUT', path, &block)
59
+ end
60
+
61
+ # Define a handler for `POST <path>` requests.
62
+ #
63
+ # @macro handler_args
64
+ #
65
+ def post(path, &block)
66
+ add_handler('POST', path, &block)
67
+ end
68
+
69
+ # Define a handler for `DELETE <path>` requests.
70
+ #
71
+ # @macro handler_args
72
+ #
73
+ def delete(path, &block)
74
+ add_handler('DELETE', path, &block)
75
+ end
76
+
77
+ # Define a handler for `PATCH <path>` requests.
78
+ #
79
+ # @macro handler_args
80
+ #
81
+ def patch(path, &block)
82
+ add_handler('PATCH', path, &block)
83
+ end
84
+
85
+ # Define a handler for an arbitrary HTTP method and path.
86
+ #
87
+ # If more than one handler for a given `verb` and `path` match a URL,
88
+ # then the first handler defined will be called.
89
+ #
90
+ # If no handler matches given `path`, then `404 Not Found` will be
91
+ # returned. If a handler exists for `path`, but does not support the
92
+ # method of the request, then `405 Method Not Allowed` will be
93
+ # returned to the client.
94
+ #
95
+ # The return value of the `block` can be one of a number of different
96
+ # things. If you set the `Content-Type` response header (by calling
97
+ # `set_header 'Content-Type', <something>`), then no special
98
+ # processing is done and your content is sent to the client
99
+ # more-or-less as-is, with only `Content-Length` calculation and
100
+ # wrapping your returned object in an array (if it doesn't already
101
+ # respond to `#each`, as required by Rack). This allows your API to
102
+ # return anything at all if it feels like it.
103
+ #
104
+ # However, by far the most common response will be a JSON document.
105
+ # If you don't set a `Content-Type` header in your handler, then we
106
+ # try quite hard to turn what you return from your handler into either
107
+ # JSON (if possible), or `text/plain`.
108
+ #
109
+ # For starters, if what you send back responds to `#each`, then we
110
+ # assume that you know what you're doing and will be sending back
111
+ # strings that will come together to be valid JSON. We'll set
112
+ # `Content-Type` and `Link` headers to match with the handler's
113
+ # schema, and that is that.
114
+ #
115
+ # If what you send back doesn't respond to `#each`, then we try to
116
+ # determine if its valid JSON by parsing it as JSON (if it's a string)
117
+ # or trying to call `#to_json` on it. If either of those work, then
118
+ # `Content-Type` and `Link` are set to match the schema for your
119
+ # handler, and all is well. Otherwise, we're kinda out of options and
120
+ # we'll send back the content as `text/plain` and hope the client
121
+ # knows what to do with it.
122
+ #
123
+ # @param verb [String] the **case sensitive** HTTP method which you
124
+ # wish this handler to respond for. If you're using the
125
+ # `get`/`post/`put`/etc wrappers for `add_handler`, this argument is
126
+ # taken care of for you. It's only if you want to define your own
127
+ # custom HTTP verbs that you'd ever need to worry about this
128
+ # argument.
129
+ #
130
+ # @param path [String, Regexp] defines the path which is to be handled
131
+ # by this handler. The path is defined relative to the root of the
132
+ # application; that is, there may be path components in the request
133
+ # URI which won't be matched against, because they're handled by the
134
+ # webserver or Rack itself (if the app is routed to via a `map`
135
+ # block, for example).
136
+ #
137
+ # If `path` is a string, the matching logic is very simple: if the
138
+ # path of the request matches exactly with `path`, then we run this
139
+ # handler. If not, we skip it.
140
+ #
141
+ # If `path` is a regex, things are ever so slightly more
142
+ # complicated. In that instance, we'll run the handler if the given
143
+ # regex matches the path of the request. In addition, any capturing
144
+ # subexpressions (aka "the bits in parentheses") in the regular
145
+ # expression will be passed as arguments to the handler block.
146
+ #
147
+ # In almost all cases, you'll want to anchor your regular
148
+ # expressions (surround them in `^` and `$`); while it's very
149
+ # unlikely that you'll want to handle a URL with `/foo` anywhere in
150
+ # the path, we don't want to *forbid* you from doing so, so there's
151
+ # no automatic anchoring of regexes.
152
+ #
153
+ # @param block [Proc] the code to execute when this handler is
154
+ # invoked. An arbitrary number of arguments may be passed to this
155
+ # block, if `path` is a regex which contains capturing
156
+ # subexpressions (that is, parts of the regular expression
157
+ # surrounded by unescaped parentheses). This is useful to capture
158
+ # portions of the URL, such as resource IDs, and feed them into your
159
+ # handler as arguments.
160
+ #
161
+ # @raise [ArgumentError] if you don't pass in a block, or you pass an
162
+ # invalid type for `path`.
163
+ #
164
+ def add_handler(verb, path, &block)
165
+ unless block_given?
166
+ raise ArgumentError,
167
+ "Must pass a block"
168
+ end
169
+
170
+ @routes ||= []
171
+ @routes << Route.new(verb, path, method_for(verb, path, block))
172
+ end
173
+
174
+ # :nodoc:
175
+ # Grovel through the list of routes, looking for a match.
176
+ #
177
+ # @raise [Clutterbuck::Router::NotFoundError] if no route matched the
178
+ # given path.
179
+ #
180
+ # @raise [Clutterbuck::Router::MethodNotAllowedError] if a route matched
181
+ # the given path, but it didn't have the right verb.
182
+ #
183
+ def find_route(verb, path)
184
+ if @routes.nil?
185
+ raise Clutterbuck::Router::NotFoundError,
186
+ path
187
+ end
188
+
189
+ candidates = @routes.select { |r| r.handles?(path) }
190
+
191
+ if candidates.empty?
192
+ raise Clutterbuck::Router::NotFoundError,
193
+ path
194
+ end
195
+
196
+ candidates = candidates.select { |r| r.verb == verb }
197
+
198
+ if candidates.empty?
199
+ raise Clutterbuck::Router::MethodNotAllowedError,
200
+ "#{verb} not permitted on #{path}"
201
+ end
202
+
203
+ candidates.first
204
+ end
205
+
206
+ private
207
+
208
+ # Get an unbound instance method for the given verb/path/block.
209
+ #
210
+ # @param verb [String]
211
+ #
212
+ # @param path [String, Regexp]
213
+ #
214
+ # @param block [Proc]
215
+ #
216
+ # @return [UnboundMethod]
217
+ #
218
+ def method_for(verb, path, block)
219
+ name = "#{verb} #{path}".to_sym
220
+ define_method(name, &block)
221
+ instance_method(name).tap do |m|
222
+ remove_method name
223
+ end
224
+ end
225
+ end
226
+
227
+ #:nodoc:
228
+ # Add in the class-level methods to the app.
229
+ #
230
+ def self.included(mod)
231
+ mod.extend(ClassMethods)
232
+ end
233
+
234
+ # Create a new instance of the app.
235
+ #
236
+ # @param env [Hash] The Rack environment for this request.
237
+ #
238
+ def initialize(env)
239
+ @env = env
240
+ end
241
+
242
+ # Handle the request.
243
+ #
244
+ # Find the route, run it, and return the result.
245
+ #
246
+ def call
247
+ path = env["PATH_INFO"].empty? ? "/" : env["PATH_INFO"]
248
+
249
+ begin
250
+ route = self.class.find_route(env["REQUEST_METHOD"], path)
251
+ rescue Clutterbuck::Router::NotFoundError
252
+ return [404, [["Content-Type", "text/plain"]], ["Not found"]]
253
+ rescue Clutterbuck::Router::MethodNotAllowedError => ex
254
+ return [405, [["Content-Type", "text/plain"]], [ex]]
255
+ end
256
+
257
+ route.run(self, path).tap do |response|
258
+ if env["REQUEST_METHOD"] == "HEAD"
259
+ response[2] = []
260
+ end
261
+ end
262
+ end
263
+
264
+ protected
265
+ # Return the Rack environment for this request.
266
+ def env
267
+ @env
268
+ end
269
+ end
@@ -0,0 +1,59 @@
1
+ #:nodoc:
2
+ module Clutterbuck; end
3
+ #:nodoc:
4
+ module Clutterbuck::Router; end
5
+
6
+ # A route within the application.
7
+ #
8
+ # Not something you should ever have to deal with yourself directly, it's
9
+ # part of the internal plumbing of {Clutterbuck::Router}.
10
+ #
11
+ class Clutterbuck::Router::Route
12
+ attr_accessor :verb, :path_match
13
+
14
+ # @param verb [String]
15
+ #
16
+ # @param path_match [String, Regexp]
17
+ #
18
+ # @param method [UnboundMethod] is a method to call on an instance of the
19
+ # app class this route is defined on, which will do whatever is needed
20
+ # to handle the route. Because you can't just create an
21
+ # `UnboundMethod` out of thin air, the `UnboundMethod` needs to be
22
+ # created in the class, and then passed into here. Ugly.
23
+ #
24
+ def initialize(verb, path_match, method)
25
+ unless path_match.is_a?(String) or path_match.is_a?(Regexp)
26
+ raise ArgumentError,
27
+ "path must be either a string or a regexp"
28
+ end
29
+
30
+ @verb, @path_match, @method = verb, path_match, method
31
+ end
32
+
33
+ # Can this route handle a request for the specified path?
34
+ #
35
+ # @param path [String]
36
+ #
37
+ # @return Boolean
38
+ #
39
+ def handles?(path)
40
+ !!case @path_match
41
+ when String
42
+ @path_match == path
43
+ when Regexp
44
+ @path_match =~ path
45
+ end
46
+ end
47
+
48
+ # Execute the handler for the route
49
+ #
50
+ # Run the method
51
+ def run(obj, path)
52
+ args = case @path_match
53
+ when String then []
54
+ when Regexp then @path_match.match(path)[1..-1]
55
+ end
56
+
57
+ @method.bind(obj).call(*args)
58
+ end
59
+ end