lotus-router 0.0.0 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.coveralls.yml +2 -0
  3. data/.gitignore +5 -13
  4. data/.travis.yml +5 -0
  5. data/.yardopts +3 -0
  6. data/Gemfile +10 -3
  7. data/README.md +470 -6
  8. data/Rakefile +10 -1
  9. data/benchmarks/callable +23 -0
  10. data/benchmarks/named_routes +72 -0
  11. data/benchmarks/resource +44 -0
  12. data/benchmarks/resources +58 -0
  13. data/benchmarks/routes +67 -0
  14. data/benchmarks/run.sh +11 -0
  15. data/benchmarks/utils.rb +56 -0
  16. data/lib/lotus-router.rb +1 -0
  17. data/lib/lotus/router.rb +752 -3
  18. data/lib/lotus/router/version.rb +2 -2
  19. data/lib/lotus/routing/endpoint.rb +114 -0
  20. data/lib/lotus/routing/endpoint_resolver.rb +251 -0
  21. data/lib/lotus/routing/http_router.rb +130 -0
  22. data/lib/lotus/routing/namespace.rb +86 -0
  23. data/lib/lotus/routing/resource.rb +73 -0
  24. data/lib/lotus/routing/resource/action.rb +340 -0
  25. data/lib/lotus/routing/resource/options.rb +48 -0
  26. data/lib/lotus/routing/resources.rb +40 -0
  27. data/lib/lotus/routing/resources/action.rb +123 -0
  28. data/lib/lotus/routing/route.rb +53 -0
  29. data/lotus-router.gemspec +16 -12
  30. data/test/fixtures.rb +193 -0
  31. data/test/integration/client_error_test.rb +16 -0
  32. data/test/integration/pass_on_response_test.rb +13 -0
  33. data/test/named_routes_test.rb +123 -0
  34. data/test/namespace_test.rb +289 -0
  35. data/test/new_test.rb +67 -0
  36. data/test/redirect_test.rb +33 -0
  37. data/test/resource_test.rb +128 -0
  38. data/test/resources_test.rb +136 -0
  39. data/test/routing/endpoint_resolver_test.rb +110 -0
  40. data/test/routing/resource/options_test.rb +36 -0
  41. data/test/routing_test.rb +99 -0
  42. data/test/test_helper.rb +32 -0
  43. data/test/version_test.rb +7 -0
  44. metadata +102 -10
@@ -1,5 +1,5 @@
1
1
  module Lotus
2
- module Router
3
- VERSION = "0.0.0"
2
+ class Router
3
+ VERSION = '0.1.0'
4
4
  end
5
5
  end
