lennarb 1.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb597d27bfaffebc72a4b7aa52f74dd0c5e94c60d401c4f8c30558246509a59d
4
- data.tar.gz: 0a49adeeb50aee3f5d09a641535d9e230aadeb40d69dc9a1883551641f045999
3
+ metadata.gz: cd39c745c65bf722a7075f57bbd36a2a603b659ddecb23bac33b7733c0b912e2
4
+ data.tar.gz: 723a60bc0e9cf0ed60c3f548e8e9004310a29c3c36c63b6ab55308ca99c83da3
5
5
  SHA512:
6
- metadata.gz: 4a682640f80f02dc84fba9e1f9aa7f666b68b08101c19e2ab406dc9e5367352be25bf7f9f91b26b39ff2f92f9a2c472cc7ba1c306679970fa510fbca73806a04
7
- data.tar.gz: cb431441f0481b65774ed39f83bf12bb19a2316e3815bfba88388cefc7a641c712777727de51a3a03f60fde4a6de078b2c34d4abb25481835958cc9bb4eb2377
6
+ metadata.gz: 901cfe54fe27ea6addd4b48690f3cd12b73a9f85e05066f9382f6467e65e537760509958bfb4ffd23cc2ab523168428dd3c2b50027c523b8cfb15b3c1af1b4a0
7
+ data.tar.gz: c877fb10d4a3f918016390b43fcc54ed3b70fb8c38577b23a4c34b1820f3c2d898c685f738de1f6c7ff6e2c25066b2fb95e8020ef7a00c22a02f0c5f847fa76b
@@ -21,7 +21,6 @@ jobs:
21
21
  - macos
22
22
 
23
23
  ruby:
24
- - '3.3'
25
24
  - '3.4'
26
25
 
27
26
  experimental: [false]
@@ -47,3 +46,5 @@ jobs:
47
46
  - name: Run tests
48
47
  timeout-minutes: 10
49
48
  run: bundle exec rake test
49
+ - name: Run linter
50
+ run: bundle exec rake standard
data/Rakefile CHANGED
@@ -1,10 +1,12 @@
1
1
  require "bundler/gem_tasks"
2
+ require "standard/rake"
2
3
  require "rake/testtask"
3
4
 
4
- Rake::TestTask.new(:test) do |t|
5
+ Rake::TestTask.new do |t|
5
6
  t.libs << "test"
6
7
  t.libs << "lib"
7
8
  t.test_files = FileList["test/**/*_test.rb"]
9
+ t.verbose = true
8
10
  end
9
11
 
10
- task default: %i[test]
12
+ task default: :test
data/changelog.md CHANGED
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.1] - 2025-02-23
11
+
12
+ ### Added
13
+
14
+ - Add support to mount routes. Now, you can centralize the routes in a single file and mount them in the main application. Ex.
15
+
16
+ ```rb
17
+ class PostsController
18
+ extend Lennarb::Routes::Mixin
19
+
20
+ get '/posts' do |req, res|
21
+ res.html('Posts')
22
+ end
23
+ end
24
+
25
+ SampleApp = Lennarb.new do |router|
26
+ mount PostsController
27
+ end
28
+ ```
29
+
30
+ The `mount` method will add the routes from the `PostsController` class to the main application. You can use the `mount` method with multiple classes, ex. `mount PostsController, CommentsController`.
31
+
32
+ - Add `Lennarb::Environment` module to manage the environment variables in the project. Now, the `Lennarb` class is the main class of the project.
33
+ - Add `Lennarb::Config` module to manage the configuration in the project. Now, the `Lennarb` class is the main class of the project.
34
+ - Add `Lennarb::App` class.
35
+ - Lint the code with `standard` gem on the CI/CD pipeline.
36
+
37
+ ### Changed
38
+
39
+ - Convert the `Lennarb` class to a module. Now, the `App` class is the main class of the project.
40
+ - Move the request process to `Lennarb::RequestHandler` class.
41
+ - Improve the method `merge!` from `Lennarb::RouterNode` to prevent the duplication of the routes.
42
+
43
+ ### Fixed
44
+
45
+ - Software design issues.
46
+
10
47
  ## [1.4.0] - 2025-02-09
11
48
 
12
49
  ### Changed
@@ -177,6 +214,7 @@ end
177
214
  - Add `console` gem to print the logs in the console.
178
215
 
