lennarb 1.3.0 → 1.4.1

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,76 @@
1
+ module Lennarb
2
+ class Environment
3
+ NAMES = %i[development test production local]
4
+
5
+ # Returns the name of the environment.
6
+ # @parameter name [Symbol]
7
+ #
8
+ attr_reader :name
9
+
10
+ # Initialize the environment.
11
+ # @parameter name [String, Symbol] The name of the environment.
12
+ #
13
+ def initialize(name)
14
+ @name = name.to_sym
15
+
16
+ return if NAMES.include?(@name)
17
+
18
+ raise ArgumentError, "Invalid environment: #{@name.inspect}"
19
+ end
20
+
21
+ # Returns true if the environment is development.
22
+ #
23
+ def development? = name == :development
24
+
25
+ # Returns true if the environment is test.
26
+ #
27
+ def test? = name == :test
28
+
29
+ # Returns true if the environment is production.
30
+ #
31
+ def production? = name == :production
32
+
33
+ # Returns true if the environment is local (either `test` or `development`).
34
+ #
35
+ def local? = test? || development?
36
+
37
+ # Implements equality for the environment.
38
+ #
39
+ def ==(other) = name == other || name.to_s == other
40
+ alias_method :eql?, :==
41
+ alias_method :equal?, :==
42
+ alias_method :===, :==
43
+
44
+ # Returns the name of the environment as a symbol.
45
+ # @returns [Symbol]
46
+ #
47
+ def to_sym = name
48
+
49
+ # Returns the name of the environment as a string.
50
+ # @returns [String]
51
+ #
52
+ def to_s = name.to_s
53
+
54
+ # Returns the name of the environment as a string.
55
+ # @returns [String]
56
+ def inspect = to_s.inspect
57
+
58
+ # Yields a block if the environment is the same as the given environment.
59
+ # - To match all environments use `:any` or `:all`.
60
+ # - To match local environments use `:local`.
61
+ # @param envs [Array<Symbol>] The environment(s) to check.
62
+ #
63
+ # @example
64
+ # app.env.on(:development) do
65
+ # # Code to run in development
66
+ # end
67
+ def on(*envs)
68
+ matched = envs.include?(:any) ||
69
+ envs.include?(:all) ||
70
+ envs.include?(name) ||
71
+ (envs.include?(:local) && local?)
72
+
73
+ yield if matched
74
+ end
75
+ end
76
+ end
@@ -1,14 +1,8 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2023-2024, by Aristóteles Coutinho.
5
-
6
- class Lennarb
1
+ module Lennarb
7
2
  class Request < Rack::Request
8
3
  # The environment variables of the request
9
4
  #
10
5
  # @returns [Hash]
11
- #
12
6
  attr_reader :env
13
7
 
14
8
  # Initialize the request object
@@ -20,64 +14,241 @@ class Lennarb
20
14
  #
21
15
  def initialize(env, route_params = {})
22
16
  super(env)
23
- @route_params = route_params
17
+ @route_params = route_params || {}
24
18
  end
25
19
 
26
- # Get the request body
20
+ # Get the request parameters merged with route parameters
27
21
  #
28
- # @returns [String]
22
+ # @returns [Hash]
29
23
  #
30
- def params = @params ||= super.merge(@route_params)&.transform_keys(&:to_sym)
24
+ def params
25
+ @params ||= super.merge(@route_params)&.transform_keys(&:to_sym)
26
+ end
31
27
 
32
- # Get the request path
28
+ # Get the request path without query string
33
29
  #
34
30
  # @returns [String]
35
31
  #
36
- def path = @path ||= super.split('?').first
32
+ def path
33
+ @path ||= super.split("?").first
34
+ end
37
35
 
38
36
  # Read the body of the request
39
37
  #
40
38
  # @returns [String]
41
39
  #
42
- def body = @body ||= super.read
40
+ def body
41
+ @body ||= super.read
42
+ end
43
43
 
44
44
  # Get the query parameters
45
45
  #
46
46
  # @returns [Hash]
47
47
  #
