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.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/.yardopts +1 -0
- data/LICENCE +674 -0
- data/README.md +103 -0
- data/clutterbuck-router.gemspec +33 -0
- data/config.ru +4 -0
- data/lib/.gitkeep +0 -0
- data/lib/clutterbuck-router.rb +1 -0
- data/lib/clutterbuck/router.rb +269 -0
- data/lib/clutterbuck/router/route.rb +59 -0
- metadata +200 -0
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
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
|