179
216
  - Add CLI module to:
217
+
180
218
  - Create a new project with `lennarb new` command.
181
219
  - Run the server with `lennarb server` command.
182
220
 
@@ -27,15 +27,17 @@ Create a new file named `config.ru`:
27
27
  ```ruby
28
28
  require 'lennarb'
29
29
 
30
- app = Lennarb.new do |app|
31
- app.get '/' do |req, res|
32
- res.status = 200
33
- res.html('<h1>Welcome to Lennarb!</h1>')
30
+ MyApp = Lennarb::App.new do
31
+ routes
32
+ get '/' do |req, res|
33
+ res.status = 200
34
+ res.html('<h1>Welcome to Lennarb!</h1>')
35
+ end
34
36
  end
35
37
  end
36
38
 
37
- app.initializer!
38
- run app
39
+ MyApp.initialize!
40
+ run App
39
41
  ```
40
42
 
41
43
  Start the server:
@@ -84,16 +86,16 @@ end
84
86
  Routes are defined using HTTP method helpers:
85
87
 
86
88
  ```ruby
87
- app = Lennarb.new do |l|
88
- # Basic route
89
- l.get '/' do |req, res|
90
- res.html('Home page')
91
- end
92
-
93
- # Route with parameters
94
- l.get '/users/:id' do |req, res|
95
- user_id = req.params[:id]
96
- res.json("{\"id\": #{user_id}}")
89
+ Lennarb::App.new do
90
+ routes do
91
+ get '/' do |req, res|
92
+ res.html('Home page')
93
+ end
94
+
95
+ get '/users/:id' do |req, res|
96
+ user_id = req.params[:id]
97
+ res.json("{\"id\": #{user_id}}")
98
+ end
97
99
  end
98
100
  end
99
101
  ```
@@ -103,7 +105,7 @@ end
103
105
  Parameters from dynamic route segments are available in `req.params`:
104
106
 
105
107
  ```ruby
106
- app.get '/hello/:name' do |req, res|
108
+ get '/hello/:name' do |req, res|
107
109
  name = req.params[:name]
108
110
  res.text("Hello, #{name}!")
109
111
  end
@@ -117,20 +119,26 @@ Lennarb is thread-safe by design:
117
119
  - The router tree is frozen after initialization
118
120
  - Response objects are created per-request
119
121
 
120
- ## Application Lifecycle
122
+ ## Application Life-cycle
121
123
 
122
124
  ### Initialization
123
125
 
124
126
  ```ruby
125
- app = Lennarb.new do |l|
126
- # Define routes and configuration
127
+ Myapp = Lennarb::App.new do
128
+ # Define routes
129
+ routes do
130
+ end
131
+
132
+ # Define Configurations
133
+ config do
134
+ end
127
135
  end
128
136
 
129
137
  # Initialize and freeze the application
130
- app.initializer!
138
+ MyApp.initialize!
131
139
  ```
132
140
 
133
- The `initializer!` method:
141
+ The `initialize!` method:
134
142
 
135
143
  - Loads environment-specific dependencies
136
144
  - Freezes the route tree
@@ -140,6 +148,12 @@ The `initializer!` method:
140
148
 
141
149
  Lennarb uses the `LENNA_ENV` environment variable (defaults to "development"):
142
150
 
151
+ It can be set using the following environment variables:
152
+
153
+ - `LENNA_ENV`
154
+ - `APP_ENV`
155
+ - `RACK_ENV`
156
+
143
157
  ```bash
144
158
  LENNA_ENV=production rackup
145
159
  ```
@@ -149,7 +163,7 @@ LENNA_ENV=production rackup
149
163
  Lennarb provides basic error handling:
150
164
 
151
165
  ```ruby
152
- app.get '/api' do |req, res|
166
+ get '/api' do |req, res|
153
167
  # Errors are caught and return 500 with error message
154
168
  raise "Something went wrong"
155
169
  end
@@ -162,18 +176,18 @@ Default error responses:
162
176
 
163
177
  ## Best Practices
164
178
 
165
- 1. **Always call initializer!**
179
+ 1. **Always call initialize!**
166
180
 
167
181
  ```ruby
168
- app = Lennarb.new { |l| ... }
169
- app.initializer!
182
+ app = Lennarb::App.new
183
+ app.initialize!
170
184
  run app
171
185
  ```
172
186
 
