lennarb 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lenna
4
+ class Router
5
+ # @api public
6
+ # @note This class is used to cache the routes.
7
+ class Cache
8
+ def initialize = @cache = {}
9
+
10
+ # @api public
11
+ # @param [String] method
12
+ # @param [String] path
13
+ # @return [String]
14
+ # @note This method is used to generate a key for the cache.
15
+ def cache_key(method, path) = "#{method} #{path}"
16
+
17
+ # @api public
18
+ # @param route_key [String] The key for the route.
19
+ # @param node [Lenna::Route::Node] The node for the route.
20
+ # @return [Lenna::Route::Node]
21
+ # @note This method is used to add a route to the cache.
22
+ def add(route_key, node) = @cache[route_key] = node
23
+
24
+ # @api public
25
+ # @param route_key [String] The key for the route.
26
+ # @return [Lenna::Route::Node]
27
+ # @note This method is used to get a route from the cache.
28
+ def get(route_key) = @cache[route_key]
29
+
30
+ # @api public
31
+ # @param route_key [String] The key for the route.
32
+ # @return [Boolean]
33
+ # @note This method is used to check if a route exists
34
+ # in the cache.
35
+ def exist?(route_key) = @cache.key?(route_key)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lenna
4
+ class Router
5
+ # This class is used to manage the namespaces.
6
+ #
7
+ # @api private
8
+ class NamespaceStack
9
+ # @return [Array] The stack of namespaces
10
+ #
11
+ # @api private
12
+ attr_reader :stack
13
+
14
+ # @return [void]
15
+ #
16
+ # @api private
17
+ def initialize = @stack = ['']
18
+
19
+ # This method is used to push a prefix to the stack.
20
+ #
21
+ # @param prefix [String] The prefix to be pushed
22
+ # @return [void]
23
+ #
24
+ # @example:
25
+ #
26
+ # stack = NamespaceStack.new
27
+ # stack.push('/users')
28
+ # stack.current_prefix # => '/users'
29
+ #
30
+ # @see #resolve_prefix
31
+ #
32
+ # @api private
33
+ def push(prefix)
34
+ @stack.push(resolve_prefix(prefix))
35
+ end
36
+
37
+ # @return [String] The popped prefix
38
+ #
39
+ # @api private
40
+ def pop
41
+ @stack.pop unless @stack.size == 1
42
+ end
43
+
44
+ # @return [String] The current prefix
45
+ #
46
+ # @api private
47
+ #
48
+ # @since 0.1.0
49
+ def current_prefix = @stack.last
50
+
51
+ # The to_s method is used to return the current prefix.
52
+ #
53
+ # @return [String] The current prefix
54
+ #
55
+ # @api private
56
+ def to_s = current_prefix
57
+
58
+ private
59
+
60
+ # The resolve_prefix method is used to resolve the prefix.
61
+ #
62
+ # @param prefix [String] The prefix to be resolved
63
+ # @return [String] The resolved prefix
64
+ #
65
+ # @see #current_prefix
66
+ #
67
+ # @since 0.1.0
68
+ def resolve_prefix(prefix)
69
+ current_prefix + prefix
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lenna
4
+ class Router
5
+ # The Request class is responsible for managing the request.
6
+ #
7
+ # @attr headers [Hash] the request headers
8
+ # @attr body [Hash] the request body
9
+ # @attr params [Hash] the request params
10
+ class Request < ::Rack::Request
11
+ # This method is used to parse the body params.
12
+ #
13
+ # @return [Hash] the request params
14
+ #
15
+ # @public
16
+ def params = super.merge(parse_body_params)
17
+
18
+ # This method rewinds the body
19
+ #
20
+ # @return [String] the request body content
21
+ #
22
+ # @api public
23
+ #
24
+ # @since 0.1.0
25
+ def body_content
26
+ body.rewind
27
+ body.read
28
+ end
29
+
30
+ # This method returns the headers in a normalized way.
31
+ #
32
+ # @return [Hash] the request headers
33
+ #
34
+ # @api public
35
+ #
36
+ # @example:
37
+ # Turn this:
38
+ # HTTP_FOO=bar Foo=bar
39
+ def headers
40
+ @headers ||= env.select { |k, _| k.start_with?('HTTP_') }
41
+ .transform_keys { |k| format_header_name(k) }
42
+ end
43
+
44
+ # This method returns the request body in a normalized way.
45
+ #
46
+ # @return [Hash] the request body
47
+ #
48
+ # @api public
49
+ def json_body = @json_body ||= parse_body_params
50
+
51
+ private
52
+
53
+ def json_request? = media_type == 'application/json'
54
+
55
+ def parse_json_body
56
+ @parsed_json_body ||= ::JSON.parse(body_content) if json_request?
57
+ rescue ::JSON::ParserError
58
+ {}
59
+ end
60
+
61
+ def parse_body_params
62
+ case media_type
63
+ when 'application/json'
64
+ parse_json_body
65
+ when 'application/x-www-form-urlencoded', 'multipart/form-data'
66
+ post_params
67
+ else
68
+ {}
69
+ end
70
+ end
71
+
72
+ def format_header_name(name)
73
+ name.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-')
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lenna
4
+ class Router
5
+ # The Response class is responsible for managing the response.
6
+ #
7
+ # @attr _headers [Hash] the response headers
8
+ # @attr _body [Array(String)] the response body
9
+ # @attr _status [Integer] the response status
10
+ # @attr params [Hash] the response params
11
+ class Response
12
+ public attr_reader :params
13
+ private attr_accessor :_headers, :_body, :_status
14
+
15
+ # Initialize the Response
16
+ def initialize(headers = {}, status = 200, body = [])
17
+ self._headers = headers
18
+ self._status = status
19
+ self._body = body
20
+ @params = {}
21
+ end
22
+
23
+ # @api public
24
+ # @return [Integer] the response status
25
+ def status = fetch_status
26
+
27
+ # @api public
28
+ # @param status [Integer] the response status
29
+ # @return [void]
30
+ def put_status(value) = status!(value)
31
+
32
+ # @api public
33
+ # @return [Array(String)] the body value
34
+ def body = fetch_body
35
+
36
+ # @api public
37
+ # @param value [Array(String)] the body value
38
+ # @return [void]
39
+ def put_body(value) = body!(value)
40
+
41
+ # @api public
42
+ # @param header [String] the header name
43
+ # @return [String] the header value
44
+ # @note This method will get the header value.
45
+ def header(key) = fetch_header(key)
46
+
47
+ # @api public
48
+ # @return [Hash] the response headers
49
+ def headers = fetch_headers
50
+
51
+ # @api public
52
+ # @param header [String] the header name
53
+ # @param value [String] the header value
54
+ # @return [void]
55
+ # @note This method will set the header value.
56
+ # If the header already exists, then the value will
57
+ # be appended to the header.
58
+ #
59
+ # @example
60
+ # put_header('X-Request-Id', '123')
61
+ # # => '123'
62
+ #
63
+ # put_header('X-Request-Id', '456')
64
+ # # => ['123', '456']
65
+ #
66
+ # put_header('X-Request-Id', ['456', '789'])
67
+ # # => ['123', '456', '789']
68
+ def put_header(key, value) = header!(key, value)
69
+
70
+ # Add multiple headers.
71
+ # @param headers [Hash] the headers
72
+ # @return [void]
73
+ # @note This method will add the headers.
74
+ # The headers are a hash where the key is the
75
+ # header name and the value is the header value.
76
+ #
77
+ # @example
78
+ # headers = {
79
+ # 'Content-Type' => 'application/json',
80
+ # 'X-Request-Id' => '123'
81
+ # }
82
+ #
83
+ def put_headers(headers)
84
+ headers => ::Hash
85
+
86
+ headers.each { |key, value| put_header(key, value) }
87
+ end
88
+
89
+ # @api public
90
+ # @param header [String] the header name
91
+ # @return [void]
92
+ # @note This method will delete the header.
93
+ def remove_header(key) = delete_header(key)
94
+
95
+ # @api public
96
+ # @param value [String] the key of the cookie
97
+ # @return [String] the cookie
98
+ # @note This method will get the cookie.
99
+ def cookie(value)
100
+ value => ::String
101
+
102
+ fetch_header('Set-Cookie')
103
+ .then { |cookie| cookie.split('; ') }
104
+ .then { |cookie| cookie.find { |c| c.start_with?("#{value}=") } }
105
+ .then { |cookie| cookie.split('=').last }
106
+ end
107
+
108
+ # @api public
109
+ # @param key [String] the key of the cookie
110
+ # @param value [String] the value of the cookie
111
+ # @return [void]
112
+ # @note This method will set the cookie.
113
+ def put_cookie(key, value)
114
+ key => ::String
115
+ value => ::String
116
+
117
+ cookie = "#{key}=#{value}"
118
+
119
+ header!('Set-Cookie', cookie)
120
+ end
121
+
122
+ # @api public
123
+ # @return [Hash] the cookies
124
+ def cookies
125
+ fetch_header('Set-Cookie')
126
+ .then { |cookie| cookie.split('; ') }
127
+ .each_with_object({}) do |cookie, acc|
128
+ key, value = cookie.split('=')
129
+
130
+ acc[key] = value
131
+ end
132
+ end
133
+
134
+ # @api public
135
+ # @param location [String] the redirect location
136
+ # @param status [Integer] the redirect status
137
+ # @return [void]
138
+ # @note This method will set the redirect location and
139
+ # status and finish the response.
140
+ def redirect(location, status: 302)
141
+ location => ::String
142
+
143
+ header!('Location', location)
144
+ status!(status)
145
+
146
+ finish!
147
+ rescue ::NoMatchingPatternError
148
+ raise ::ArgumentError, 'location must be a string'
149
+ end
150
+
151
+ # @api public
152
+ # @return [void]
153
+ # @note This method will finish the response.
154
+ def finish = finish!
155
+
156
+ # @api public
157
+ # @return [String] the response content type
158
+ # @note This method will set
159
+ # the response content type.
160
+ def content_type = header('Content-Type')
161
+
162
+ # @api public
163
+ # @param type [String] the response content type
164
+ # @param charset [Hash] the response charset
165
+ # @return [void]
166
+ def put_content_type(type, charset: nil)
167
+ type => ::String
168
+
169
+ case charset
170
+ in ::String then header!('Content-Type', "#{type}; charset=#{charset}")
171
+ else header!('Content-Type', type)
172
+ end
173
+ rescue ::NoMatchingPatternError
174
+ raise ::ArgumentError, 'type must be a string'
175
+ end
176
+
177
+ # @api public
178
+ # @param data [Hash, Array] the response data
179
+ # @return [void]
180
+ # @note This method will set the response data and
181
+ # finish the response.
182
+ def json(data:, status: 200)
183
+ data => ::Array | ::Hash
184
+
185
+ status!(status)
186
+ header!('Content-Type', 'application/json')
187
+ body!(data.to_json)
188
+
189
+ finish!
190
+ end
191
+
192
+ # Set the response content type to text/html.
193
+ # @param str [String] the response body
194
+ # @return [void]
195
+ def html(str = nil, status: 200)
196
+ status!(status)
197
+ header!('Content-Type', 'text/html')
198
+ body!(str)
199
+
200
+ finish!
201
+ end
202
+
203
+ # @param template_nam [String] the template name
204
+ # @param path [String] the template path, default is 'views'
205
+ # @param locals [Hash] the template locals
206
+ # @return [void | Exception]
207
+ # @note This method will render the template.
208
+ # The template engine is determined by the
209
+ # file extension.
210
+ #
211
+ # @example
212
+ # render('index')
213
+ # # => Render the template `views/index.html.erb`
214
+ #
215
+ # render('users/index')
216
+ # # => Render the template `views/users/index.html.erb`
217
+ #
218
+ # render('index', path: 'app/views/users')
219
+ # # => Render the template `app/views/users/index.html.erb`
220
+ #
221
+ # render('index', locals: { name: 'John' })
222
+ # # => Render the template `views/index.html.erb` with the local
223
+ # # variable `name` set to 'John'
224
+ def render(template_name, path: 'views', locals: {}, status: 200)
225
+ template_path = ::File.join(path, "#{template_name}.html.erb")
226
+
227
+ # Check if the template exists
228
+ unless File.exist?(template_path)
229
+ msg = "Template not found: #{template_path} 🤷‍♂️."
230
+
231
+ # Oops! The template doesn't exist or the path is wrong.
232
+ #
233
+ # The template exists? 🤔
234
+ # If you want to render a template from a custom path, then you
235
+ # can pass the full path though the path: keyword argument instead
236
+ # of just the name. For example:
237
+ # render('index', path: 'app/views/users')
238
+ raise msg
239
+ end
240
+
241
+ ::File
242
+ .read(template_path)
243
+ .then { |template| ::ERB.new(template).result_with_hash(locals) }
244
+ .then { |erb_template| html(erb_template, status:) }
245
+ end
246
+
247
+ # Helper methods for the response.
248
+ # @api public
249
+ # @return [void]
250
+ # @note This method will finish the response with a 404 status.
251
+ def not_found
252
+ body!(['Not Found'])
253
+ status!(404)
254
+ finish!
255
+ end
256
+
257
+ private
258
+
259
+ # @api private
260
+ # @return [Integer] the response status
261
+ # @note This method will get the response status.
262
+ def fetch_status = _status
263
+
264
+ # @api private
265
+ # @return [Integer] the response status
266
+ # @note This method will get the response status.
267
+ def status!(value)
268
+ value => ::Integer
269
+
270
+ self._status = value
271
+ rescue ::NoMatchingPatternError
272
+ raise ::ArgumentError, 'status must be an integer'
273
+ end
274
+
275
+ # @api private
276
+ # @return [Array(String)] the body value
277
+ def fetch_body = _body
278
+
279
+ # @api private
280
+ # @param body [Array(String)] the body to be used
281
+ # @return [void]
282
+ # @note This method will set the body.
283
+ def body!(value)
284
+ body => ::String | ::Array
285
+
286
+ case value
287
+ in ::String then self._body = [value]
288
+ in ::Array then self._body = value
289
+ end
290
+ rescue ::NoMatchingPatternError
291
+ raise ::ArgumentError, 'body must be a string or an array'
292
+ end
293
+
294
+ # @api private
295
+ # @param header [String] the header name
296
+ # @return [String] the header value
297
+ # @note This method will get the header value.
298
+ def fetch_header(header) = _headers[header]
299
+
300
+ # @api private
301
+ # @return [Hash] the response headers
302
+ def fetch_headers = _headers
303
+
304
+ # @api private
305
+ # @param key [String] the header name
306
+ # @param value [String] the value to be used
307
+ # @return [void]
308
+ # @note This method will set the header value.
309
+ # If the header already exists, then the value will
310
+ # be appended to the header.
311
+ def header!(key, value)
312
+ key => ::String
313
+ value => ::String | ::Array
314
+
315
+ header_value = fetch_header(key)
316
+
317
+ case value
318
+ in ::String then _headers[key] = [*header_value, value].uniq.join(', ')
319
+ in ::Array then _headers[key] = [*header_value, *value].uniq.join(', ')
320
+ end
321
+ rescue ::NoMatchingPatternError
322
+ raise ::ArgumentError, 'header must be a string or an array'
323
+ end
324
+
325
+ # @api private
326
+ # @param key [String] the header name
327
+ # @return [void]
328
+ # @note This method will delete the header.
329
+ def delete_header(key) = _headers.delete(key)
330
+
331
+ # @api private
332
+ # @param value [String] the redirect location
333
+ # @return [void]
334
+ def location!(value)
335
+ value => ::String
336
+
337
+ header!('Location', value)
338
+ end
339
+
340
+ # @api private
341
+ # @param value [String] the content value
342
+ # @return [String] the size of the content
343
+ # @note This method will get the size of the content.
344
+ def content_length!(value) = header!('Content-Length', value)
345
+
346
+ # @api private
347
+ # @return [void]
348
+ # @note This method will finish the response.
349
+ def finish!
350
+ put_content_type('text/html') unless header('Content-Type')
351
+ content_length!(body.join.size.to_s) unless header('Content-Length')
352
+
353
+ [_status, _headers, _body]
354
+ end
355
+ end
356
+ end
357
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lenna
4
+ class Router
5
+ # @note This class is responsible for matching the request path
6
+ # to an endpoint and executing the endpoint action.
7
+ #
8
+ # @api private
9
+ #
10
+ # @since 0.1.0
11
+ #
12
+ # This will match the request path to an endpoint and execute
13
+ # the endpoint action.
14
+ class RouteMatcher
15
+ # @param root_node [Lenna::Node] The root node
16
+ def initialize(root_node) = @root_node = root_node
17
+
18
+ # This method will match the request path to an endpoint and execute
19
+ # the endpoint action.
20
+ #
21
+ # @param req [Lenna::Request] The request object
22
+ # @param res [Lenna::Response] The response object
23
+ # @return [Lenna::Response] The response object
24
+ #
25
+ # @see #split_path
26
+ # @see #find_endpoint
27
+ # @see Lenna::Response#not_found
28
+ def match_and_execute_route(req, res)
29
+ params = {}
30
+ path_parts = split_path(req.path_info)
31
+ endpoint = find_endpoint(@root_node, path_parts, params)
32
+
33
+ if endpoint && (action = endpoint[req.request_method])
34
+ req.params.merge!(params)
35
+ action.call(req, res)
36
+ else
37
+ res.not_found
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # @todo: Refactor this method to a module.
44
+ def split_path(path) = path.split('/').reject(&:empty?)
45
+
46
+ # @param node [Lenna::Node] The node to search
47
+ # @param parts [Array] The path parts
48
+ # @param params [Hash] The params hash
49
+ # @return [Lenna::Node] The node that matches the path
50
+ #
51
+ # @note This method is recursive.
52
+ #
53
+ # @since 0.1.0
54
+ def find_endpoint(node, parts, params)
55
+ return node.endpoint if parts.empty?
56
+
57
+ part = parts.shift
58
+ child_node = node.children[part]
59
+
60
+ if child_node.nil? && (placeholder_node = node.children[:placeholder])
61
+ params[placeholder_node.placeholder_name] = part
62
+ child_node = placeholder_node
63
+ end
64
+
65
+ find_endpoint(child_node, parts, params) if child_node
66
+ end
67
+ end
68
+ end
69
+ end