48
48
  def query_params
49
- @query_params ||= Rack::Utils.parse_nested_query(query_string).transform_keys(&:to_sym)
49
+ @query_params ||= Rack::Utils.parse_nested_query(query_string || "").transform_keys(&:to_sym)
50
+ end
51
+
52
+ # Set a value in the environment
53
+ #
54
+ # @parameter [String] key
55
+ # @parameter [Object] value
56
+ # @returns [Object] the value
57
+ #
58
+ def []=(key, value)
59
+ env[key] = value
60
+ end
61
+
62
+ # Get a value from the environment
63
+ #
64
+ # @parameter [String] key
65
+ # @returns [Object]
66
+ #
67
+ def [](key)
68
+ env[key]
50
69
  end
51
70
 
52
71
  # Get the headers of the request
53
72
  #
73
+ # @returns [Hash]
74
+ #
54
75
  def headers
55
- @headers ||= env.select { |key, _| key.start_with?('HTTP_') }
76
+ @headers ||= env.select { |key, _| key.start_with?("HTTP_") }
77
+ end
78
+
79
+ # Get the client IP address
80
+ #
81
+ # @returns [String]
82
+ #
83
+ def ip
84
+ ip_address
56
85
  end
57
86
 
58
- def ip = ip_address
59
- def secure? = scheme == 'https'
60
- def user_agent = headers['HTTP_USER_AGENT']
61
- def accept = headers['HTTP_ACCEPT']
62
- def referer = headers['HTTP_REFERER']
63
- def host = headers['HTTP_HOST']
64
- def content_length = headers['HTTP_CONTENT_LENGTH']
65
- def content_type = headers['HTTP_CONTENT_TYPE']
66
- def xhr? = headers['HTTP_X_REQUESTED_WITH']&.casecmp('XMLHttpRequest')&.zero?
87
+ # Check if the request is secure (HTTPS)
88
+ #
89
+ # @returns [Boolean]
90
+ #
91
+ def secure?
92
+ scheme == "https"
93
+ end
67
94
 
68
- def []=(key, value)
69
- env[key] = value
95
+ # Shorthand methods for common headers
96
+
97
+ # Get the user agent
98
+ #
99
+ # @returns [String, nil]
100
+ #
101
+ def user_agent
102
+ env["HTTP_USER_AGENT"]
70
103
  end
71
104
 