173
187
  2. **Set response status**
174
188
 
175
189
  ```ruby
176
- app.get '/api' do |req, res|
190
+ get '/api' do |req, res|
177
191
  res.status = 200
178
192
  res.json('{"status": "ok"}')
179
193
  end
@@ -10,19 +10,19 @@ You can use the `res` object to send a response to the client.
10
10
  ```ruby
11
11
  # app.rb
12
12
 
13
- app.get '/' do |req, res|
13
+ get '/' do |req, res|
14
14
  res.html 'Hello World'
15
15
  end
16
16
  ```
17
17
 
18
18
  ## Content Types
19
19
 
20
- Lenna supports the following content types:
20
+ Lennarb supports the following content types:
21
21
 
22
22
  ```ruby
23
23
  # app.rb
24
24
 
25
- app.get '/' do |req, res|
25
+ get '/' do |req, res|
26
26
  res.html 'Hello World'
27
27
  res.json '{"message": "Hello World"}'
28
28
  res.text 'Hello World'
@@ -43,7 +43,7 @@ You can use the `res.write` method to write to the response body:
43
43
  ```ruby
44
44
  # app.rb
45
45
 
46
- app.get '/' do |req, res|
46
+ get '/' do |req, res|
47
47
  res.write 'Hello World'
48
48
  end
49
49
  ```
@@ -53,7 +53,7 @@ JSON example:
53
53
  ```ruby
54
54
  # app.rb
55
55
 
56
- app.post '/posts' do |req, res|
56
+ post '/posts' do |req, res|
57
57
  req.params # => { name: 'Lenna' }
58
58
  name = req.params[:name]
59
59
 
@@ -76,7 +76,7 @@ You can redirect the client using the `res.redirect` method:
76
76
  ```ruby
77
77
  # app.ruby
78
78
 
79
- app.get '/' do |req, res|
79
+ get '/' do |req, res|
80
80
  # Stuff code here...
81
81
  res.redirect '/hello'
82
82
  end
data/lennarb.gemspec CHANGED
@@ -31,14 +31,17 @@ Gem::Specification.new do |spec|
31
31
  spec.add_dependency "bigdecimal"
32
32
  spec.add_dependency "colorize", "~> 1.1"
33
33
  spec.add_dependency "rack", "~> 3.1"
34
+ spec.add_dependency "superconfig"
34
35
  spec.add_development_dependency "bundler"
35
36
  spec.add_development_dependency "covered"
36
37
  spec.add_development_dependency "simplecov"
37
38
  spec.add_development_dependency "minitest"
39
+ spec.add_development_dependency "minitest-utils"
38
40
  spec.add_development_dependency "rack-test"
39
41
  spec.add_development_dependency "rake"
40
42
  spec.add_development_dependency "standard"
41
43
  spec.add_development_dependency "standard-custom"
42
44
  spec.add_development_dependency "standard-performance"
43
45
  spec.add_development_dependency "m"
46
+ spec.add_development_dependency "debug"
44
47
  end
