rutter 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ require_relative "route"
6
+ require_relative "scope"
7
+
8
+ module Rutter
9
+ # The router redirect incoming requests to defined endpoints. Most often
10
+ # it's to a controller and an action or a Rack endpoint.
11
+ #
12
+ # @attr_reader [Array<Rutter::Route>] flat_map
13
+ # Defined routes.
14
+ # @attr_reader [Hash<String => Array>] verb_map
15
+ # Routes group by verb.
16
+ # @attr_reader [Hash<Symbol => Rutter::Route>] named_map
17
+ # Named routes.
18
+ #
19
+ # @example basic usage
20
+ # Rutter.new do
21
+ # get "/", to: ->(env) {}
22
+ # post "/", to: ->(env) {}
23
+ # put "/", to: ->(env) {}
24
+ # patch "/", to: ->(env) {}
25
+ # delete "/", to: ->(env) {}
26
+ # options "/", to: ->(env) {}
27
+ # head "/", to: ->(env) {}
28
+ # trace "/", to: ->(env) {}
29
+ # end.freeze
30
+ class Builder
31
+ attr_reader :flat_map, :verb_map, :named_map
32
+
33
+ # Initializes the router.
34
+ #
35
+ # @param [String] scheme
36
+ # URL scheme.
37
+ # @param [String] host
38
+ # URL host.
39
+ # @param [Integer] port
40
+ # URL port.
41
+ # @param [String] controller_suffix
42
+ # Suffix string for controllers (BooksController).
43
+ #
44
+ # @yield
45
+ # Block is run inside the created `Builder` context.
46
+ #
47
+ # @return [self]
48
+ def initialize(
49
+ scheme: "http",
50
+ host: "example.com",
51
+ port: 80,
52
+ controller_suffix: nil,
53
+ &block
54
+ )
55
+ @scheme = scheme
56
+ @host = host
57
+ @port = port
58
+ @controller_suffix = controller_suffix
59
+ @flat_map = []
60
+ @verb_map = Hash.new { |hash, key| hash[key] = [] }
61
+ @named_map = {}
62
+
63
+ instance_eval(&block) if block_given?
64
+ end
65
+
66
+ # Mount a Rack application at the specified path.
67
+ #
68
+ # @param [Class, Object] app
69
+ # A class or an object that responds to `call`.
70
+ # @param [String] at
71
+ # Path prefix to match.
72
+ #
73
+ # @return [void]
74
+ def mount(app, at:)
75
+ Route::VERBS.each { |verb| add verb, "#{at}*_", to: app }
76
+ end
77
+
78
+ # Convenient method to create a redirect endpoint.
79
+ #
80
+ # @param [String] destination
81
+ # The destination.
82
+ # @param [Integer] status
83
+ # Response status code.
84
+ #
85
+ # @return [Proc]
86
+ # Redirect endpoint.
87
+ #
88
+ # @example basic usage
89
+ # Rutter.new do
90
+ # get "/legacy-path", to: redirect("/new_path")
91
+ # end
92
+ #
93
+ # @example with custom status
94
+ # Rutter.new do
95
+ # get "/legacy-path", to: redirect("/new_path", status: 301)
96
+ # end
97
+ def redirect(destination, status: 302)
98
+ ->(_env) { [status, { "Location" => destination.to_s }, []] }
99
+ end
100
+
101
+ # Defines a root route (a GET route for '/').
102
+ #
103
+ # @return [Rutter::Route]
104
+ #
105
+ # @see Rutter::Builder#add
106
+ def root(**opts)
107
+ get "/", **opts.merge(as: :root)
108
+ end
109
+
110
+ # Adds a new route to the collection.
111
+ #
112
+ # @param [String, Symbol] method
113
+ # Request method.
114
+ # @param [String] path
115
+ # Path template.
116
+ # @param [String, Symbol] as
117
+ # Route identifier (name).
118
+ # @param [String, Proc] to
119
+ # Route endpoint.
120
+ #
121
+ # @return [Rutter::Route]
122
+ def add(method, path, to:, as: nil)
123
+ route = Route.new(method, path, to, controller_suffix: @controller_suffix)
124
+
125
+ flat_map << route
126
+ verb_map[route.method] << route
127
+
128
+ return route unless as
129
+
130
+ add_named_route!(as, route)
131
+ end
132
+
133
+ # @see Rutter::Builder#add
134
+ Route::VERBS.each do |verb|
135
+ define_method verb.downcase do |path, to:, **opts|
136
+ add verb, path, to: to, **opts
137
+ end
138
+ end
139
+
140
+ # Starts a scoped collection of routes.
141
+ #
142
+ # @option opts [String] :path (nil)
143
+ # Path prefix
144
+ # @option opts [String] :namespace (nil)
145
+ # Namespace prefix
146
+ # @option opts [String, Symbol] :as (nil)
147
+ # Name prefix
148
+ # @yield
149
+ # Scope context.
150
+ #
151
+ # @return [Rutter::Scope]
152
+ #
153
+ # @example
154
+ # Rutter.new do
155
+ # scope path: "animals", namespace: "Species", as: "animals" do
156
+ # scope path: "mammals", namespace: "Mammals", as: "mammals" do
157
+ # get "/cats", to: "Cats#index", as: :cats
158
+ # end
159
+ # end
160
+ # end
161
+ #
162
+ # @example with subdomain
163
+ # Rutter.new do
164
+ # scope path: "v1", namespace: "Api::V1", subdomain: "api" do
165
+ # get "/books", to: "Books#index"
166
+ # end
167
+ # end
168
+ def scope(**opts, &block)
169
+ Scope.new(self, **opts, &block)
170
+ end
171
+
172
+ # Transforms a named route into a URL path.
173
+ #
174
+ # @param [Symbol] name
175
+ # Name of the route.
176
+ # @param [Hash] params
177
+ # Route paremeters.
178
+ #
179
+ # @return [String]
180
+ #
181
+ # @raise [ArgumentError]
182
+ # if route not found.
183
+ def path(name, params = {})
184
+ route = named_map[name]
185
+
186
+ raise ArgumentError, "no route called '#{name}'" unless route
187
+
188
+ path = route.expand(params)
189
+ path = path.gsub(%r{\A[\/]+|[\/]+\z}, "")
190
+ "/#{path}"
191
+ end
192
+
193
+ # Transforms a named route into a full URL with scheme and host.
194
+ #
195
+ # @param [Symbol] name
196
+ # Name of the route.
197
+ # @option params [String] :_scheme (configuration.scheme)
198
+ # Override scheme.
199
+ # @option params [String] :_host (configuration.host)
200
+ # Override host.
201
+ # @option params [String] :_subdomain (nil)
202
+ # Set a specific subdomain for the URL.
203
+ # @option params [Symbol] :_port (configuration.port)
204
+ # Override port. The port will only be visible unless it's set to `80`
205
+ # or `443`.
206
+ #
207
+ # @return [String]
208
+ #
209
+ # @raise [ArgumentError]
210
+ # If route not found.
211
+ def url(name, params = {})
212
+ scheme = params.delete(:_scheme) || @scheme
213
+ host = params.delete(:_host) || @host
214
+ port = (params.delete(:_port) || @port).to_i
215
+ host = "#{host}:#{port}" unless [80, 443].include?(port)
216
+ subdomain = params.delete(:_subdomain)
217
+ host = "#{subdomain}.#{host}" if subdomain
218
+ "#{scheme}://#{host}#{path(name, params)}"
219
+ end
220
+
221
+ # Process the request and is compatible with the Rack protocol.
222
+ #
223
+ # @param [Hash] env
224
+ # Rack environment hash.
225
+ #
226
+ # @return [Array]
227
+ # Serialized Rack response.
228
+ #
229
+ # @see http://rack.github.io
230
+ #
231
+ # @private
232
+ def call(env)
233
+ if (route = match(env))
234
+ env["rutter.action"] = route.endpoint[:action]
235
+ env["rutter.params"] = route.params(env["PATH_INFO"])
236
+ ctrl = route.endpoint[:controller]
237
+ ctrl = Object.const_get(ctrl) if ctrl.is_a?(String)
238
+ return ctrl.call(env)
239
+ end
240
+
241
+ NOT_FOUND_RESPONSE
242
+ end
243
+
244
+ # Freezes the router and its routes.
245
+ #
246
+ # @return [self]
247
+ def freeze
248
+ flat_map.freeze
249
+ verb_map.freeze
250
+ named_map.freeze
251
+
252
+ super
253
+ end
254
+
255
+ private
256
+
257
+ # Matches the incoming request with the routes.
258
+ #
259
+ # @param env [Hash] Rack's environment hash.
260
+ #
261
+ # @return [Rutter::Route, false]
262
+ #
263
+ # @private
264
+ def match(env)
265
+ path = env["PATH_INFO"].downcase
266
+ path = path.chomp("/") if path != "/" && path.end_with?("/")
267
+
268
+ routes = verb_map[env["REQUEST_METHOD"]]
269
+ routes.each do |route|
270
+ return route if route.match?(path)
271
+ end
272
+
273
+ false
274
+ end
275
+
276
+ # @private
277
+ def add_named_route!(name, route)
278
+ name = normalize_route_name(name)
279
+
280
+ if named_map.key?(name)
281
+ raise "a route called '#{name}' has already been defined"
282
+ end
283
+
284
+ named_map[name] = route
285
+ end
286
+
287
+ # @private
288
+ def normalize_route_name(name)
289
+ name.to_s
290
+ .tr("/", "_")
291
+ .gsub(/[_]{2,}/, "_")
292
+ .gsub(/\A_|_\z/, "")
293
+ .downcase
294
+ .to_sym
295
+ end
296
+
297
+ # Response returned when no route matched the request.
298
+ #
299
+ # @private
300
+ NOT_FOUND_RESPONSE = [
301
+ 404,
302
+ { "X-Cascade" => "pass" },
303
+ ["Route Not Found"]
304
+ ].freeze
305
+ end
306
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Rutter
6
+ # Represents a single route.
7
+ #
8
+ # @attr_reader [String] method
9
+ # Request method to match.
10
+ # @attr_reader [String] path
11
+ # Path to match.
12
+ # @attr_reader [Hash] endpoint
13
+ # Route endpoint.
14
+ class Route
15
+ # Valid request verbs.
16
+ VERBS = %w[GET POST PUT PATCH DELETE OPTIONS HEAD TRACE].freeze
17
+
18
+ attr_reader :method, :path, :endpoint
19
+
20
+ # Initializes the route.
21
+ #
22
+ # @param [Symbol, String] method
23
+ # Requets method to match.
24
+ # @param [String] path
25
+ # Path template.
26
+ # @param [String, Proc, Hash] endpoint
27
+ # Route endpoint.
28
+ # @param [String, false] controller_suffix
29
+ # Suffix for string controllers.
30
+ #
31
+ # @return [self]
32
+ #
33
+ # @raise [ArgumentError]
34
+ # If request method is unknown.
35
+ #
36
+ # @private
37
+ def initialize(method, path, endpoint, controller_suffix: nil)
38
+ @method = method.to_s.upcase
39
+
40
+ unless VERBS.include?(method)
41
+ raise ArgumentError, "invalid verb: '#{method}'"
42
+ end
43
+
44
+ @controller_suffix = controller_suffix || ""
45
+ @path = normalize_path(path)
46
+ @dynamic = false
47
+ @pattern = path_to_pattern(@path).freeze
48
+ @endpoint = build_endpoint_hash(endpoint).freeze
49
+
50
+ freeze
51
+ end
52
+
53
+ # Matches the given path to the route pattern.
54
+ #
55
+ # @param [String] path_info
56
+ # Requested path to match against.
57
+ #
58
+ # @return [Boolean]
59
+ def match?(path_info)
60
+ @dynamic ? path_info.match?(@pattern) : path_info == @path
61
+ end
62
+
63
+ # Extract params from the given path.
64
+ #
65
+ # NOTE: If the path does not match, `nil` is returned.
66
+ #
67
+ # @param [String] path
68
+ # Requested path to extract params from.
69
+ #
70
+ # @return [nil, Hash<String => String>]
71
+ def params(path)
72
+ return unless (result = path.match(@pattern))
73
+ values = result.captures.map { |v| CGI.unescape(v) if v }
74
+ Hash[result.names.zip(values)]
75
+ end
76
+
77
+ # Expands the pattern with the given arguments.
78
+ #
79
+ # @param [Hash] params
80
+ # Expand parameters.
81
+ #
82
+ # @return [String] Expanded string.
83
+ #
84
+ # @example basic example
85
+ # route = Route.new("GET", "/books/:id/:title", -> {})
86
+ #
87
+ # route.expand(id: 54, title: "eloquent-ruby")
88
+ # # => "/books/54/eloquent-ruby"
89
+ #
90
+ # @example with query
91
+ # route = Route.new("GET", "/books/:id/reviews", -> {})
92
+ #
93
+ # route.expand(id: 54, status: "approved")
94
+ # # => "/books/54/reviews?status=approved"
95
+ def expand(params = {})
96
+ string = if @dynamic
97
+ new_path = path.gsub(SEGMENT_MATCH) do |match|
98
+ params.delete(match[1..-1].to_sym)
99
+ end
100
+ new_path.gsub(/\(|\)/, "")
101
+ else
102
+ path.dup
103
+ end
104
+
105
+ string = normalize_path(string)
106
+ return string if params.empty?
107
+
108
+ "#{string}?#{Rack::Utils.build_nested_query(params)}"
109
+ end
110
+
111
+ private
112
+
113
+ # Segment for dynamic parts.
114
+ #
115
+ # @private
116
+ DYNAMIC_SEGMENT = "[^/.,;?]+"
117
+
118
+ # Segment for wildcards.
119
+ #
120
+ # @private
121
+ WILDCARD_SEGMENT = ".*"
122
+
123
+ # Match segment regexp.
124
+ #
125
+ # @private
126
+ SEGMENT_MATCH = /((:|\*)[\w]+)/
127
+
128
+ # @private
129
+ def path_to_pattern(path)
130
+ pattern = path.dup
131
+
132
+ if pattern.include?("(")
133
+ @dynamic = true
134
+ pattern = pattern.gsub("(", "(?:").gsub(")", ")?")
135
+ end
136
+
137
+ pattern = pattern.gsub(SEGMENT_MATCH) do |match|
138
+ @dynamic = true
139
+ segment = match[0] == ":" ? DYNAMIC_SEGMENT : WILDCARD_SEGMENT
140
+ "(?<#{match[1..-1]}>#{segment})"
141
+ end
142
+
143
+ /\A#{pattern}\z/
144
+ end
145
+
146
+ # @private
147
+ def normalize_path(path)
148
+ path = path.gsub(%r{[\/]{2,}}, "/")
149
+ .gsub(%r{\A[\/]+|[\/]+\z}, "")
150
+ .downcase
151
+
152
+ "/#{path}"
153
+ end
154
+
155
+ # @private
156
+ def build_endpoint_hash(endpoint)
157
+ if endpoint.is_a?(String)
158
+ ctrl, action = endpoint.gsub(/[:]{3,}/, "::")
159
+ .gsub(/\A[:]+|[:]+\z/, "")
160
+ .split("#")
161
+
162
+ { controller: "#{ctrl}#{@controller_suffix}", action: action }
163
+ else
164
+ { controller: endpoint, action: nil }
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Rutter
6
+ # Routes helper.
7
+ class Routes
8
+ extend Forwardable
9
+
10
+ # Delegate path and url method to the builder.
11
+ #
12
+ # @see Rutter::Builder#path
13
+ # @see Rutter::Builder#url
14
+ def_delegators :@builder, :path, :url
15
+
16
+ # Initializes the helper.
17
+ #
18
+ # @param [Rutter::Builder] builder
19
+ # Route builder object.
20
+ #
21
+ # @return [self]
22
+ def initialize(builder)
23
+ @builder = builder
24
+ end
25
+
26
+ protected
27
+
28
+ # @private
29
+ def method_missing(method, *args)
30
+ named_route, type = method.to_s.split(/\_(path|url)\z/)
31
+ return super unless type
32
+ @builder.public_send(type, named_route.to_sym, *args)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rutter
4
+ # Scoped set of routes.
5
+ class Scope
6
+ # Initializes the scope.
7
+ #
8
+ # @param [Rutter::Builder] router
9
+ # Root router object.
10
+ # @option opts [String] :path (nil)
11
+ # Path prefix
12
+ # @option opts [String] :namespace (nil)
13
+ # Namespace prefix
14
+ # @option opts [String, Symbol] :as (nil)
15
+ # Name prefix
16
+ # @yield
17
+ # Scope context.
18
+ #
19
+ # @return [self]
20
+ #
21
+ # @private
22
+ def initialize(router, **opts, &block)
23
+ @router = router
24
+ @path_prefix = opts[:path]
25
+ @namespace_prefix = opts[:namespace]
26
+ @name_prefix = opts[:as]
27
+
28
+ instance_eval(&block) if block_given?
29
+ end
30
+
31
+ # @see Rutter::Builder#scope
32
+ def scope(**opts, &block)
33
+ opts[:as] = "#{@name_prefix}_#{opts[:as]}" if @name_prefix
34
+ opts[:path] = "#{@path_prefix}/#{opts[:path]}" if @path_prefix
35
+
36
+ if @namespace_prefix
37
+ opts[:namespace] = "#{@namespace_prefix}::#{opts[:namespace]}"
38
+ end
39
+
40
+ Scope.new(@router, **opts, &block)
41
+ end
42
+
43
+ # @see Rutter::Builder#mount
44
+ def mount(app, at:, **opts)
45
+ at = "#{@path_prefix}/#{at}" if @path_prefix
46
+ @router.mount app, at: at, **opts
47
+ end
48
+
49
+ # @see Rutter::Builder#root
50
+ def root(to:, as: :root)
51
+ get "/", to: to, as: as
52
+ end
53
+
54
+ # @see Rutter::Builder#add
55
+ Route::VERBS.each do |verb|
56
+ verb_method = verb.downcase
57
+ define_method verb_method do |path, **opts|
58
+ path = "#{@path_prefix}/#{path}" if @path_prefix
59
+ opts[:as] = "#{@name_prefix}_#{opts[:as]}" if opts[:as]
60
+ if @namespace_prefix && opts[:to].is_a?(String)
61
+ opts[:to] = "#{@namespace_prefix}::#{opts[:to]}"
62
+ end
63
+
64
+ @router.public_send verb_method, path, **opts
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rutter
4
+ # Current version number.
5
+ VERSION = "0.1.0"
6
+ end
data/lib/rutter.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rutter/version"
4
+ require_relative "rutter/builder"
5
+ require_relative "rutter/routes"
6
+
7
+ # HTTP router for Ramverk and Rack.
8
+ module Rutter
9
+ # Creates a new builder object.
10
+ #
11
+ # @return [Rutter::Builder]
12
+ #
13
+ # @see Rutter::Builder
14
+ def self.new(**opts, &block)
15
+ Builder.new(**opts, &block)
16
+ end
17
+ end
data/rutter.gemspec ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require File.expand_path("../lib/rutter/version", __FILE__)
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "rutter"
7
+ spec.version = Rutter::VERSION
8
+ spec.summary = "HTTP router for Rack."
9
+
10
+ spec.required_ruby_version = ">= 2.4.0"
11
+ spec.required_rubygems_version = ">= 2.5.0"
12
+
13
+ spec.license = "MIT"
14
+
15
+ spec.author = "Tobias Sandelius"
16
+ spec.email = "tobias@sandeli.us"
17
+ spec.homepage = "https://github.com/sandelius/rutter"
18
+
19
+ spec.files = `git ls-files -z`.split("\x0")
20
+ spec.test_files = spec.files.grep(%r{^(spec)/})
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_dependency "rack", "~> 2.0"
24
+
25
+ spec.add_development_dependency "bundler"
26
+ spec.add_development_dependency "rake"
27
+ spec.add_development_dependency "minitest"
28
+ spec.add_development_dependency "mocha"
29
+ spec.add_development_dependency "rack-test"
30
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ describe "Rutter::Builder#mount" do
6
+ let(:router) do
7
+ Rutter.new do
8
+ mount ->(_env) { [200, {}, ["Admin App"]] }, at: "/admin"
9
+ end
10
+ end
11
+
12
+ def app
13
+ router.freeze
14
+ end
15
+
16
+ it "forwards the request to the given application" do
17
+ get "/admin/with/sub/paths"
18
+ assert_equal "Admin App", last_response.body
19
+ end
20
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "spec_helper"
5
+
6
+ class TestController
7
+ def self.call(env)
8
+ hash = env["rutter.params"]
9
+ hash["action"] = env["rutter.action"]
10
+ [100, {}, [JSON.generate(hash)]]
11
+ end
12
+ end
13
+
14
+ describe "ramverk.router_params" do
15
+ let(:router) { Rutter.new(controller_suffix: "Controller") }
16
+
17
+ def app
18
+ router.freeze
19
+ end
20
+
21
+ it "calls the endpoint (controller)" do
22
+ router.get "/books/:title", to: "Test#index"
23
+ get "/books/eloquent-ruby"
24
+ assert_equal 100, last_response.status
25
+ assert_equal '{"title":"eloquent-ruby","action":"index"}',
26
+ last_response.body
27
+ end
28
+ end