72
- def [](key)
73
- env[key]
105
+ # Get the accept header
106
+ #
107
+ # @returns [String, nil]
108
+ #
109
+ def accept
110
+ env["HTTP_ACCEPT"]
111
+ end
112
+
113
+ # Get the referer header
114
+ #
115
+ # @returns [String, nil]
116
+ #
117
+ def referer
118
+ env["HTTP_REFERER"]
119
+ end
120
+
121
+ # Get the host header
122
+ #
123
+ # @returns [String, nil]
124
+ #
125
+ def host
126
+ env["HTTP_HOST"]
127
+ end
128
+
129
+ # Get the content length header
130
+ #
131
+ # @returns [String, nil]
132
+ #
133
+ def content_length
134
+ env["HTTP_CONTENT_LENGTH"]
135
+ end
136
+
137
+ # Get the content type header
138
+ #
139
+ # @returns [String, nil]
140
+ #
141
+ def content_type
142
+ env["HTTP_CONTENT_TYPE"]
143
+ end
144
+
145
+ # Check if the request is an XHR request
146
+ #
147
+ # @returns [Boolean]
148
+ #
149
+ def xhr?
150
+ env["HTTP_X_REQUESTED_WITH"]&.casecmp("XMLHttpRequest")&.zero? || false
151
+ end
152
+
153
+ # Check if the request is a JSON request
154
+ #
155
+ # @returns [Boolean]
156
+ #
157
+ def json?
158
+ content_type&.include?("application/json")
159
+ end
160
+
161
+ # Parse JSON body if content type is application/json
162
+ #
163
+ # @returns [Hash, nil]
164
+ #
165
+ def json_body
166
+ return nil unless json?
167
+ @json_body ||= begin
168
+ require "json"
169
+ JSON.parse(body, symbolize_names: true)
170
+ rescue JSON::ParserError
171
+ nil
172
+ end
173
+ end
174
+
175
+ # Check if the request is an AJAX request (alias for xhr?)
176
+ #
177
+ # @returns [Boolean]
178
+ #
179
+ def ajax?
180
+ xhr?
181
+ end
182
+
183
+ # Get the requested format (.html, .json, etc)
184
+ #
185
+ # @returns [Symbol, nil]
186
+ #
187
+ def format
188
+ path_info = env["PATH_INFO"]
189
+ return nil unless path_info.include?(".")
190
+
191
+ extension = File.extname(path_info).delete(".")
192
+ extension.empty? ? nil : extension.to_sym
193
+ end
194
+
195
+ # Check if the request is a GET request
196
+ #
197
+ # @returns [Boolean]
198
+ #
199
+ def get?
200
+ request_method == "GET"
201
+ end
202
+
203
+ # Check if the request is a POST request
204
+ #
205
+ # @returns [Boolean]
206
+ #
207
+ def post?
208
+ request_method == "POST"
209
+ end
210
+
211
+ # Check if the request is a PUT request
212
+ #
213
+ # @returns [Boolean]
214
+ #
215
+ def put?
216
+ request_method == "PUT"
217
+ end
218
+
219
+ # Check if the request is a DELETE request
220
+ #
221
+ # @returns [Boolean]
222
+ #
223
+ def delete?
224
+ request_method == "DELETE"
225
+ end
226
+
227
+ # Check if the request is a HEAD request
228
+ #
229
+ # @returns [Boolean]
230
+ #
231
+ def head?
232
+ request_method == "HEAD"
233
+ end
234
+
235
+ # Check if the request is a PATCH request
236
+ #
237
+ # @returns [Boolean]
238
+ #
239
+ def patch?
240
+ request_method == "PATCH"
74
241
  end
75
242
 
76
243
  private
77
244
 
245
+ # Get the client IP address
246
+ #
247
+ # @returns [String]
248
+ #
78
249
  def ip_address
79
- forwarded_for = headers['HTTP_X_FORWARDED_FOR']
80
- forwarded_for ? forwarded_for.split(',').first.strip : env['REMOTE_ADDR']
250
+ forwarded_for = env["HTTP_X_FORWARDED_FOR"]
251
+ forwarded_for ? forwarded_for.split(",").first.strip : env["REMOTE_ADDR"]
81
252
  end
82
253
  end
83
254
  end
@@ -0,0 +1,31 @@
1
+ module Lennarb
2
+ class RequestHandler
3
+ Lennarb::Error = Class.new(StandardError)
4
+
5
+ attr_reader :app
6
+
7
+ def initialize(app)
8
+ @app = app
9
+ end
10
+
11
+ def call(env)
12
+ http_method = env[Rack::REQUEST_METHOD].to_sym
13
+ parts = env[Rack::PATH_INFO].split("/").reject(&:empty?)
14
+ block, params = app.routes.match_route(parts, http_method)
15
+
16
+ unless block
17
+ return [404, {"content-type" => CONTENT_TYPE[:TEXT]}, ["Not Found"]]
18
+ end
19
+
20
+ req = Request.new(env, params)
21
+ res = Response.new
22
+
23
+ catch(:halt) do
24
+ block.call(req, res)
25
+ res.finish
26
+ rescue Lennarb::Error => error
27
+ [500, {"content-type" => CONTENT_TYPE[:TEXT]}, ["Internal Server Error (#{error.message})"]]
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,9 +1,4 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2023-2024, by Aristóteles Coutinho.
5
-
6
- class Lennarb
1
+ module Lennarb
7
2
  class Response
8
3
  # @!attribute [rw] status
9
4
  # @returns [Integer]
@@ -27,27 +22,24 @@ class Lennarb
27
22
 
28
23
  # Constants
29
24
  #