@@ -0,0 +1,172 @@
1
+ module Lennarb
2
+ class App
3
+ # This error is raised whenever the app is initialized more than once.
4
+ AlreadyInitializedError = Class.new(StandardError)
5
+
6
+ # The root app directory of the app.
7
+ #
8
+ # @returns [Pathname]
9
+ #
10
+ attr_accessor :root
11
+
12
+ # The current environment. Defaults to "development".
13
+ # It can be set using the following environment variables:
14
+ #
15
+ # - `LENNA_ENV`
16
+ # - `APP_ENV`
17
+ # - `RACK_ENV`
18
+ #
19
+ # @returns [Lennarb::Environment]
20
+ #
21
+ attr_reader :env
22
+
23
+ def initialize(&)
24
+ @initialized = false
25
+ self.root = Pathname.pwd
26
+ self.env = compute_env
27
+ instance_eval(&) if block_given?
28
+ end
29
+
30
+ # Set the current environment. See {Lennarb::Environment} for more details.
31
+ #
32
+ # @parameter [Hash] env
33
+ #
34
+ def env=(env)
35
+ raise AlreadyInitializedError if initialized?
36
+
37
+ @env = Environment.new(env)
38
+ end
39
+
40
+ # Mount an app at a specific path.
41
+ #
42
+ # @parameter [Object] The controller|app to mount.
43
+ #
44
+ # @returns [void]
45
+ #
46
+ # @example
47
+ #
48
+ # class PostController
49
+ # extend Lennarb::Routes::Mixin
50
+ #
51
+ # get "/post/:id" do |req, res|
52
+ # res.text("Post ##{req.params[:id]}")
53
+ # end
54
+ # end
55
+ #
56
+ # MyApp = Lennarb::App.new do
57
+ # routes do
58
+ # mount PostController
59
+ # end
60
+ #
61
+ def mount(*controllers)
62
+ controllers.each do |controller|
63
+ raise ArgumentError, "Controller must respond to :routes" unless controller.respond_to?(:routes)
64
+
65
+ self.controllers << controller
66
+ end
67
+ end
68
+
69
+ # Define the app's configuration. See {Lennarb::Config}.
70
+ #
71
+ # @returns [Lennarb::Config]
72
+ #
73
+ # @example Run config on every environment
74
+ # app.config do
75
+ # mandatory :database_url, string
76
+ # end
77
+ #
78
+ # @example Run config on every a specific environment
79
+ # app.config :development do
80
+ # set :domain, "example.dev"
81
+ # end
82
+ #
83
+ # @example Run config on every a specific environment
84
+ # app.config :development, :test do
85
+ # set :domain, "example.dev"
86
+ # end
87
+ #
88
+ def config(*envs, &)
89
+ @config ||= Config.new
90
+
91
+ write = block_given? &&
92
+ (envs.map(&:to_sym).include?(env.to_sym) || envs.empty?)
93
+
94
+ @config.instance_eval(&) if write
95
+
96
+ @config
97
+ end
98
+
99
+ # Define the app's route. See {Lennarb::RouteNode} for more details.
100
+ #
101
+ # @returns [Lennarb::RouteNode]
102
+ #
103
+ def routes(&)
104
+ @routes ||= Routes.new
105
+ @routes.instance_eval(&) if block_given?
106
+ @routes
107
+ end
108
+
109
+ # The Rack app.
110
+ #
111
+ def app
112
+ @app ||= begin
113
+ request_handler = RequestHandler.new(self)
114
+
115
+ Rack::Builder.app do
116
+ run request_handler
117
+ end
118
+ end
119
+ end
120
+
121
+ # Store mounted app's
122
+ #
123
+ def controllers
124
+ @controllers ||= []
125
+ end
126
+ alias_method :mounted_apps, :controllers
127
+
128
+ # Check if the app is initialized.
129
+ #
130
+ # @returns [Boolean]
131
+ #
132
+ def initialized? = @initialized
133
+
134
+ # Initialize the app.
135
+ #
136
+ # @returns [void]
137
+ #
138
+ def initialize!
139
+ raise AlreadyInitializedError if initialized?
140
+
141
+ controllers.each do
142
+ routes.store.merge!(it.routes.store)
143
+ end
144
+
145
+ @initialized = true
146
+
147
+ app.freeze
148
+ routes.freeze
149
+ end
150
+
151
+ # Call the app.
152
+ #
153
+ # @parameter [Hash] env
154
+ #
155
+ def call(env)
156
+ env[RACK_LENNA_APP] = self
157
+ Dir.chdir(root) { return app.call(env) }
158
+ end
159
+
160
+ # Compute the current environment.
161
+ #
162
+ # @returns [String]
163
+ #
164
+ # @private
165
+ #
166
+ private def compute_env
167
+ env = ENV_NAMES.map { ENV[_1] }.compact.first.to_s
168
+
169
+ env.empty? ? "development" : env
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,34 @@
1
+ module Lennarb
2
+ # The configuration for the application.
3
+ # It uses {https://rubygems.org/gems/superconfig SuperConfig} to define the
4
+ # configuration.
5
+ class Config < SuperConfig::Base
6
+ MissingEnvironmentVariable = Class.new(StandardError)
7
+ MissingCallable = Class.new(StandardError)
8
+
9
+ undef_method :credential
10
+
11
+ def initialize(**)
12
+ block = proc { true }
13
+ super(**, &block)
14
+ end
15
+
16
+ # @private
17
+ def to_s = "#<Lennarb::Config>"
18
+
19
+ # @private
20
+ def mandatory(*, **)
21
+ super
22
+ rescue SuperConfig::MissingEnvironmentVariable => error
23
+ raise MissingEnvironmentVariable, error.message
24
+ end
25
+
26
+ # @private
27
+ def property(*, **, &)
28
+ super
29
+ rescue SuperConfig::MissingCallable
30
+ raise MissingCallable,
31
+ "arg[1] must respond to #call or a block must be provided"
32
+ end
33
+ end
34
+ end
@@ -1 +1,6 @@
1
- HTTP_METHODS = %i[GET POST PUT PATCH DELETE HEAD OPTIONS].freeze
1
+ module Lennarb
2
+ RACK_LENNA_APP = "lennarb.app"
3
+ ENV_NAMES = %w[LENNA_ENV APP_ENV RACK_ENV]
4
+ HTTP_METHODS = %i[GET POST PUT PATCH DELETE HEAD OPTIONS]
5
+ CONTENT_TYPE = {HTML: "text/html", TEXT: "text/plain", JSON: "application/json"}
6
+ end
@@ -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,9 +1,8 @@
1
- class Lennarb
1
+ module Lennarb
2
2
  class Request < Rack::Request
