hanami-router 0.0.0 → 0.6.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.
@@ -1,5 +1,6 @@
1
1
  module Hanami
2
- module Router
3
- VERSION = "0.0.0"
2
+ class Router
3
+ # @since 0.1.0
4
+ VERSION = '0.6.0'.freeze
4
5
  end
5
6
  end
@@ -0,0 +1,151 @@
1
+ require 'delegate'
2
+ require 'hanami/routing/error'
3
+ require 'hanami/utils/class'
4
+
5
+ module Hanami
6
+ module Routing
7
+ # Endpoint not found
8
+ # This is raised when the router fails to load an endpoint at the runtime.
9
+ #
10
+ # @since 0.1.0
11
+ class EndpointNotFound < Hanami::Routing::Error
12
+ end
13
+
14
+ # Routing endpoint
15
+ # This is the object that responds to an HTTP request made against a certain
16
+ # path.
17
+ #
18
+ # The router will use this class for:
19
+ #
20
+ # * Procs and any Rack compatible object (respond to #call)
21
+ #
22
+ # @since 0.1.0
23
+ #
24
+ # @api private
25
+ #
26
+ # @example
27
+ # require 'hanami/router'
28
+ #
29
+ # Hanami::Router.new do
30
+ # get '/proc', to: ->(env) { [200, {}, ['This will use Hanami::Routing::Endpoint']] }
31
+ # get '/rack-app', to: RackApp.new
32
+ # end
33
+ class Endpoint < SimpleDelegator
34
+ # @since 0.2.0
35
+ def inspect
36
+ case __getobj__
37
+ when Proc
38
+ source, line = __getobj__.source_location
39
+ lambda_inspector = " (lambda)" if __getobj__.lambda?
40
+
41
+ "#<Proc@#{ ::File.expand_path(source) }:#{ line }#{ lambda_inspector }>"
42
+ when Class
43
+ __getobj__
44
+ else
45
+ "#<#{ __getobj__.class }>"
46
+ end
47
+ end
48
+ end
49
+
50
+ # Routing endpoint
51
+ # This is the object that responds to an HTTP request made against a certain
52
+ # path.
53
+ #
54
+ # The router will use this class for:
55
+ #
56
+ # * Classes
57
+ # * Hanami::Action endpoints referenced as a class
58
+ # * Hanami::Action endpoints referenced a string
59
+ # * RESTful resource(s)
60
+ #
61
+ # @since 0.1.0
62
+ #
63
+ # @api private
64
+ #
65
+ # @example
66
+ # require 'hanami/router'
67
+ #
68
+ # Hanami::Router.new do
69
+ # get '/class', to: RackMiddleware
70
+ # get '/hanami-action-class', to: Dashboard::Index
71
+ # get '/hanami-action-string', to: 'dashboard#index'
72
+ #
73
+ # resource 'identity'
74
+ # resources 'articles'
75
+ # end
76
+ class ClassEndpoint < Endpoint
77
+ # Rack interface
78
+ #
79
+ # @since 0.1.0
80
+ def call(env)
81
+ __getobj__.new.call(env)
82
+ end
83
+ end
84
+
85
+ # Routing endpoint
86
+ # This is the object that responds to an HTTP request made against a certain
87
+ # path.
88
+ #
89
+ # The router will use this class for the same use cases of `ClassEndpoint`,
90
+ # but when the target class can't be found, instead of raise a `LoadError`
91
+ # we reference in a lazy endpoint.
92
+ #
93
+ # For each incoming HTTP request, it will look for the referenced class,
94
+ # then it will instantiate and invoke #call on the object.
95
+ #
96
+ # This behavior is required to solve a chicken-egg situation when we try
97
+ # to load the router first and then the application with all its endpoints.
98
+ #
99
+ # @since 0.1.0
100
+ #
101
+ # @api private
102
+ #
103
+ # @see Hanami::Routing::ClassEndpoint
104
+ class LazyEndpoint < Endpoint
105
+ # Initialize the lazy endpoint
106
+ #
107
+ # @since 0.1.0
108
+ def initialize(name, namespace)
109
+ @name, @namespace = name, namespace
110
+ end
111
+
112
+ # Rack interface
113
+ #
114
+ # @raise [EndpointNotFound] when the endpoint can't be found.
115
+ #
116
+ # @since 0.1.0
117
+ def call(env)
118
+ obj.call(env)
119
+ end
120
+
121
+ # @since 0.2.0
122
+ def inspect
123
+ # TODO review this implementation once the namespace feature will be
124
+ # cleaned up.
125
+ result = klass rescue nil
126
+
127
+ if result.nil?
128
+ result = @name
129
+ result = "#{ @namespace }::#{ result }" if @namespace != Object
130
+ end
131
+
132
+ result
133
+ end
134
+
135
+ private
136
+ # @since 0.1.0
137
+ # @api private
138
+ def obj
139
+ klass.new
140
+ end
141
+
142
+ # @since 0.2.0
143
+ # @api private
144
+ def klass
145
+ Utils::Class.load!(@name, @namespace)
146
+ rescue NameError => e
147
+ raise EndpointNotFound.new(e.message)
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,225 @@
1
+ require 'hanami/utils/string'
2
+ require 'hanami/utils/class'
3
+ require 'hanami/routing/endpoint'
4
+
5
+ module Hanami
6
+ module Routing
7
+ # Resolve duck-typed endpoints
8
+ #
9
+ # @since 0.1.0
10
+ #
11
+ # @api private
12
+ class EndpointResolver
13
+ # @since 0.2.0
14
+ # @api private
15
+ NAMING_PATTERN = '%{controller}::%{action}'.freeze
16
+
17
+ # Default separator for controller and action.
18
+ # A different separator can be passed to #initialize with the `:separator` option.
19
+ #
20
+ # @see #initialize
21
+ # @see #resolve
22
+ #
23
+ # @since 0.1.0
24
+ #
25
+ # @example
26
+ # require 'hanami/router'
27
+ #
28
+ # router = Hanami::Router.new do
29
+ # get '/', to: 'articles#show'
30
+ # end
31
+ ACTION_SEPARATOR = '#'.freeze
32
+
33
+ attr_reader :action_separator
34
+
35
+ # Initialize an endpoint resolver
36
+ #
37
+ # @param options [Hash] the options used to customize lookup behavior
38
+ #
39
+ # @option options [Class] :endpoint the endpoint class that is returned
40
+ # by `#resolve`. (defaults to `Hanami::Routing::Endpoint`)
41
+ #
42
+ # @option options [Class,Module] :namespace the Ruby namespace where to
43
+ # lookup for controllers and actions. (defaults to `Object`)
44
+ #
45
+ # @option options [String] :pattern the string to interpolate in order
46
+ # to return an action name. This string SHOULD contain
47
+ # <tt>'%{controller}'</tt> and <tt>'%{action}'</tt>, all the other keys
48
+ # will be ignored.
49
+ # See the examples below.
50
+ #
51
+ # @option options [String] :action_separator the sepatator between controller and
52
+ # action name. (defaults to `ACTION_SEPARATOR`)
53
+ #
54
+ # @return [Hanami::Routing::EndpointResolver] self
55
+ #
56
+ # @since 0.1.0
57
+ #
58
+ # @example Specify custom endpoint class
59
+ # require 'hanami/router'
60
+ #
61
+ # resolver = Hanami::Routing::EndpointResolver.new(endpoint: CustomEndpoint)
62
+ # router = Hanami::Router.new(resolver: resolver)
63
+ #
64
+ # router.get('/', to: endpoint).dest # => #<CustomEndpoint:0x007f97f3359570 ...>
65
+ #
66
+ # @example Specify custom Ruby namespace
67
+ # require 'hanami/router'
68
+ #
69
+ # resolver = Hanami::Routing::EndpointResolver.new(namespace: MyApp)
70
+ # router = Hanami::Router.new(resolver: resolver)
71
+ #
72
+ # router.get('/', to: 'articles#show')
73
+ # # => Will look for: MyApp::Articles::Show
74
+ #
75
+ #
76
+ #
77
+ # @example Specify custom pattern
78
+ # require 'hanami/router'
79
+ #
80
+ # resolver = Hanami::Routing::EndpointResolver.new(pattern: '%{controller}Controller::%{action}')
81
+ # router = Hanami::Router.new(resolver: resolver)
82
+ #
83
+ # router.get('/', to: 'articles#show')
84
+ # # => Will look for: ArticlesController::Show
85
+ #
86
+ #
87
+ #
88
+ # @example Specify custom controller-action separator
89
+ # require 'hanami/router'
90
+ #
91
+ # resolver = Hanami::Routing::EndpointResolver.new(separator: '@')
92
+ # router = Hanami::Router.new(resolver: resolver)
93
+ #
94
+ # router.get('/', to: 'articles@show')
95
+ # # => Will look for: Articles::Show
96
+ def initialize(options = {})
97
+ @endpoint_class = options[:endpoint] || Endpoint
98
+ @namespace = options[:namespace] || Object
99
+ @action_separator = options[:action_separator] || ACTION_SEPARATOR
100
+ @pattern = options[:pattern] || NAMING_PATTERN
101
+ end
102
+
103
+ # Resolve the given set of HTTP verb, path, endpoint and options.
104
+ # If it fails to resolve, it will mount the default endpoint to the given
105
+ # path, which returns an 404 (Not Found).
106
+ #
107
+ # @param options [Hash] the options required to resolve the endpoint
108
+ #
109
+ # @option options [String,Proc,Class,Object#call] :to the endpoint
110
+ # @option options [String] :namespace an optional routing namespace
111
+ #
112
+ # @return [Endpoint] this may vary according to the :endpoint option
113
+ # passed to #initialize
114
+ #
115
+ # @since 0.1.0
116
+ #
117
+ # @see #initialize
118
+ # @see #find
119
+ #
120
+ # @example Resolve to a Proc
121
+ # require 'hanami/router'
122
+ #
123
+ # router = Hanami::Router.new
124
+ # router.get '/', to: ->(env) { [200, {}, ['Hi!']] }
125
+ #
126
+ # @example Resolve to a class
127
+ # require 'hanami/router'
128
+ #
129
+ # router = Hanami::Router.new
130
+ # router.get '/', to: RackMiddleware
131
+ #
132
+ # @example Resolve to a Rack compatible object (respond to #call)
133
+ # require 'hanami/router'
134
+ #
135
+ # router = Hanami::Router.new
136
+ # router.get '/', to: AnotherMiddleware.new
137
+ #
138
+ # @example Resolve to a Hanami::Action from a string (see Hanami::Controller framework)
139
+ # require 'hanami/router'
140
+ #
141
+ # router = Hanami::Router.new
142
+ # router.get '/', to: 'articles#show'
143
+ #
144
+ # @example Resolve to a Hanami::Action (see Hanami::Controller framework)
145
+ # require 'hanami/router'
146
+ #
147
+ # router = Hanami::Router.new
148
+ # router.get '/', to: Articles::Show
149
+ #
150
+ # @example Resolve a redirect with a namespace
151
+ # require 'hanami/router'
152
+ #
153
+ # router = Hanami::Router.new
154
+ # router.namespace 'users' do
155
+ # get '/home', to: ->(env) { ... }
156
+ # redirect '/dashboard', to: '/home'
157
+ # end
158
+ #
159
+ # # GET /users/dashboard => 301 Location: "/users/home"
160
+ def resolve(options, &endpoint)
161
+ result = endpoint || find(options)
162
+ resolve_callable(result) || resolve_matchable(result) || default
163
+ end
164
+
165
+ # Finds a path from the given options.
166
+ #
167
+ # @param options [Hash] the path description
168
+ # @option options [String,Proc,Class,Object#call] :to the endpoint
169
+ # @option options [String] :namespace an optional namespace
170
+ #
171
+ # @since 0.1.0
172
+ #
173
+ # @return [Object]
174
+ def find(options)
175
+ options[:to]
176
+ end
177
+
178
+ protected
179
+ def default
180
+ @endpoint_class.new(
181
+ ->(env) { [404, {'X-Cascade' => 'pass'}, 'Not Found'] }
182
+ )
183
+ end
184
+
185
+ def constantize(string)
186
+ klass = Utils::Class.load!(string, @namespace)
187
+ if klass.respond_to?(:call)
188
+ Endpoint.new(klass)
189
+ else
190
+ ClassEndpoint.new(klass)
191
+ end
192
+ rescue NameError
193
+ LazyEndpoint.new(string, @namespace)
194
+ end
195
+
196
+ def classify(string)
197
+ Utils::String.new(string).classify
198
+ end
199
+
200
+ private
201
+ def resolve_callable(callable)
202
+ if callable.respond_to?(:call)
203
+ @endpoint_class.new(callable)
204
+ elsif callable.is_a?(Class) && callable.instance_methods.include?(:call)
205
+ @endpoint_class.new(callable.new)
206
+ end
207
+ end
208
+
209
+ def resolve_matchable(matchable)
210
+ if matchable.respond_to?(:match)
211
+ constantize(
212
+ resolve_action(matchable) || classify(matchable)
213
+ )
214
+ end
215
+ end
216
+
217
+ def resolve_action(string)
218
+ if string.match(action_separator)
219
+ controller, action = string.split(action_separator).map {|token| classify(token) }
220
+ @pattern % {controller: controller, action: action}
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,7 @@
1
+ module Hanami
2
+ module Routing
3
+ # @since 0.5.0
4
+ class Error < ::StandardError
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,209 @@
1
+ require 'rack/request'
2
+
3
+ module Hanami
4
+ module Routing
5
+ # Force ssl
6
+ #
7
+ # Redirect response to the secure equivalent resource (https)
8
+ #
9
+ # @since 0.4.1
10
+ # @api private
11
+ class ForceSsl
12
+ # Https scheme
13
+ #
14
+ # @since 0.4.1
15
+ # @api private
16
+ SSL_SCHEME = 'https'.freeze
17
+
18
+ # @since 0.4.1
19
+ # @api private
20
+ HTTPS = 'HTTPS'.freeze
21
+
22
+ # @since 0.4.1
23
+ # @api private
24
+ ON = 'on'.freeze
25
+
26
+ # Location header
27
+ #
28
+ # @since 0.4.1
29
+ # @api private
30
+ LOCATION_HEADER = 'Location'.freeze
31
+
32
+ # Default http port
33
+ #
34
+ # @since 0.4.1
35
+ # @api private
36
+ DEFAULT_HTTP_PORT = 80
37
+
38
+ # Default ssl port
39
+ #
40
+ # @since 0.4.1
41
+ # @api private
42
+ DEFAULT_SSL_PORT = 443
43
+
44
+ # Moved Permanently http code
45
+ #
46
+ # @since 0.4.1
47
+ # @api private
48
+ MOVED_PERMANENTLY_HTTP_CODE = 301
49
+
50
+ # Temporary Redirect http code
51
+ #
52
+ # @since 0.4.1
53
+ # @api private
54
+ TEMPORARY_REDIRECT_HTTP_CODE = 307
55
+
56
+ # @since 0.4.1
57
+ # @api private
58
+ HTTP_X_FORWARDED_SSL = 'HTTP_X_FORWARDED_SSL'.freeze
59
+
60
+ # @since 0.4.1
61
+ # @api private
62
+ HTTP_X_FORWARDED_SCHEME = 'HTTP_X_FORWARDED_SCHEME'.freeze
63
+
64
+ # @since 0.4.1
65
+ # @api private
66
+ HTTP_X_FORWARDED_PROTO = 'HTTP_X_FORWARDED_PROTO'.freeze
67
+
68
+ # @since 0.4.1
69
+ # @api private
70
+ HTTP_X_FORWARDED_PROTO_SEPARATOR = ','.freeze
71
+
72
+ # @since 0.4.1
73
+ # @api private
74
+ RACK_URL_SCHEME = 'rack.url_scheme'.freeze
75
+
76
+ # @since 0.4.1
77
+ # @api private
78
+ REQUEST_METHOD = 'REQUEST_METHOD'.freeze
79
+
80
+ # @since 0.4.1
81
+ # @api private
82
+ IDEMPOTENT_METHODS = ['GET', 'HEAD'].freeze
83
+
84
+ EMPTY_BODY = [].freeze
85
+
86
+ # Initialize ForceSsl.
87
+ #
88
+ # @param active [Boolean] activate redirection to SSL
89
+ # @param options [Hash] set of options
90
+ # @option options [String] :host
91
+ # @option options [Integer] :port
92
+ #
93
+ # @since 0.4.1
94
+ # @api private
95
+ def initialize(active, options = {})
96
+ @active = active
97
+ @host = options[:host]
98
+ @port = options[:port]
99
+
100
+ _redefine_call
101
+ end
102
+
103
+ # Set 301 status and Location header if this feature is activated.
104
+ #
105
+ # @param env [Hash] a Rack env instance
106
+ #
107
+ # @return [Array]
108
+ #
109
+ # @see Hanami::Routing::HttpRouter#call
110
+ #
111
+ # @since 0.4.1
112
+ # @api private
113
+ def call(env)
114
+ end
115
+
116
+ # Check if router has to force the response with ssl
117
+ #
118
+ # @return [Boolean]
119
+ #
120
+ # @since 0.4.1
121
+ # @api private
122
+ def force?(env)
123
+ !ssl?(env)
124
+ end
125
+
126
+ private
127
+
128
+ # @since 0.4.1
129
+ # @api private
130
+ attr_reader :host
131
+
132
+ # Return full url to redirect
133
+ #
134
+ # @param env [Hash] Rack env
135
+ #
136
+ # @return [String]
137
+ #
138
+ # @since 0.4.1
139
+ # @api private
140
+ def full_url(env)
141
+ "#{ SSL_SCHEME }://#{ host }:#{ port }#{ ::Rack::Request.new(env).fullpath }"
142
+ end
143
+
144
+ # Return redirect code
145
+ #
146
+ # @param env [Hash] Rack env
147
+ #
148
+ # @return [Integer]
149
+ #
150
+ # @since 0.4.1
151
+ # @api private
152
+ def redirect_code(env)
153
+ if IDEMPOTENT_METHODS.include?(env[REQUEST_METHOD])
154
+ MOVED_PERMANENTLY_HTTP_CODE
155
+ else
156
+ TEMPORARY_REDIRECT_HTTP_CODE
157
+ end
158
+ end
159
+
160
+ # Return correct default port for full url
161
+ #
162
+ # @return [Integer]
163
+ #
164
+ # @since 0.4.1
165
+ # @api private
166
+ def port
167
+ if @port == DEFAULT_HTTP_PORT
168
+ DEFAULT_SSL_PORT
169
+ else
170
+ @port
171
+ end
172
+ end
173
+
174
+ # @since 0.4.1
175
+ # @api private
176
+ def _redefine_call
177
+ return unless @active
178
+
179
+ define_singleton_method :call do |env|
180
+ [redirect_code(env), { LOCATION_HEADER => full_url(env) }, EMPTY_BODY] if force?(env)
181
+ end
182
+ end
183
+
184
+ # Adapted from Rack::Request#scheme
185
+ #
186
+ # @since 0.4.1
187
+ # @api private
188
+ def scheme(env)
189
+ if env[HTTPS] == ON
190
+ SSL_SCHEME
191
+ elsif env[HTTP_X_FORWARDED_SSL] == ON
192
+ SSL_SCHEME
193
+ elsif env[HTTP_X_FORWARDED_SCHEME]
194
+ env[HTTP_X_FORWARDED_SCHEME]
195
+ elsif env[HTTP_X_FORWARDED_PROTO]
196
+ env[HTTP_X_FORWARDED_PROTO].split(HTTP_X_FORWARDED_PROTO_SEPARATOR)[0]
197
+ else
198
+ env[RACK_URL_SCHEME]
199
+ end
200
+ end
201
+
202
+ # @since 0.4.1
203
+ # @api private
204
+ def ssl?(env)
205
+ scheme(env) == SSL_SCHEME
206
+ end
207
+ end
208
+ end
209
+ end