30
- LOCATION = 'location'
25
+ LOCATION = "location"
31
26
  private_constant :LOCATION
32
27
 
33
- CONTENT_TYPE = 'content-type'
28
+ CONTENT_TYPE = "content-type"
34
29
  private_constant :CONTENT_TYPE
35
30
 
36
- CONTENT_LENGTH = 'content-length'
31
+ CONTENT_LENGTH = "content-length"
37
32
  private_constant :CONTENT_LENGTH
38
33
 
39
- ContentType = { HTML: 'text/html', TEXT: 'text/plain', JSON: 'application/json' }.freeze
40
- private_constant :ContentType
41
-
42
34
  # Initialize the response object
43
35
  #
44
36
  # @returns [Response]
45
37
  #
46
38
  def initialize
47
- @status = 404
39
+ @status = 200
48
40
  @headers = {}
49
- @body = []
50
- @length = 0
41
+ @body = []
42
+ @length = 0
51
43
  end
52
44
 
53
45
  # Set the response header
@@ -91,7 +83,7 @@ class Lennarb
91
83
  # @returns [String] str
92
84
  #
93
85
  def text(str)
94
- @headers[CONTENT_TYPE] = ContentType[:TEXT]
86
+ @headers[CONTENT_TYPE] = Lennarb::CONTENT_TYPE[:TEXT]
95
87
  write(str)
96
88
  end
97
89
 
@@ -102,7 +94,7 @@ class Lennarb
102
94
  # @returns [String] str
103
95
  #
104
96
  def html(str)
105
- @headers[CONTENT_TYPE] = ContentType[:HTML]
97
+ @headers[CONTENT_TYPE] = Lennarb::CONTENT_TYPE[:HTML]
106
98
  write(str)
107
99
  end
108
100
 
@@ -113,8 +105,13 @@ class Lennarb
113
105
  # @returns [String] str
114
106
  #
115
107
  def json(str)
116
- @headers[CONTENT_TYPE] = ContentType[:JSON]
117
- write(str)
108
+ json_str = JSON.generate(str)
109
+ @headers[CONTENT_TYPE] = Lennarb::CONTENT_TYPE[:JSON]
110
+ write(json_str)
111
+ rescue JSON::GeneratorError => e
112
+ @status = 500
113
+ @headers[CONTENT_TYPE] = Lennarb::CONTENT_TYPE[:TEXT]
114
+ write("JSON generation error: #{e.message}")
118
115
  end
119
116
 
120
117
  # Redirect the response
@@ -1,24 +1,28 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2023-2024, by Aristóteles Coutinho.
5
-
6
- class Lennarb
1
+ module Lennarb
7
2
  class RouteNode
3
+ DuplicateRouteError = Class.new(StandardError)
8
4
  attr_accessor :static_children, :dynamic_children, :blocks, :param_key
9
5
 
10
6
  def initialize
11
- @blocks = {}
12
- @param_key = nil
13
- @static_children = {}
7
+ @blocks = {}
8
+ @param_key = nil
9
+ @static_children = {}
14
10
  @dynamic_children = {}
15
11
  end
16
12
 
13
+ # Add a route to the route node.
14
+ #
15
+ # @param parts [Array<String>] The parts of the route.
16
+ # @param http_method [String] The HTTP method.
17
+ # @param block [Proc] The block to be executed when the route is matched.
18
+ #
19
+ # @returns [void]
20
+ #
17
21
  def add_route(parts, http_method, block)
18
22
  current_node = self
19
23
 
20
24
  parts.each do |part|
21
- if part.start_with?(':')
25
+ if part.start_with?(":")
22
26
  param_sym = part[1..].to_sym
23
27
  current_node.dynamic_children[param_sym] ||= RouteNode.new
24
28
  dynamic_node = current_node.dynamic_children[param_sym]
@@ -33,6 +37,14 @@ class Lennarb
33
37
  current_node.blocks[http_method] = block
34
38
  end
35
39
 
