lotus-router 0.0.0 → 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.
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