3
3
  # The environment variables of the request
4
4
  #
5
5
  # @returns [Hash]
6
- #
7
6
  attr_reader :env
8
7
 
9
8
  # Initialize the request object
@@ -15,71 +14,240 @@ class Lennarb
15
14
  #
16
15
  def initialize(env, route_params = {})
17
16
  super(env)
18
- @route_params = route_params
17
+ @route_params = route_params || {}
19
18
  end
20
19
 
21
- # Get the request body
20
+ # Get the request parameters merged with route parameters
22
21
  #
23
- # @returns [String]
22
+ # @returns [Hash]
24
23
  #
25
- 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
26
27
 
27
- # Get the request path
28
+ # Get the request path without query string
28
29
  #
29
30
  # @returns [String]
30
31
  #
31
- def path = @path ||= super.split("?").first
32
+ def path
33
+ @path ||= super.split("?").first
34
+ end
32
35
 
33
36
  # Read the body of the request
34
37
  #
35
38
  # @returns [String]
36
39
  #
37
- def body = @body ||= super.read
40
+ def body
41
+ @body ||= super.read
42
+ end
38
43
 
39
44
  # Get the query parameters
40
45
  #
41
46
  # @returns [Hash]
42
47
  #
43
48
  def query_params
44
- @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]
45
69
  end
46
70
 
47
71
  # Get the headers of the request
48
72
  #
73
+ # @returns [Hash]
74
+ #
49
75
  def headers
50
76
  @headers ||= env.select { |key, _| key.start_with?("HTTP_") }
51
77
  end
52
78
 
53
- def ip = ip_address
79
+ # Get the client IP address
80
+ #
81
+ # @returns [String]
82
+ #
83
+ def ip
84
+ ip_address
85
+ end
54
86
 
55
- def secure? = scheme == "https"
87
+ # Check if the request is secure (HTTPS)
88
+ #
89
+ # @returns [Boolean]
90
+ #
91
+ def secure?
92
+ scheme == "https"
93
+ end
56
94
 
57
- def user_agent = headers["HTTP_USER_AGENT"]
95
+ # Shorthand methods for common headers
58
96
 
59
- def accept = headers["HTTP_ACCEPT"]
97
+ # Get the user agent
98
+ #
99
+ # @returns [String, nil]
100
+ #
101
+ def user_agent
102
+ env["HTTP_USER_AGENT"]
103
+ end
60
104
 
61
- def referer = headers["HTTP_REFERER"]
105
+ # Get the accept header
106
+ #
107
+ # @returns [String, nil]
108
+ #
109
+ def accept
110
+ env["HTTP_ACCEPT"]
111
+ end
62
112
 
63
- def host = headers["HTTP_HOST"]
113
+ # Get the referer header
114
+ #
115
+ # @returns [String, nil]
116
+ #
117
+ def referer
118
+ env["HTTP_REFERER"]
119
+ end
64
120
 
65
- def content_length = headers["HTTP_CONTENT_LENGTH"]
121
+ # Get the host header
122
+ #
123
+ # @returns [String, nil]
124
+ #
125
+ def host
126
+ env["HTTP_HOST"]
127
+ end
66
128
 
67
- def content_type = headers["HTTP_CONTENT_TYPE"]
129
+ # Get the content length header
130
+ #
131
+ # @returns [String, nil]
132
+ #
133
+ def content_length
134
+ env["HTTP_CONTENT_LENGTH"]
135
+ end
68
136
 
