lennarb 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.
@@ -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