40
+ # Match a route.
41
+ #
42
+ # @param parts [Array<String>] The parts of the route.
43
+ # @param http_method [String] The HTTP method.
44
+ # @param params [Hash] The parameters of the route.
45
+ #
46
+ # @returns [Array<Proc, Hash>]
47
+ #
36
48
  def match_route(parts, http_method, params: {})
37
49
  if parts.empty?
38
50
  return [blocks[http_method], params] if blocks[http_method]
@@ -57,10 +69,35 @@ class Lennarb
57
69
  [nil, nil]
58
70
  end
59
71
 
72
+ # Merge another route node into this one.
73
+ #
74
+ # @param other [RouteNode] The other route node.
75
+ #
76
+ # @returns [void|DuplicateRouteError]
77
+ #
60
78
  def merge!(other)
61
- static_children.merge!(other.static_children)
62
- dynamic_children.merge!(other.dynamic_children)
63
- blocks.merge!(other.blocks)
79
+ other.blocks.each do |http_method, block|
80
+ if @blocks[http_method]
81
+ raise DuplicateRouteError, "Duplicate route for HTTP method: #{http_method}"
82
+ end
83
+ @blocks[http_method] = block
84
+ end
85
+
86
+ other.static_children.each do |path, node|
87
+ if @static_children[path]
88
+ @static_children[path].merge!(node)
89
+ else
90
+ @static_children[path] = node
91
+ end
92
+ end
93
+
94
+ other.dynamic_children.each do |param, node|
95
+ if @dynamic_children[param]
96
+ @dynamic_children[param].merge!(node)
97
+ else
98
+ @dynamic_children[param] = node
99
+ end
100
+ end
64
101
  end
65
102
  end
66
103
  end
@@ -0,0 +1,71 @@
1
+ module Lennarb
2
+ class Routes
3
+ attr_reader :store
4
+ # RouteNode is a trie data structure that stores routes.
5
+ # see {Lennarb::RouteNode} for more details.
6
+ #
7
+ # @example
8
+ # node = RouteNode.new
9
+ # node.add_route(["foo", "bar"], :GET, -> {})
10
+ #
11
+ def initialize(&)
12
+ @store = RouteNode.new
13
+ instance_eval(&) if block_given?
14
+ end
15
+
16
+ # Define the HTTP methods.
17
+ #
18
+ # get, post, put, delete, patch, options, head
19
+ #
20
+ HTTP_METHODS.each do |http_method|
21
+ define_method(http_method.downcase) do |path, &block|
22
+ register_route(http_method, path, &block)
23
+ end
24
+ end
25
+
26
+ # Define the root route.
27
+ #
28
+ # @param [String] path
29
+ #
30
+ # @param [Proc] block
31
+ #
32
+ # @returns [void]
33
+ #
34
+ def root(&block) = register_route(:GET, "/", &block)
35
+
36
+ # Match the route.
37
+ #
38
+ # @param [Array<String>] parts
39
+ #
40
+ # @param [Symbol] http_method
41
+ #
42
+ def match_route(...) = @store.match_route(...)
43
+
44
+ # Freeze store object.
45
+ #
46
+ # @returns [void]
47
+ #
48
+ def freeze = @store.freeze
49
+
50
+ private def register_route(http_method, path, &block)
51
+ parts = path.split("/").reject(&:empty?)
52
+ @store.add_route(parts, http_method, block)
53
+ end
54
+
55
+ module Mixin
56
+ extend self
57
+
58
+ def routes(&block)
59
+ @routes ||= Routes.new(&block)
60
+ end
61
+
62
+ HTTP_METHODS.each do |http_method|
63
+ define_method(http_method.downcase) do |path, &block|
64
+ routes.send(http_method.downcase, path, &block)
65
+ end
66
+ end
67
+
68
+ def root(&) = routes.root(&)
69
+ end
70
+ end
71
+ end
@@ -1,10 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2023-2024, by Aristóteles Coutinho.
5
-
6
- class Lennarb
7
- VERSION = '1.3.0'
8
-
9
- public_constant :VERSION
1
+ module Lennarb
2
+ VERSION = "1.4.1"
10
3
  end