rutter 0.1.0

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