clutterbuck-router 0.0.1

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.
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