69
- def xhr? = headers["HTTP_X_REQUESTED_WITH"]&.casecmp("XMLHttpRequest")&.zero?
137
+ # Get the content type header
138
+ #
139
+ # @returns [String, nil]
140
+ #
141
+ def content_type
142
+ env["HTTP_CONTENT_TYPE"]
143
+ end
70
144
 
71
- def []=(key, value)
72
- env[key] = value
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
73
151
  end
74
152
 
75
- def [](key)
76
- env[key]
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"
77
241
  end
78
242
 
79
243
  private
80
244
 
245
+ # Get the client IP address
246
+ #
247
+ # @returns [String]
248
+ #
81
249
  def ip_address
82
- forwarded_for = headers["HTTP_X_FORWARDED_FOR"]
250
+ forwarded_for = env["HTTP_X_FORWARDED_FOR"]
83
251
  forwarded_for ? forwarded_for.split(",").first.strip : env["REMOTE_ADDR"]
84
252
  end
85
253
  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,4 +1,4 @@
1
- class Lennarb
1
+ module Lennarb
2
2
  class Response
3
3
  # @!attribute [rw] status
4
4
  # @returns [Integer]
@@ -31,13 +31,12 @@ class Lennarb
31
31
  CONTENT_LENGTH = "content-length"
32
32
  private_constant :CONTENT_LENGTH
33
33
 
34
- ContentType = {HTML: "text/html", TEXT: "text/plain", JSON: "application/json"}.freeze
35
34
  # Initialize the response object
36
35
  #
37
36
  # @returns [Response]
38
37
  #
39
38
  def initialize
40
- @status = 404
39
+ @status = 200
41
40
  @headers = {}
42
41
  @body = []
43
42
  @length = 0
@@ -84,7 +83,7 @@ class Lennarb
84
83
  # @returns [String] str
85
84
  #
86
85
  def text(str)
87
- @headers[CONTENT_TYPE] = ContentType[:TEXT]
86
+ @headers[CONTENT_TYPE] = Lennarb::CONTENT_TYPE[:TEXT]
88
87
  write(str)
89
88
  end
90
89
 
@@ -95,7 +94,7 @@ class Lennarb
95
94
  # @returns [String] str
96
95
  #
97
96
  def html(str)
98
- @headers[CONTENT_TYPE] = ContentType[:HTML]
97
+ @headers[CONTENT_TYPE] = Lennarb::CONTENT_TYPE[:HTML]
99
98
  write(str)
100
99
  end
101
100
 
@@ -106,8 +105,13 @@ class Lennarb
106
105
  # @returns [String] str
107
106
  #
108
107
  def json(str)
109
- @headers[CONTENT_TYPE] = ContentType[:JSON]
110
- 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}")
111
115
  end
112
116
 
113
117
  # Redirect the response
@@ -1,5 +1,6 @@
1
- class Lennarb
1
+ module Lennarb
2
2
  class RouteNode
3
+ DuplicateRouteError = Class.new(StandardError)
3
4
  attr_accessor :static_children, :dynamic_children, :blocks, :param_key
4
5
 
5
6
  def initialize
@@ -9,6 +10,14 @@ class Lennarb
9
10
  @dynamic_children = {}
10
11
  end
11
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
+ #
12
21
  def add_route(parts, http_method, block)
13
22
  current_node = self
14
23
 
@@ -28,6 +37,14 @@ class Lennarb
28
37
  current_node.blocks[http_method] = block
29
38
  end
30
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
+ #
31
48
  def match_route(parts, http_method, params: {})
32
49
  if parts.empty?
33
50
  return [blocks[http_method], params] if blocks[http_method]
@@ -52,10 +69,35 @@ class Lennarb
52
69
  [nil, nil]
53
70
  end
54
71
 
72
+ # Merge another route node into this one.
73
+ #
74
+ # @param other [RouteNode] The other route node.
75
+ #
76
+ # @returns [void|DuplicateRouteError]
77
+ #
55
78
  def merge!(other)
56
- static_children.merge!(other.static_children)
57
- dynamic_children.merge!(other.dynamic_children)
58
- 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
59
101
  end
60
102
  end
