hanami-router 0.0.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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