@@ -0,0 +1,114 @@
1
+ require 'delegate'
2
+ require 'lotus/utils/class'
3
+
4
+ module Lotus
5
+ module Routing
6
+ # Endpoint not found
7
+ # This is raised when the router fails to load an endpoint at the runtime.
8
+ #
9
+ # @since 0.1.0
10
+ class EndpointNotFound < ::Exception
11
+ end
12
+
13
+ # Routing endpoint
14
+ # This is the object that responds to an HTTP request made against a certain
15
+ # path.
16
+ #
17
+ # The router will use this class for:
18
+ #
19
+ # * Procs and any Rack compatible object (respond to #call)
20
+ #
21
+ # @since 0.1.0
22
+ #
23
+ # @api private
24
+ #
25
+ # @example
26
+ # require 'lotus/router'
27
+ #
28
+ # Lotus::Router.new do
29
+ # get '/proc', to: ->(env) { [200, {}, ['This will use Lotus::Routing::Endpoint']] }
30
+ # get '/rack-app', to: RackApp.new
31
+ # end
32
+ class Endpoint < SimpleDelegator
33
+ end
34
+
35
+ # Routing endpoint
36
+ # This is the object that responds to an HTTP request made against a certain
37
+ # path.
38
+ #
39
+ # The router will use this class for:
40
+ #
41
+ # * Classes
42
+ # * Lotus::Action endpoints referenced as a class
43
+ # * Lotus::Action endpoints referenced a string
44
+ # * RESTful resource(s)
45
+ #
46
+ # @since 0.1.0
47
+ #
48
+ # @api private
49
+ #
50
+ # @example
51
+ # require 'lotus/router'
52
+ #
53
+ # Lotus::Router.new do
54
+ # get '/class', to: RackMiddleware
55
+ # get '/lotus-action-class', to: DashboardController::Index
56
+ # get '/lotus-action-string', to: 'dashboard#index'
57
+ #
58
+ # resource 'identity'
59
+ # resources 'articles'
60
+ # end
61
+ class ClassEndpoint < Endpoint
62
+ # Rack interface
63
+ #
64
+ # @since 0.1.0
65
+ def call(env)
66
+ __getobj__.new.call(env)
67
+ end
68
+ end
69
+
70
+ # Routing endpoint
71
+ # This is the object that responds to an HTTP request made against a certain
72
+ # path.
73
+ #
74
+ # The router will use this class for the same use cases of `ClassEndpoint`,
75
+ # but when the target class can't be found, instead of raise a `LoadError`
76
+ # we reference in a lazy endpoint.
77
+ #
78
+ # For each incoming HTTP request, it will look for the referenced class,
79
+ # then it will instantiate and invoke #call on the object.
80
+ #
81
+ # This behavior is required to solve a chicken-egg situation when we try
82
+ # to load the router first and then the application with all its endpoints.
83
+ #
84
+ # @since 0.1.0
85
+ #
86
+ # @api private
87
+ #
88
+ # @see Lotus::Routing::ClassEndpoint
89
+ class LazyEndpoint < Endpoint
90
+ # Initialize the lazy endpoint
91
+ #
92
+ # @since 0.1.0
93
+ def initialize(name, namespace)
94
+ @name, @namespace = name, namespace
95
+ end
96
+
97
+ # Rack interface
98
+ #
99
+ # @raise [EndpointNotFound] when the endpoint can't be found.
100
+ #
101
+ # @since 0.1.0
102
+ def call(env)
103
+ obj.call(env)
104
+ end
105
+
106
+ private
107
+ def obj
108
+ Utils::Class.load!(@name, @namespace).new
109
+ rescue NameError => e
110
+ raise EndpointNotFound.new(e.message)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,251 @@
1
+ require 'lotus/utils/string'
2
+ require 'lotus/utils/class'
3
+ require 'lotus/routing/endpoint'
4
+
5
+ module Lotus
6
+ module Routing
7
+ # Resolve duck-typed endpoints
8
+ #
9
+ # @since 0.1.0
10
+ #
11
+ # @api private
12
+ class EndpointResolver
13
+ # Default suffix appended to the controller#action string.
14
+ # A different suffix can be passed to #initialize with the `:suffix` option.
15
+ #
16
+ # @see #initialize
17
+ # @see #resolve
18
+ #
19
+ # @since 0.1.0
20
+ #
21
+ # @example
22
+ # require 'lotus/router'
23
+ #
24
+ # router = Lotus::Router.new do
25
+ # get '/', to: 'articles#show'
26
+ # end
27
+ #
28
+ # # That string is transformed into "Articles(::Controller::|Controller::)Show"
29
+ # # because the risolver is able to lookup (in the given order) for:
30
+ # #
31
+ # # * Articles::Controller::Show
32
+ # # * ArticlesController::Show
33
+ # #
34
+ # # When it finds a class, it stops the lookup and returns the result.
35
+ SUFFIX = '(::Controller::|Controller::)'.freeze
36
+
37
+ # Default separator for controller and action.
38
+ # A different separator can be passed to #initialize with the `:separator` option.
39
+ #
40
+ # @see #initialize
41
+ # @see #resolve
42
+ #
43
+ # @since 0.1.0
44
+ #
45
+ # @example
46
+ # require 'lotus/router'
47
+ #
48
+ # router = Lotus::Router.new do
49
+ # get '/', to: 'articles#show'
50
+ # end
51
+ ACTION_SEPARATOR = '#'.freeze
52
+
53
+ attr_reader :action_separator
54
+
55
+ # Initialize an endpoint resolver
56
+ #
57
+ # @param options [Hash] the options used to customize lookup behavior
58
+ #
59
+ # @option options [Class] :endpoint the endpoint class that is returned
60
+ # by `#resolve`. (defaults to `Lotus::Routing::Endpoint`)
61
+ #
62
+ # @option options [Class,Module] :namespace the Ruby namespace where to
63
+ # lookup for controllers and actions. (defaults to `Object`)
64
+ #
65
+ # @option options [String] :suffix the suffix appended to the controller
66
+ # name during the lookup. (defaults to `SUFFIX`)
67
+ #
68
+ # @option options [String] :action_separator the sepatator between controller and
69
+ # action name. (defaults to `ACTION_SEPARATOR`)
70
+ #
71
+ #
72
+ #
73
+ # @return [Lotus::Routing::EndpointResolver] self
74
+ #
75
+ #
76
+ #
77
+ # @since 0.1.0
78
+ #
79
+ #
80
+ #
81
+ # @example Specify custom endpoint class
82
+ # require 'lotus/router'
83
+ #
84
+ # resolver = Lotus::Routing::EndpointResolver.new(endpoint: CustomEndpoint)
85
+ # router = Lotus::Router.new(resolver: resolver)
86
+ #
87
+ # router.get('/', to: endpoint).dest # => #<CustomEndpoint:0x007f97f3359570 ...>
88
+ #
89
+ #
90
+ #
91
+ # @example Specify custom Ruby namespace
92
+ # require 'lotus/router'
93
+ #
94
+ # resolver = Lotus::Routing::EndpointResolver.new(namespace: MyApp)
95
+ # router = Lotus::Router.new(resolver: resolver)
96
+ #
97
+ # router.get('/', to: 'articles#show')
98
+ # # => Will look for:
99
+ # # * MyApp::Articles::Controller::Show
100
+ # # * MyApp::ArticlesController::Show
101
+ #
102
+ #
103
+ #
104
+ # @example Specify custom controller suffix
105
+ # require 'lotus/router'
106
+ #
107
+ # resolver = Lotus::Routing::EndpointResolver.new(suffix: '(Controller::|Ctrl::)')
108
+ # router = Lotus::Router.new(resolver: resolver)
109
+ #
110
+ # router.get('/', to: 'articles#show')
111
+ # # => Will look for:
112
+ # # * ArticlesController::Show
113
+ # # * ArticlesCtrl::Show
114
+ #
115
+ #
116
+ #
117
+ # @example Specify custom controller-action separator
118
+ # require 'lotus/router'
119
+ #
120
+ # resolver = Lotus::Routing::EndpointResolver.new(separator: '@')
121
+ # router = Lotus::Router.new(resolver: resolver)
122
+ #
123
+ # router.get('/', to: 'articles@show')
124
+ # # => Will look for:
125
+ # # * Articles::Controller::Show
126
+ # # * ArticlesController::Show
127
+ def initialize(options = {})
128
+ @endpoint_class = options[:endpoint] || Endpoint
129
+ @namespace = options[:namespace] || Object
130
+ @suffix = options[:suffix] || SUFFIX
131
+ @action_separator = options[:action_separator] || ACTION_SEPARATOR
132
+ end
133
+
134
+ # Resolve the given set of HTTP verb, path, endpoint and options.
135
+ # If it fails to resolve, it will mount the default endpoint to the given
136
+ # path, which returns an 404 (Not Found).
137
+ #
138
+ # @param options [Hash] the options required to resolve the endpoint
139
+ #
140
+ # @option options [String,Proc,Class,Object#call] :to the endpoint
141
+ # @option options [String] :prefix an optional path prefix
142
+ #
143
+ # @return [Endpoint] this may vary according to the :endpoint option
144
+ # passed to #initialize
145
+ #
146
+ # @since 0.1.0
147
+ #
148
+ # @see #initialize
149
+ # @see #find
150
+ #
151
+ # @example Resolve to a Proc
152
+ # require 'lotus/router'
153
+ #
154
+ # router = Lotus::Router.new
155
+ # router.get '/', to: ->(env) { [200, {}, ['Hi!']] }
156
+ #
157
+ # @example Resolve to a class
158
+ # require 'lotus/router'
159
+ #
160
+ # router = Lotus::Router.new
161
+ # router.get '/', to: RackMiddleware
162
+ #
163
+ # @example Resolve to a Rack compatible object (respond to #call)
164
+ # require 'lotus/router'
165
+ #
166
+ # router = Lotus::Router.new
167
+ # router.get '/', to: AnotherMiddleware.new
168
+ #
169
+ # @example Resolve to a Lotus::Action from a string (see Lotus::Controller framework)
170
+ # require 'lotus/router'
171
+ #
172
+ # router = Lotus::Router.new
173
+ # router.get '/', to: 'articles#show'
174
+ #
175
+ # @example Resolve to a Lotus::Action (see Lotus::Controller framework)
176
+ # require 'lotus/router'
177
+ #
178
+ # router = Lotus::Router.new
179
+ # router.get '/', to: ArticlesController::Show
180
+ #
181
+ # @example Resolve with a path prefix
182
+ # require 'lotus/router'
183
+ #
184
+ # router = Lotus::Router.new
185
+ # router.get '/dashboard', to: BackendApp.new, prefix: 'backend'
186
+ # # => Will be available under '/backend/dashboard'
187
+ def resolve(options, &endpoint)
188
+ result = endpoint || find(options)
189
+ resolve_callable(result) || resolve_matchable(result) || default
190
+ end
191
+
192
+ # Finds a path from the given options.
193
+ #
194
+ # @param options [Hash] the path description
195
+ # @option options [String,Proc,Class,Object#call] :to the endpoint
196
+ # @option options [String] :prefix an optional path prefix
197
+ #
198
+ # @since 0.1.0
199
+ #
200
+ # @return [Object]
201
+ def find(options)
202
+ if prefix = options[:prefix]
203
+ prefix.join(options[:to])
204
+ else
205
+ options[:to]
206
+ end
207
+ end
208
+
209
+ protected
210
+ def default
211
+ @endpoint_class.new(
212
+ ->(env) { [404, {'X-Cascade' => 'pass'}, 'Not Found'] }
213
+ )
214
+ end
215
+
216
+ def constantize(string)
217
+ begin
218
+ ClassEndpoint.new(Utils::Class.load!(string, @namespace))
219
+ rescue NameError
220
+ LazyEndpoint.new(string, @namespace)
221
+ end
222
+ end
223
+
224
+ def classify(string)
225
+ Utils::String.new(string).classify
226
+ end
227
+
228
+ private
229
+ def resolve_callable(callable)
230
+ if callable.respond_to?(:call)
231
+ @endpoint_class.new(callable)
232
+ end
233
+ end
234
+
235
+ def resolve_matchable(matchable)
236
+ if matchable.respond_to?(:match)
237
+ constantize(
238
+ resolve_action(matchable) || classify(matchable)
239
+ )
240
+ end
241
+ end
242
+
243
+ def resolve_action(string)
244
+ if string.match(action_separator)
245
+ controller, action = string.split(action_separator).map {|token| classify(token) }
246
+ controller + @suffix + action
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,130 @@
1
+ require 'http_router'
2
+ require 'lotus/utils/io'
3
+ require 'lotus/routing/endpoint_resolver'
4
+ require 'lotus/routing/route'
5
+
6
+ Lotus::Utils::IO.silence_warnings do
7
+ HttpRouter::Route::VALID_HTTP_VERBS = %w{GET POST PUT PATCH DELETE HEAD OPTIONS TRACE}
8
+ end
9
+
10
+ module Lotus
11
+ module Routing
12
+ # Invalid route
13
+ # This is raised when the router fails to recognize a route, because of the
14
+ # given arguments.
15
+ #
16
+ # @since 0.1.0
17
+ class InvalidRouteException < ::Exception
18
+ end
19
+
20
+ # HTTP router
21
+ #
22
+ # This implementation is based on ::HttpRouter (http_router gem).
23
+ #
24
+ # Lotus::Router wraps an instance of this class, in order to protect its
25
+ # public API from any future change of ::HttpRouter.
26
+ #
27
+ # @since 0.1.0
28
+ # @api private
29
+ class HttpRouter < ::HttpRouter
30
+ # Initialize the router.
31
+ #
32
+ # @see Lotus::Router#initialize
33
+ #
34
+ # @since 0.1.0
35
+ # @api private
36
+ def initialize(options = {}, &blk)
37
+ super(options, &nil)
38
+
39
+ @default_scheme = options[:scheme] if options[:scheme]
40
+ @default_host = options[:host] if options[:host]
41
+ @default_port = options[:port] if options[:port]
42
+ @route_class = options[:route] || Routing::Route
43
+ @resolver = options[:resolver] || Routing::EndpointResolver.new(options)
44
+ end
45
+
46
+ # Separator between controller and action name.
47
+ #
48
+ # @see Lotus::Routing::EndpointResolver::ACTION_SEPARATOR
49
+ #
50
+ # @since 0.1.0
51
+ # @api private
52
+ def action_separator
53
+ @resolver.action_separator
54
+ end
55
+
56
+ # Finds a path from the given options.
57
+ #
58
+ # @see Lotus::Routing::EndpointResolver#find
59
+ #
60
+ # @since 0.1.0
61
+ # @api private
62
+ def find(options)
63
+ @resolver.find(options)
64
+ end
65
+
66
+ # Generate a relative URL for a specified named route.
67
+ #
68
+ # @see Lotus::Router#path
69
+ #
70
+ # @since 0.1.0
71
+ # @api private
72
+ def path(route, *args)
73
+ _rescue_url_recognition { super }
74
+ end
75
+
76
+ # Generate an absolute URL for a specified named route.
77
+ #
78
+ # @see Lotus::Router#path
79
+ #
80
+ # @since 0.1.0
81
+ # @api private
82
+ def url(route, *args)
83
+ _rescue_url_recognition { super }
84
+ end
85
+
86
+ # Support for OPTIONS HTTP verb
87
+ #
88
+ # @see Lotus::Router#options
89
+ #
90
+ # @since 0.1.0
91
+ # @api private
92
+ def options(path, options = {}, &blk)
93
+ add_with_request_method(path, :options, options, &blk)
94
+ end
95
+
96
+ # @api private
97
+ def reset!
98
+ uncompile
99
+ @routes, @named_routes, @root = [], Hash.new{|h,k| h[k] = []}, Node::Root.new(self)
100
+ @default_host, @default_port, @default_scheme = 'localhost', 80, 'http'
101
+ end
102
+
103
+ # @api private
104
+ def pass_on_response(response)
105
+ super response.to_a
106
+ end
107
+
108
+ # @api private
109
+ def no_response(request, env)
110
+ if request.acceptable_methods.any? && !request.acceptable_methods.include?(env['REQUEST_METHOD'])
111
+ [405, {'Allow' => request.acceptable_methods.sort.join(", ")}, []]
112
+ else
113
+ @default_app.call(env)
114
+ end
115
+ end
116
+
117
+ private
118
+ def add_with_request_method(path, method, opts = {}, &app)
119
+ super.generate(@resolver, opts, &app)
120
+ end
121
+
122
+ def _rescue_url_recognition
123
+ yield
124
+ rescue ::HttpRouter::InvalidRouteException,
125
+ ::HttpRouter::TooManyParametersException => e
126
+ raise Routing::InvalidRouteException.new(e.message)
127
+ end
128
+ end
129
+ end
130
+ end