61
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,5 +1,3 @@
1
- class Lennarb
2
- VERSION = "1.4.0"
3
-
4
- public_constant :VERSION
1
+ module Lennarb
2
+ VERSION = "1.4.1"
5
3
  end
data/lib/lennarb.rb CHANGED
@@ -1,72 +1,20 @@
1
1
  # Core extensions
2
2
  #
3
- require "pathname"
4
- require "rack"
5
3
  require "bundler"
6
-
7
- require_relative "lennarb/request"
8
- require_relative "lennarb/response"
9
- require_relative "lennarb/route_node"
10
- require_relative "lennarb/version"
11
- require_relative "lennarb/constansts"
12
-
13
- class Lennarb
14
- class LennarbError < StandardError; end
15
-
16
- def initialize
17
- @_mutex ||= Mutex.new
18
- yield self if block_given?
19
- end
20
-
21
- HTTP_METHODS.each do |http_method|
22
- define_method(http_method.downcase) do |path, &block|
23
- add_route(path, http_method, block)
24
- end
25
- end
26
-
27
- def root
28
- @root ||= RouteNode.new
29
- end
30
-
31
- def app
32
- @app ||= begin
33
- request_handler = ->(env) { process_request(env) }
34
-
35
- Rack::Builder.app do
36
- run request_handler
37
- end
38
- end
39
- end
40
-
41
- def initializer!
42
- Bundler.require(:default, ENV["LENNA_ENV"] || "development")
43
-
44
- root.freeze
45
- app.freeze
46
- end
47
-
48
- def call(env) = @_mutex.synchronize { app.call(env) }
49
-
50
- def add_route(path, http_method, block)
51
- parts = path.split("/").reject(&:empty?)
52
- root.add_route(parts, http_method, block)
53
- end
54
-
55
- private def process_request(env)
56
- http_method = env[Rack::REQUEST_METHOD].to_sym
57
- parts = env[Rack::PATH_INFO].split("/").reject(&:empty?)
58
-
59
- block, params = root.match_route(parts, http_method)
60
- return [404, {"content-type" => Response::ContentType[:TEXT]}, ["Not Found"]] unless block
61
-
62
- res = Response.new
63
- req = Request.new(env, params)
64
-
65
- catch(:halt) do
66
- instance_exec(req, res, &block)
67
- res.finish
68
- end
69
- rescue => e
70
- [500, {"content-type" => Response::ContentType[:TEXT]}, ["Internal Server Error - #{e.message}"]]
71
- end
4
+ require "rack"
5
+ require "json"
6
+ require "pathname"
7
+ require "superconfig"
8
+
9
+ module Lennarb
10
+ require_relative "lennarb/constansts"
11
+ require_relative "lennarb/environment"
12
+ require_relative "lennarb/version"
13
+ require_relative "lennarb/request_handler"
14
+ require_relative "lennarb/request"
15
+ require_relative "lennarb/response"
16
+ require_relative "lennarb/route_node"
17
+ require_relative "lennarb/routes"
18
+ require_relative "lennarb/config"
19
+ require_relative "lennarb/app"
72
20
  end
data/readme.md CHANGED
@@ -7,10 +7,11 @@
7
7
 
8
8
  A lightweight, fast, and modular web framework for Ruby based on Rack. The **Lennarb** supports Ruby (MRI) 3.4+
9
9
 
10
- [![Tests](https://github.com/aristotelesbr/lennarb/workflows/rubyby-tests/badge.svg)](https://github.com/aristotelesbr/lennarb)
10
+ [![Test](https://github.com/aristotelesbr/lennarb/actions/workflows/test.yaml/badge.svg)](https://github.com/aristotelesbr/lennarb/actions/workflows/test.yaml)
11
11
  [![Gem](https://img.shields.io/gem/v/lennarb.svg)](https://rubygems.org/gems/lennarb)
12
12
  [![Gem](https://img.shields.io/gem/dt/lennarb.svg)](https://rubygems.org/gems/lennarb)
13
13
  [![MIT License](https://img.shields.io/:License-MIT-blue.svg)](https://tldrlegal.com/license/mit-license)
14
+
14
15
  </div>
15
16
 
16
17
  ## Basic Usage
@@ -18,10 +19,17 @@ A lightweight, fast, and modular web framework for Ruby based on Rack. The **Len
18
19
  ```ruby
19
20
  require "lennarb"
20
21
 
21
- Lennarb.new do |router|
22
- router.get("/hello/:name") do |req, res|
23
- name = req.params[:name]
24
- res.html("Hello, #{name}!")
22
+ Lennarb::App.new do
23
+ config do
24
+ optional :env, string, "prodcution"
25
+ optional :port, int, 9292
26
+ end
27
+
28
+ routes do
29
+ get("/hello/:name") do |req, res|
30
+ name = req.params[:name]
31
+ res.html("Hello, #{name}!")
32
+ end
25
33
  end
26
34
  end
27
35
  ```
@@ -43,7 +51,7 @@ See all [graphs](https://github.com/aristotelesbr/lennarb/blob/main/benchmark)
43
51
 
44
52
  This table ranks the routers by the number of requests they can process per second. Higher numbers indicate better performance.
45
53
 
46
- Plese see [Performance](https://aristotelesbr.github.io/lennarb/guides/performance/index.html) for more information.
54
+ Please see [Performance](https://aristotelesbr.github.io/lennarb/guides/performance/index.html) for more information.
47
55
 
48
56
  ## Usage
49
57
 
@@ -51,10 +59,10 @@ Plese see [Performance](https://aristotelesbr.github.io/lennarb/guides/performan
51
59
 
52
60
  - [Performance](https://aristotelesbr.github.io/lennarb/guides/performance/index.html) - The **Lennarb** is very fast. The following benchmarks were performed on a MacBook Pro (Retina, 13-inch, Early 2013) with 2,7 GHz Intel Core i7 and 8 GB 1867 MHz DDR3. Based on [jeremyevans/r10k](https://github.com/jeremyevans/r10k) using the following [template build](static/r10k/build/lennarb.rb).
53
61
 
54
- - [Plugin](https://aristotelesbr.github.io/lennarb/guides/plugin/index.html) - You can create your plugins to extend the functionality of the framework.
62
+ - [Request]() - TODO
55
63
 
56
64
  - [Response](https://aristotelesbr.github.io/lennarb/guides/response/index.html) - This is the response guide.
57
- The `res` object is used to send a response to the client. The Lennarb use a custom response object to send responses to the client. The `res` object is an instance of `Lennarb::Response`.
65
+ The `res` object is used to send a response to the client. The Lennarb use a custom response object to send responses to the client. The `res` object is an instance of `Lennarb::Response`.
58
66
 
59
67
  ### Developer Certificate of Origin
60
68
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lennarb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aristóteles Coutinho
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-02-09 00:00:00.000000000 Z
10
+ date: 2025-02-26 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bigdecimal
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '3.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: superconfig
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: bundler
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -107,6 +121,20 @@ dependencies:
107
121
  - - ">="
108
122
  - !ruby/object:Gem::Version
109
123
  version: '0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: minitest-utils
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
110
138
  - !ruby/object:Gem::Dependency
111
139
  name: rack-test
112
140
  requirement: !ruby/object:Gem::Requirement
@@ -191,6 +219,20 @@ dependencies:
191
219
  - - ">="
192
220
  - !ruby/object:Gem::Version
193
221
  version: '0'
222
+ - !ruby/object:Gem::Dependency
223
+ name: debug
224
+ requirement: !ruby/object:Gem::Requirement
225
+ requirements:
226
+ - - ">="
227
+ - !ruby/object:Gem::Version
228
+ version: '0'
229
+ type: :development
230
+ prerelease: false
231
+ version_requirements: !ruby/object:Gem::Requirement
232
+ requirements:
233
+ - - ">="
234
+ - !ruby/object:Gem::Version
235
+ version: '0'
194
236
  executables:
195
237
  - lenna
196
238
  extensions: []
@@ -223,10 +265,15 @@ files:
223
265
  - guides/response/readme.md
224
266
  - lennarb.gemspec
225
267
  - lib/lennarb.rb
268
+ - lib/lennarb/app.rb
269
+ - lib/lennarb/config.rb
226
270
  - lib/lennarb/constansts.rb
271
+ - lib/lennarb/environment.rb
227
272
  - lib/lennarb/request.rb
273
+ - lib/lennarb/request_handler.rb
228
274
  - lib/lennarb/response.rb
229
275
  - lib/lennarb/route_node.rb
276
+ - lib/lennarb/routes.rb
230
277
  - lib/lennarb/version.rb
231
278
  - license.md
232
279
  - logo/lennarb.png