lennarb 1.5.0 → 1.5.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 +4 -4
- data/.gitignore +1 -1
- data/README.pt-BR.md +9 -18
- data/benchmark/hot_path.rb +62 -0
- data/changelog.md +143 -8
- data/guides/getting-started/readme.md +20 -25
- data/guides/performance/readme.md +4 -0
- data/lennarb.gemspec +11 -1
- data/lib/lennarb/app.rb +44 -3
- data/lib/lennarb/environment.rb +22 -6
- data/lib/lennarb/errors.rb +7 -5
- data/lib/lennarb/middleware/request_logger.rb +30 -12
- data/lib/lennarb/middleware_stack.rb +4 -4
- data/lib/lennarb/parameter_filter.rb +38 -25
- data/lib/lennarb/request.rb +2 -10
- data/lib/lennarb/request_handler.rb +46 -11
- data/lib/lennarb/response.rb +19 -13
- data/lib/lennarb/route_node.rb +4 -4
- data/lib/lennarb/routes.rb +52 -2
- data/lib/lennarb/version.rb +1 -1
- data/readme.md +23 -35
- metadata +22 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0b33c13f9c42ddb2d9593374c25fd6efca486c37b8cba0699031e01c61b2f408
|
|
4
|
+
data.tar.gz: 9e995281d4c034b933d62fc24e480ea160ee485b566f3bde0c648272d25ece5d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 95b23a6fa14cf0f55ccd91f75c2c094b97c9929652c099cb347a8996ba30b90cbc9da0d074707af14f7bec57d9c1420f8ed0256e3dcbaa1fee7984ed8ea2f268
|
|
7
|
+
data.tar.gz: ebb7666065e53c69dee117bb9167f22bf0de2e79ea06a4018240ca991edf1c38ff582921c09d523df48c790e75e543aa3d62e64bcaac8225a1660f24d58dd467
|
data/.gitignore
CHANGED
data/README.pt-BR.md
CHANGED
|
@@ -33,14 +33,14 @@ Um framework web leve, rápido e modular para Ruby baseado em Rack. **Lennarb**
|
|
|
33
33
|
- Opções de configuração flexíveis
|
|
34
34
|
- Duas opções de implementação:
|
|
35
35
|
- `Lennarb::App`: Abordagem minimalista para controle completo
|
|
36
|
-
- `Lennarb::
|
|
36
|
+
- `Lennarb::Base`: Versão estendida, para montar várias aplicações
|
|
37
37
|
|
|
38
38
|
## Opções de Implementação
|
|
39
39
|
|
|
40
40
|
Lennarb oferece duas abordagens de implementação para atender diferentes necessidades:
|
|
41
41
|
|
|
42
42
|
- **Lennarb::App**: Abordagem minimalista para controle completo
|
|
43
|
-
- **Lennarb::
|
|
43
|
+
- **Lennarb::Base**: Versão estendida, para montar várias aplicações
|
|
44
44
|
|
|
45
45
|
Consulte a [documentação](https://aristotelesbr.github.io/lennarb/guides/getting-started/index) para detalhes sobre cada implementação.
|
|
46
46
|
|
|
@@ -63,26 +63,17 @@ gem install lennarb
|
|
|
63
63
|
```ruby
|
|
64
64
|
require "lennarb"
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
optional :port,
|
|
70
|
-
optional :env, string, "development"
|
|
66
|
+
class App < Lennarb::App
|
|
67
|
+
config do
|
|
68
|
+
mandatory :database_url, string
|
|
69
|
+
optional :port, int, 9292
|
|
71
70
|
end
|
|
72
71
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
res.html("<h1>Bem-vindo ao Lennarb!</h1>")
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
get("/hello/:name") do |req, res|
|
|
79
|
-
name = req.params[:name]
|
|
80
|
-
res.html("Olá, #{name}!")
|
|
81
|
-
end
|
|
82
|
-
end
|
|
72
|
+
get("/") { |req, res| res.html("<h1>Bem-vindo ao Lennarb!</h1>") }
|
|
73
|
+
get("/hello/:name") { |req, res| res.html("Olá, #{req.params[:name]}!") }
|
|
83
74
|
end
|
|
84
75
|
|
|
85
|
-
|
|
76
|
+
run App.new.initialize!
|
|
86
77
|
run app # Em config.ru
|
|
87
78
|
```
|
|
88
79
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Measures the per-request cost of the Lennarb request path.
|
|
2
|
+
#
|
|
3
|
+
# Run with: bundle exec ruby benchmark/hot_path.rb
|
|
4
|
+
#
|
|
5
|
+
# Reports requests per second and objects allocated per request for a static
|
|
6
|
+
# and a dynamic route, plus the isolated cost of route matching and context
|
|
7
|
+
# creation, so a regression can be attributed rather than guessed at.
|
|
8
|
+
|
|
9
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
10
|
+
|
|
11
|
+
require "lennarb"
|
|
12
|
+
require "benchmark"
|
|
13
|
+
require "stringio"
|
|
14
|
+
|
|
15
|
+
class BenchApp < Lennarb::App
|
|
16
|
+
get("/") { |req, res| res.text("ok") }
|
|
17
|
+
get("/users/:id/posts/:post_id") { |req, res| res.text(req.params[:id]) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def rack_env(path)
|
|
21
|
+
{
|
|
22
|
+
"REQUEST_METHOD" => "GET",
|
|
23
|
+
"PATH_INFO" => path,
|
|
24
|
+
"QUERY_STRING" => "",
|
|
25
|
+
"SERVER_NAME" => "example.org",
|
|
26
|
+
"SERVER_PORT" => "80",
|
|
27
|
+
"rack.url_scheme" => "http",
|
|
28
|
+
"rack.input" => StringIO.new
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def measure(label, iterations)
|
|
33
|
+
elapsed = Benchmark.realtime { iterations.times { yield } }
|
|
34
|
+
puts format("%-22s %9d req/s %6.2f us/req", label, iterations / elapsed, elapsed / iterations * 1e6)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def allocations(iterations)
|
|
38
|
+
GC.start
|
|
39
|
+
before = GC.stat(:total_allocated_objects)
|
|
40
|
+
iterations.times { yield }
|
|
41
|
+
(GC.stat(:total_allocated_objects) - before) / iterations.to_f
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
app = BenchApp.new.initialize!
|
|
45
|
+
handler = Lennarb::RequestHandler.new(app)
|
|
46
|
+
|
|
47
|
+
ITERATIONS = 100_000
|
|
48
|
+
static = rack_env("/")
|
|
49
|
+
dynamic = rack_env("/users/42/posts/7")
|
|
50
|
+
|
|
51
|
+
2_000.times { handler.call(static.dup) }
|
|
52
|
+
|
|
53
|
+
puts "ruby #{RUBY_VERSION} (#{RUBY_PLATFORM}), lennarb #{Lennarb::VERSION}"
|
|
54
|
+
puts
|
|
55
|
+
|
|
56
|
+
measure("static route", ITERATIONS) { handler.call(static.dup) }
|
|
57
|
+
measure("dynamic route", ITERATIONS) { handler.call(dynamic.dup) }
|
|
58
|
+
measure("match_route only", ITERATIONS) { app.routes.match_route(["users", "42", "posts", "7"], :GET) }
|
|
59
|
+
measure("create_context only", ITERATIONS) { handler.send(:create_context) }
|
|
60
|
+
|
|
61
|
+
puts
|
|
62
|
+
puts format("objects per request %6.1f", allocations(10_000) { handler.call(dynamic.dup) })
|
data/changelog.md
CHANGED
|
@@ -7,11 +7,146 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.5.1] - 2026-09-09
|
|
11
|
+
|
|
12
|
+
A patch release. No public API was added; everything here is a defect fix.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- `Lennarb::Request#content_type` and `#content_length` now read the Rack
|
|
17
|
+
`CONTENT_TYPE` and `CONTENT_LENGTH` headers instead of the `HTTP_`-prefixed
|
|
18
|
+
names. `Request#json?` was never true and `#json_body` never parsed on a real
|
|
19
|
+
HTTP request.
|
|
20
|
+
- Exceptions raised inside a route handler no longer escape to Rack. They are
|
|
21
|
+
logged and answered with a 500, except in development, where they are
|
|
22
|
+
re-raised so `Rack::ShowExceptions` can render the backtrace.
|
|
23
|
+
- `app` inside a route handler no longer raises `SystemStackError`. The context
|
|
24
|
+
object defined `app` with a block whose `self` was rebound to the context, so
|
|
25
|
+
the call recursed into itself. Nothing had exercised it.
|
|
26
|
+
- `App#initialize!` no longer freezes the class-level routes. Each booted
|
|
27
|
+
instance holds its own deep, frozen snapshot, so registering a route after the
|
|
28
|
+
first boot works again -- it previously raised `RoutesFrozenError` and broke
|
|
29
|
+
test suites and development reload.
|
|
30
|
+
- `Routes#freeze` now freezes the whole route tree rather than only its root.
|
|
31
|
+
- Environment-scoped configuration at the class level -- `config(:production) do
|
|
32
|
+
... end` inside a `Lennarb::App` subclass -- no longer raises `NameError`. The
|
|
33
|
+
class-level `config` referenced an `env` that only exists on instances, so only
|
|
34
|
+
the unscoped form worked. The instance and `Lennarb::Base` forms were
|
|
35
|
+
unaffected.
|
|
36
|
+
- The test suite runs green again. minitest 6 extracted `Minitest::Mock` and
|
|
37
|
+
`Object#stub` into the separate `minitest-mock` gem, which is now a
|
|
38
|
+
development dependency. Both are pinned to their major, so a future major
|
|
39
|
+
bump has to be a deliberate change rather than the result of a fresh resolve.
|
|
40
|
+
- Tests no longer leak `LENNA_ENV`/`APP_ENV`/`RACK_ENV` between each other,
|
|
41
|
+
which made results depend on minitest's random seed.
|
|
42
|
+
- `Request#host` no longer shadows `Rack::Request#host` with a worse version. It
|
|
43
|
+
returned the raw `Host` header, so it kept the port (`example.com:3000`) and
|
|
44
|
+
returned `nil` when there was no `Host` header instead of falling back to
|
|
45
|
+
`SERVER_NAME`. The override is removed and Rack's implementation applies.
|
|
46
|
+
- `Lennarb::Environment` equality is fixed in three ways. Two environments with
|
|
47
|
+
the same name were not `==` to each other; `equal?` was aliased to `==`, which
|
|
48
|
+
broke Ruby's object-identity contract in both directions (`env.equal?(env)` was
|
|
49
|
+
false and `env.equal?(:test)` was true); and `eql?` was inconsistent with
|
|
50
|
+
`hash`, so an environment did not work as a Hash key. `equal?` is no longer
|
|
51
|
+
overridden, and `eql?`/`hash` are now consistent.
|
|
52
|
+
- 22 YARD `@retrn` typos corrected to `@return` in `route_node.rb`,
|
|
53
|
+
`middleware_stack.rb`, `environment.rb` and `response.rb`. The tag was not
|
|
54
|
+
recognised, so those return types were missing from the published
|
|
55
|
+
documentation.
|
|
56
|
+
- The comments on `DuplicateRouteError`, `MissingEnvironmentVariable`,
|
|
57
|
+
`MissingCallable` and `RoutesFrozenError` all claimed the error was raised
|
|
58
|
+
when the app is initialized more than once. Each now describes what it is.
|
|
59
|
+
- **Route parameters are now URL-decoded.** `/u/John%20Doe` used to yield
|
|
60
|
+
`"John%20Doe"`; it now yields `"John Doe"`, and a percent-encoded slash stays
|
|
61
|
+
inside its segment instead of splitting the path. **If your application worked
|
|
62
|
+
around this by decoding route parameters itself, remove that workaround or you
|
|
63
|
+
will decode twice.** Segments are decoded with `Rack::Utils.unescape_path`, so
|
|
64
|
+
`+` is left alone, as it should be in a path.
|
|
65
|
+
- `.gitignore` now matches `.minitestfailures`.
|
|
66
|
+
|
|
67
|
+
### Security
|
|
68
|
+
|
|
69
|
+
- **`ParameterFilter` matching is now case-insensitive.** It was built with a
|
|
70
|
+
case-sensitive `Regexp.union`, and `RequestLogger` is in the default
|
|
71
|
+
middleware stack in every environment and logs `request.params` at `info`. A
|
|
72
|
+
form field named `Password`, `Token` or `API_KEY` was written to the log in
|
|
73
|
+
cleartext, and from there to journald, CloudWatch or Datadog.
|
|
74
|
+
- **`Regexp` filters passed to `ParameterFilter` now work.** The docstring
|
|
75
|
+
documented them, but `filters.map(&:to_s)` stringified them and
|
|
76
|
+
`Regexp.union` escaped the result, so the filter matched only the literal text
|
|
77
|
+
`(?i-mx:password)`. Passing a Regexp to harden filtering disabled it entirely,
|
|
78
|
+
including for keys that had previously been filtered.
|
|
79
|
+
- **The default filter list now covers** `auth`, `credit`, `card_number`, `cvn`,
|
|
80
|
+
`iban`, `api`, `pin` and `session_id`. Previously `cvv` was filtered while
|
|
81
|
+
`card_number` was not, protecting the CVV and not the card number it guards.
|
|
82
|
+
- **`ParameterFilter#filter` no longer modifies the parameters it is given.**
|
|
83
|
+
`params.dup` is shallow and nested values were assigned in place, so an app
|
|
84
|
+
calling `filter(req.params)` for an error report found its own params replaced
|
|
85
|
+
by `"[FILTERED]"`.
|
|
86
|
+
- **`RequestLogger` now uses the logger configured on the app handling the
|
|
87
|
+
request**, resolved from the Rack env. It read `Lennarb::App.app.config.logger`,
|
|
88
|
+
and `App.app` is never assigned anywhere, so a configured logger was
|
|
89
|
+
unreachable and request lines always went to the process's stderr. Configuring
|
|
90
|
+
a redacting or file-scoped logger was therefore not an available mitigation.
|
|
91
|
+
- **The request path is escaped before being logged.** Control characters from
|
|
92
|
+
the client could otherwise forge log lines. Parameter values were already safe
|
|
93
|
+
because they go through `inspect`.
|
|
94
|
+
- **Booting without `LENNA_ENV`, `APP_ENV` or `RACK_ENV` now logs a warning.**
|
|
95
|
+
The environment defaults to `development`, which enables
|
|
96
|
+
`Rack::ShowExceptions`; a deployment that forgot to export the variable served
|
|
97
|
+
the entire Rack environment, `Authorization` and `Cookie` included, to anyone
|
|
98
|
+
who could trigger a 500. Changing the default itself is a breaking change and
|
|
99
|
+
is deferred to 1.6.0.
|
|
100
|
+
- **`Response#json` no longer echoes the exception message to the client**, which
|
|
101
|
+
could carry `inspect` output of the object being serialized, and now rescues
|
|
102
|
+
`JSON::JSONError` rather than `JSON::GeneratorError`: a circular or over-deep
|
|
103
|
+
object graph raises `JSON::NestingError`, which descends from `ParserError` and
|
|
104
|
+
escaped the rescue entirely.
|
|
105
|
+
|
|
106
|
+
### Changed
|
|
107
|
+
|
|
108
|
+
- `RequestHandler` compiles the route execution context once per application
|
|
109
|
+
class instead of building an object with a fresh singleton class on every
|
|
110
|
+
request. On ruby 3.4.1 (arm64-darwin23): static route 424,302 to 753,914
|
|
111
|
+
req/s, dynamic route 175,364 to 221,795 req/s, `create_context` 1.37us to
|
|
112
|
+
0.15us (24% to 3.3% of a request), 48 to 40 objects allocated per request.
|
|
113
|
+
The extra object over the 39 measured mid-release is the cost of decoding
|
|
114
|
+
route parameters, below.
|
|
115
|
+
- `App#routes` returns the instance's frozen snapshot after `initialize!`, so
|
|
116
|
+
`app.routes.equal?(App.routes)` is no longer true once the app is booted.
|
|
117
|
+
- Documentation now teaches subclassing `Lennarb::App` as the canonical form.
|
|
118
|
+
The previous quick start raised `NoMethodError`, and subclassing is the only
|
|
119
|
+
form isolated per application. The pt-BR quick start also called `configure`
|
|
120
|
+
(the method is `config`) and `mandary` (a typo for `mandatory`).
|
|
121
|
+
- Changelog no longer references `Lennarb::Application` or
|
|
122
|
+
`Lennarb::Routes::Mixin`, neither of which exists. The real APIs are
|
|
123
|
+
`Lennarb::Base` and `Lennarb::Base.mount`.
|
|
124
|
+
|
|
10
125
|
### Added
|
|
11
126
|
|
|
12
|
-
-
|
|
127
|
+
- `benchmark/hot_path.rb`, so the performance claims can be reproduced.
|
|
128
|
+
- `required_ruby_version = ">= 3.4"` in the gemspec. The code uses `it`, the
|
|
129
|
+
implicit block parameter introduced in Ruby 3.4, and the README has always
|
|
130
|
+
promised 3.4+, but no version constraint was declared. RubyGems would install
|
|
131
|
+
the gem on an older Ruby and the first `require` failed with a `SyntaxError`
|
|
132
|
+
rather than a clear resolution error. This has been missing since 0.1.0.
|
|
133
|
+
|
|
134
|
+
### Known limitations
|
|
135
|
+
|
|
136
|
+
- Two applications created with `Lennarb::App.new` without subclassing still
|
|
137
|
+
share the class's route definitions. Subclass to isolate them.
|
|
138
|
+
- Hooks and helpers are still stored per app class and are shared the same way.
|
|
139
|
+
Tracked in [#87](https://github.com/aristotelesbr/lennarb/issues/87); they are
|
|
140
|
+
meant to become an opt-in mechanism rather than machinery every application
|
|
141
|
+
carries.
|
|
142
|
+
|
|
143
|
+
## [1.5.0] - 2025-04-19
|
|
144
|
+
|
|
145
|
+
### Added
|
|
146
|
+
|
|
147
|
+
- Add `Lennarb::Base` class to be the base class of the "standard" implementation of the Lennarb framework, for mounting several applications behind one middleware stack.
|
|
13
148
|
- Add middleware support to Lennarb::App class.
|
|
14
|
-
- Add `middleware` support to
|
|
149
|
+
- Add `middleware` support to `Lennarb::Base` with default middlewares.
|
|
15
150
|
- Add files to centralize the errors of the project.
|
|
16
151
|
- Add CODE_OF_CONDUCT.md in English and Portuguese
|
|
17
152
|
- Add CONTRIBUTING.md in English and Portuguese
|
|
@@ -52,20 +187,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
52
187
|
- Add support to mount routes. Now, you can centralize the routes in a single file and mount them in the main application. Ex.
|
|
53
188
|
|
|
54
189
|
```rb
|
|
55
|
-
class
|
|
56
|
-
extend Lennarb::Routes::Mixin
|
|
57
|
-
|
|
190
|
+
class Posts < Lennarb::App
|
|
58
191
|
get '/posts' do |req, res|
|
|
59
192
|
res.html('Posts')
|
|
60
193
|
end
|
|
61
194
|
end
|
|
62
195
|
|
|
63
|
-
|
|
64
|
-
mount
|
|
196
|
+
class Application < Lennarb::Base
|
|
197
|
+
mount Posts, at: '/'
|
|
65
198
|
end
|
|
66
199
|
```
|
|
67
200
|
|
|
68
|
-
|
|
201
|
+
`Lennarb::Base.mount` registers a `Lennarb::App` subclass at a path, behind the
|
|
202
|
+
base application's middleware stack. Call it once per application you want to
|
|
203
|
+
mount.
|
|
69
204
|
|
|
70
205
|
- Add `Lennarb::Environment` module to manage the environment variables in the project. Now, the `Lennarb` class is the main class of the project.
|
|
71
206
|
- Add `Lennarb::Config` module to manage the configuration in the project. Now, the `Lennarb` class is the main class of the project.
|
|
@@ -27,17 +27,14 @@ Create a new file named `config.ru`:
|
|
|
27
27
|
```ruby
|
|
28
28
|
require 'lennarb'
|
|
29
29
|
|
|
30
|
-
MyApp
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
res.html('<h1>Welcome to Lennarb!</h1>')
|
|
35
|
-
end
|
|
30
|
+
class MyApp < Lennarb::App
|
|
31
|
+
get '/' do |req, res|
|
|
32
|
+
res.status = 200
|
|
33
|
+
res.html('<h1>Welcome to Lennarb!</h1>')
|
|
36
34
|
end
|
|
37
35
|
end
|
|
38
36
|
|
|
39
|
-
MyApp.initialize!
|
|
40
|
-
run MyApp
|
|
37
|
+
run MyApp.new.initialize!
|
|
41
38
|
```
|
|
42
39
|
|
|
43
40
|
Start the server:
|
|
@@ -86,16 +83,13 @@ end
|
|
|
86
83
|
Routes are defined using HTTP method helpers:
|
|
87
84
|
|
|
88
85
|
```ruby
|
|
89
|
-
Lennarb::App
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
user_id = req.params[:id]
|
|
97
|
-
res.json({ id: user_id })
|
|
98
|
-
end
|
|
86
|
+
class MyApp < Lennarb::App
|
|
87
|
+
get '/' do |req, res|
|
|
88
|
+
res.html('Home page')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
get '/users/:id' do |req, res|
|
|
92
|
+
res.json({id: req.params[:id]})
|
|
99
93
|
end
|
|
100
94
|
end
|
|
101
95
|
```
|
|
@@ -124,18 +118,19 @@ Lennarb is thread-safe by design:
|
|
|
124
118
|
### Initialization
|
|
125
119
|
|
|
126
120
|
```ruby
|
|
127
|
-
MyApp
|
|
128
|
-
# Define
|
|
129
|
-
|
|
121
|
+
class MyApp < Lennarb::App
|
|
122
|
+
# Define configuration
|
|
123
|
+
config do
|
|
130
124
|
end
|
|
131
125
|
|
|
132
|
-
# Define
|
|
133
|
-
|
|
126
|
+
# Define routes
|
|
127
|
+
get '/' do |req, res|
|
|
128
|
+
res.text('ok')
|
|
134
129
|
end
|
|
135
130
|
end
|
|
136
131
|
|
|
137
|
-
#
|
|
138
|
-
MyApp.initialize!
|
|
132
|
+
# Boot the application: it snapshots and freezes its routes
|
|
133
|
+
MyApp.new.initialize!
|
|
139
134
|
```
|
|
140
135
|
|
|
141
136
|
The `initialize!` method:
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Performance
|
|
2
2
|
|
|
3
|
+
> The comparison tables below were produced in 2024 on a 2013 MacBook Pro with
|
|
4
|
+
> Ruby 3.3.0. They are kept for historical reference. To measure the current
|
|
5
|
+
> request path on your own machine, run `bundle exec ruby benchmark/hot_path.rb`.
|
|
6
|
+
|
|
3
7
|
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. All tests are performed using the **Ruby 3.3.0**
|
|
4
8
|
|
|
5
9
|
## Benchmark results
|
data/lennarb.gemspec
CHANGED
|
@@ -24,6 +24,11 @@ Gem::Specification.new do |spec|
|
|
|
24
24
|
.reject { |f| f.match(%r{^(test|features)/}) }
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# lib/lennarb/routes.rb uses `it`, the implicit block parameter, which is
|
|
28
|
+
# Ruby 3.4+. Without this, RubyGems installs happily on an older Ruby and the
|
|
29
|
+
# first require fails with a SyntaxError instead of a clear resolution error.
|
|
30
|
+
spec.required_ruby_version = ">= 3.4"
|
|
31
|
+
|
|
27
32
|
spec.bindir = "exe"
|
|
28
33
|
spec.executables = ["lenna"]
|
|
29
34
|
spec.require_paths = ["lib"]
|
|
@@ -36,7 +41,12 @@ Gem::Specification.new do |spec|
|
|
|
36
41
|
spec.add_development_dependency "bundler"
|
|
37
42
|
spec.add_development_dependency "simplecov"
|
|
38
43
|
spec.add_development_dependency "simplecov-json"
|
|
39
|
-
|
|
44
|
+
# Pinned to the major: minitest 6 removed Minitest::Mock and Object#stub, which
|
|
45
|
+
# silently broke CI for five months because nothing pins and gems.locked is
|
|
46
|
+
# gitignored. A major bump must be a deliberate change, not a fresh resolve.
|
|
47
|
+
spec.add_development_dependency "minitest", "~> 6.0"
|
|
48
|
+
# minitest 6 extracted Minitest::Mock and Object#stub into their own gem.
|
|
49
|
+
spec.add_development_dependency "minitest-mock", "~> 5.27"
|
|
40
50
|
spec.add_development_dependency "minitest-utils"
|
|
41
51
|
spec.add_development_dependency "rack-test"
|
|
42
52
|
spec.add_development_dependency "rake"
|
data/lib/lennarb/app.rb
CHANGED
|
@@ -75,12 +75,24 @@ module Lennarb
|
|
|
75
75
|
@config ||= Config.new(self)
|
|
76
76
|
|
|
77
77
|
if block_given?
|
|
78
|
-
write = envs.empty? || envs.map(&:to_sym).include?(
|
|
78
|
+
write = envs.empty? || envs.map(&:to_sym).include?(compute_env_name)
|
|
79
79
|
@config.instance_eval(&) if write
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
@config
|
|
83
83
|
end
|
|
84
|
+
|
|
85
|
+
# The environment name computed from ENV.
|
|
86
|
+
#
|
|
87
|
+
# A class has no env of its own, so environment-scoped config blocks
|
|
88
|
+
# resolve it here.
|
|
89
|
+
#
|
|
90
|
+
# @return [Symbol] Environment name
|
|
91
|
+
# @api private
|
|
92
|
+
private def compute_env_name
|
|
93
|
+
name = ENV_NAMES.map { |var| ENV[var] }.compact.first.to_s
|
|
94
|
+
(name.empty? ? "development" : name).to_sym
|
|
95
|
+
end
|
|
84
96
|
end
|
|
85
97
|
|
|
86
98
|
# Instance methods
|
|
@@ -179,7 +191,9 @@ module Lennarb
|
|
|
179
191
|
self.class.instance_exec(&block)
|
|
180
192
|
end
|
|
181
193
|
|
|
182
|
-
|
|
194
|
+
# Before boot, route definitions go to the class. After boot, this
|
|
195
|
+
# instance serves from its own frozen snapshot.
|
|
196
|
+
@routes || self.class.routes
|
|
183
197
|
end
|
|
184
198
|
|
|
185
199
|
# Get/define configuration
|
|
@@ -205,8 +219,15 @@ module Lennarb
|
|
|
205
219
|
def initialize!
|
|
206
220
|
raise AlreadyInitializedError if @initialized
|
|
207
221
|
|
|
222
|
+
warn_about_defaulted_env
|
|
223
|
+
|
|
224
|
+
# Snapshot the class routes into this instance and freeze only the copy,
|
|
225
|
+
# so booting an app never freezes process-global class state.
|
|
226
|
+
@routes = Routes.new
|
|
227
|
+
@routes.merge!(self.class.routes)
|
|
228
|
+
@routes.freeze
|
|
229
|
+
|
|
208
230
|
@initialized = true
|
|
209
|
-
routes.freeze
|
|
210
231
|
self
|
|
211
232
|
end
|
|
212
233
|
|
|
@@ -242,6 +263,26 @@ module Lennarb
|
|
|
242
263
|
stack
|
|
243
264
|
end
|
|
244
265
|
|
|
266
|
+
# Warn when the environment was defaulted rather than chosen.
|
|
267
|
+
#
|
|
268
|
+
# Defaulting to development is a fail-open: development enables
|
|
269
|
+
# Rack::ShowExceptions, which renders the whole Rack environment -- the
|
|
270
|
+
# Authorization and Cookie headers included -- on any unhandled error. A
|
|
271
|
+
# deployment that simply forgot to export the variable would serve that to
|
|
272
|
+
# anyone able to trigger a 500.
|
|
273
|
+
#
|
|
274
|
+
# @return [void]
|
|
275
|
+
private def warn_about_defaulted_env
|
|
276
|
+
return if ENV_NAMES.any? { |name| ENV[name] }
|
|
277
|
+
|
|
278
|
+
config.logger.warn do
|
|
279
|
+
"No #{ENV_NAMES.join(", ")} is set, so the environment defaults to " \
|
|
280
|
+
"development. Rack::ShowExceptions is enabled and will render the " \
|
|
281
|
+
"full Rack environment, including Authorization and Cookie headers, " \
|
|
282
|
+
"on any unhandled error. Set one of these variables in production."
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
245
286
|
# Compute environment from ENV variables
|
|
246
287
|
#
|
|
247
288
|
# @return [String] Environment name
|
data/lib/lennarb/environment.rb
CHANGED
|
@@ -40,23 +40,39 @@ module Lennarb
|
|
|
40
40
|
|
|
41
41
|
# Implements equality for the environment.
|
|
42
42
|
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
# Compares by name, so an environment equals its name as a Symbol or a
|
|
44
|
+
# String, and equals another environment with the same name.
|
|
45
|
+
#
|
|
46
|
+
# `equal?` is deliberately not aliased here: in Ruby it means object
|
|
47
|
+
# identity and overriding it broke that contract in both directions.
|
|
48
|
+
#
|
|
49
|
+
def ==(other) = name == other || name.to_s == other.to_s
|
|
46
50
|
alias_method :===, :==
|
|
47
51
|
|
|
52
|
+
# Value equality, kept consistent with {#hash} so an environment behaves as
|
|
53
|
+
# a Hash key.
|
|
54
|
+
#
|
|
55
|
+
# @param other [Object]
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
#
|
|
58
|
+
def eql?(other) = other.is_a?(Environment) && name == other.name
|
|
59
|
+
|
|
60
|
+
# @return [Integer]
|
|
61
|
+
#
|
|
62
|
+
def hash = name.hash
|
|
63
|
+
|
|
48
64
|
# Returns the name of the environment as a symbol.
|
|
49
|
-
# @
|
|
65
|
+
# @return [Symbol]
|
|
50
66
|
#
|
|
51
67
|
def to_sym = name
|
|
52
68
|
|
|
53
69
|
# Returns the name of the environment as a string.
|
|
54
|
-
# @
|
|
70
|
+
# @return [String]
|
|
55
71
|
#
|
|
56
72
|
def to_s = name.to_s
|
|
57
73
|
|
|
58
74
|
# Returns the name of the environment as a string.
|
|
59
|
-
# @
|
|
75
|
+
# @return [String]
|
|
60
76
|
def inspect = to_s.inspect
|
|
61
77
|
|
|
62
78
|
# Yields a block if the environment is the same as the given environment.
|
data/lib/lennarb/errors.rb
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
module Lennarb
|
|
2
|
-
#
|
|
2
|
+
# Base class for errors raised by Lennarb.
|
|
3
3
|
#
|
|
4
4
|
Error = Class.new(StandardError)
|
|
5
|
-
#
|
|
5
|
+
# Raised when merging routes would define the same HTTP method twice for the
|
|
6
|
+
# same path.
|
|
6
7
|
#
|
|
7
8
|
DuplicateRouteError = Class.new(StandardError)
|
|
8
|
-
#
|
|
9
|
+
# Raised when a mandatory configuration value has no environment variable set.
|
|
9
10
|
#
|
|
10
11
|
MissingEnvironmentVariable = Class.new(StandardError)
|
|
11
|
-
#
|
|
12
|
+
# Raised when a configuration property is declared without something callable
|
|
13
|
+
# to compute it.
|
|
12
14
|
#
|
|
13
15
|
MissingCallable = Class.new(StandardError)
|
|
14
|
-
#
|
|
16
|
+
# Raised when a route is registered after the routes have been frozen.
|
|
15
17
|
#
|
|
16
18
|
RoutesFrozenError = Class.new(RuntimeError)
|
|
17
19
|
end
|
|
@@ -13,8 +13,17 @@ module Lennarb
|
|
|
13
13
|
@app = app
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
# Get logger
|
|
17
|
-
|
|
16
|
+
# Get the logger for this request.
|
|
17
|
+
#
|
|
18
|
+
# Resolved from the app handling the request, which App#call publishes in
|
|
19
|
+
# the Rack env, so an app's configured logger is actually used. Falls back
|
|
20
|
+
# to the class-level default when there is no app in the env.
|
|
21
|
+
#
|
|
22
|
+
# @param [Hash, nil] env Rack environment
|
|
23
|
+
# @return [Object] The logger
|
|
24
|
+
def logger(env = nil)
|
|
25
|
+
env&.[](RACK_LENNA_APP)&.config&.logger || Lennarb::App.app.config.logger
|
|
26
|
+
end
|
|
18
27
|
|
|
19
28
|
# Process the request and log information
|
|
20
29
|
#
|
|
@@ -28,7 +37,7 @@ module Lennarb
|
|
|
28
37
|
|
|
29
38
|
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
30
39
|
|
|
31
|
-
log_request(request, status, headers, duration)
|
|
40
|
+
log_request(request, status, headers, duration, env)
|
|
32
41
|
|
|
33
42
|
[status, headers, body]
|
|
34
43
|
end
|
|
@@ -52,18 +61,20 @@ module Lennarb
|
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
# Log the complete request
|
|
55
|
-
def log_request(request, status, headers, duration)
|
|
56
|
-
|
|
64
|
+
def log_request(request, status, headers, duration, env = nil)
|
|
65
|
+
log = logger(env)
|
|
66
|
+
|
|
67
|
+
log.info { request_line(request, duration, status) }
|
|
57
68
|
|
|
58
|
-
|
|
69
|
+
log.info { status_line(status) }
|
|
59
70
|
|
|
60
71
|
if request.params.any?
|
|
61
|
-
|
|
72
|
+
log.info { params_line(request.params) }
|
|
62
73
|
end
|
|
63
74
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
# Rack 3 header names are lowercase; Response#redirect writes "location".
|
|
76
|
+
location = headers["location"]
|
|
77
|
+
log.info { redirect_line(location) } if location
|
|
67
78
|
end
|
|
68
79
|
|
|
69
80
|
# Format the request line
|
|
@@ -92,9 +103,16 @@ module Lennarb
|
|
|
92
103
|
"Redirect: #{location}".colorize(:yellow)
|
|
93
104
|
end
|
|
94
105
|
|
|
95
|
-
#
|
|
106
|
+
# Escape control characters in the request path.
|
|
107
|
+
#
|
|
108
|
+
# The path comes from the client, and an unescaped newline or ANSI escape
|
|
109
|
+
# would let it forge log lines. Parameter values are already safe because
|
|
110
|
+
# they go through #inspect.
|
|
111
|
+
#
|
|
112
|
+
# @param [String] path Request path
|
|
113
|
+
# @return [String] Path safe to write to a log
|
|
96
114
|
def filter_path(path)
|
|
97
|
-
path
|
|
115
|
+
path.to_s.gsub(/[[:cntrl:]]/) { |char| format("\\x%02X", char.ord) }
|
|
98
116
|
end
|
|
99
117
|
|
|
100
118
|
# Filter request parameters
|
|
@@ -19,7 +19,7 @@ module Lennarb
|
|
|
19
19
|
# @param [Array] args
|
|
20
20
|
# @param [Proc] block
|
|
21
21
|
#
|
|
22
|
-
# @
|
|
22
|
+
# @return [void]
|
|
23
23
|
#
|
|
24
24
|
def use(middleware, *args, &block)
|
|
25
25
|
@store << [middleware, args, block]
|
|
@@ -31,7 +31,7 @@ module Lennarb
|
|
|
31
31
|
# @param [Array] args
|
|
32
32
|
# @param [Proc] block
|
|
33
33
|
#
|
|
34
|
-
# @
|
|
34
|
+
# @return [void]
|
|
35
35
|
#
|
|
36
36
|
def unshift(middleware, *args, &block)
|
|
37
37
|
@store.unshift([middleware, args, block])
|
|
@@ -39,7 +39,7 @@ module Lennarb
|
|
|
39
39
|
|
|
40
40
|
# Clear the middleware stack.
|
|
41
41
|
#
|
|
42
|
-
# @
|
|
42
|
+
# @return [void]
|
|
43
43
|
#
|
|
44
44
|
def clear
|
|
45
45
|
@store.clear
|
|
@@ -47,7 +47,7 @@ module Lennarb
|
|
|
47
47
|
|
|
48
48
|
# Convert the middleware stack to an array.
|
|
49
49
|
#
|
|
50
|
-
# @
|
|
50
|
+
# @return [Array]
|
|
51
51
|
#
|
|
52
52
|
def to_a
|
|
53
53
|
@store
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module Lennarb
|
|
2
|
-
#
|
|
3
|
-
#
|
|
2
|
+
# Filters sensitive parameters from logs and exceptions.
|
|
3
|
+
# Useful for preventing the leakage of confidential information.
|
|
4
4
|
#
|
|
5
|
-
#
|
|
5
|
+
# By default, the following parameter keys are filtered:
|
|
6
6
|
#
|
|
7
7
|
# - `passw`
|
|
8
8
|
# - `email`
|
|
@@ -17,6 +17,16 @@ module Lennarb
|
|
|
17
17
|
# - `cvv`
|
|
18
18
|
# - `cvc`
|
|
19
19
|
# - `signature`
|
|
20
|
+
# - `auth`
|
|
21
|
+
# - `credit`
|
|
22
|
+
# - `card_number`
|
|
23
|
+
# - `cvn`
|
|
24
|
+
# - `iban`
|
|
25
|
+
# - `api`
|
|
26
|
+
# - `pin`
|
|
27
|
+
# - `session_id`
|
|
28
|
+
#
|
|
29
|
+
# Matching is case-insensitive, so `Password` and `API_KEY` are filtered too.
|
|
20
30
|
#
|
|
21
31
|
# @example
|
|
22
32
|
# filter = Lennarb::ParameterFilter.new
|
|
@@ -30,47 +40,50 @@ module Lennarb
|
|
|
30
40
|
# @api private
|
|
31
41
|
DEFAULT_FILTERS = %w[
|
|
32
42
|
passw email secret token _key crypt salt certificate otp ssn cvv cvc
|
|
33
|
-
signature
|
|
43
|
+
signature auth credit card_number cvn iban api pin session_id
|
|
34
44
|
].freeze
|
|
35
45
|
|
|
36
|
-
#
|
|
46
|
+
# Initialize a new parameter filter
|
|
47
|
+
#
|
|
48
|
+
# Regexp filters are used as given. Anything else is matched as a literal
|
|
49
|
+
# substring of the key, case-insensitively.
|
|
37
50
|
#
|
|
38
|
-
# @param [Array<String, Regexp>] filters
|
|
51
|
+
# @param [Array<String, Symbol, Regexp>] filters List of patterns to filter
|
|
39
52
|
def initialize(filters = DEFAULT_FILTERS)
|
|
40
|
-
|
|
53
|
+
union = Regexp.union(filters.map { |pattern| pattern.is_a?(Regexp) ? pattern : pattern.to_s })
|
|
54
|
+
@filter = Regexp.new(union.source, Regexp::IGNORECASE)
|
|
41
55
|
end
|
|
42
56
|
|
|
43
|
-
#
|
|
57
|
+
# Filter parameters according to the configured filter
|
|
44
58
|
#
|
|
45
|
-
# @param [Hash, Array] params
|
|
46
|
-
# @param [String] mask
|
|
47
|
-
# @return [Hash, Array]
|
|
59
|
+
# @param [Hash, Array] params Parameters to be filtered
|
|
60
|
+
# @param [String] mask Value that will replace filtered parameters
|
|
61
|
+
# @return [Hash, Array] Filtered parameters
|
|
48
62
|
def filter(params, mask: DEFAULT_MASK)
|
|
49
|
-
filter_object(params
|
|
63
|
+
filter_object(params, mask)
|
|
50
64
|
end
|
|
51
65
|
|
|
52
66
|
private
|
|
53
67
|
|
|
54
|
-
#
|
|
68
|
+
# Recursively filter an object (hash or array).
|
|
55
69
|
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
70
|
+
# Builds new containers rather than writing into the ones it was given, so
|
|
71
|
+
# the caller's parameters are never modified.
|
|
72
|
+
#
|
|
73
|
+
# @param [Object] object Object to be filtered
|
|
74
|
+
# @param [String] mask Value that will replace filtered parameters
|
|
75
|
+
# @return [Object] Filtered object
|
|
59
76
|
def filter_object(object, mask)
|
|
60
77
|
case object
|
|
61
78
|
when Hash
|
|
62
|
-
object.
|
|
63
|
-
|
|
64
|
-
mask
|
|
65
|
-
else
|
|
66
|
-
filter_object(value, mask)
|
|
67
|
-
end
|
|
79
|
+
object.each_with_object({}) do |(key, value), result|
|
|
80
|
+
result[key] = key.to_s.match?(@filter) ? mask : filter_object(value, mask)
|
|
68
81
|
end
|
|
69
82
|
when Array
|
|
70
|
-
object
|
|
83
|
+
object.map { filter_object(it, mask) }
|
|
84
|
+
else
|
|
85
|
+
object
|
|
71
86
|
end
|
|
72
|
-
|
|
73
|
-
object
|
|
74
87
|
end
|
|
75
88
|
end
|
|
76
89
|
end
|
data/lib/lennarb/request.rb
CHANGED
|
@@ -125,20 +125,12 @@ module Lennarb
|
|
|
125
125
|
env["HTTP_REFERER"]
|
|
126
126
|
end
|
|
127
127
|
|
|
128
|
-
# Get the host header
|
|
129
|
-
#
|
|
130
|
-
# @return [String, nil]
|
|
131
|
-
#
|
|
132
|
-
def host
|
|
133
|
-
env["HTTP_HOST"]
|
|
134
|
-
end
|
|
135
|
-
|
|
136
128
|
# Get the content length header
|
|
137
129
|
#
|
|
138
130
|
# @return [String, nil]
|
|
139
131
|
#
|
|
140
132
|
def content_length
|
|
141
|
-
env["
|
|
133
|
+
env["CONTENT_LENGTH"]
|
|
142
134
|
end
|
|
143
135
|
|
|
144
136
|
# Get the content type header
|
|
@@ -146,7 +138,7 @@ module Lennarb
|
|
|
146
138
|
# @return [String, nil]
|
|
147
139
|
#
|
|
148
140
|
def content_type
|
|
149
|
-
env["
|
|
141
|
+
env["CONTENT_TYPE"]
|
|
150
142
|
end
|
|
151
143
|
|
|
152
144
|
# Check if the request is an XHR request
|
|
@@ -17,7 +17,7 @@ module Lennarb
|
|
|
17
17
|
# @return [Array] Rack response [status, headers, body]
|
|
18
18
|
def call(env)
|
|
19
19
|
http_method = env[Rack::REQUEST_METHOD].to_sym
|
|
20
|
-
parts = env[Rack::PATH_INFO]
|
|
20
|
+
parts = split_path(env[Rack::PATH_INFO])
|
|
21
21
|
block, params = app.routes.match_route(parts, http_method)
|
|
22
22
|
|
|
23
23
|
return [404, {"content-type" => CONTENT_TYPE[:TEXT]}, ["Not Found"]] unless block
|
|
@@ -35,27 +35,62 @@ module Lennarb
|
|
|
35
35
|
Hooks.execute(context, app.class, :after, req, res)
|
|
36
36
|
|
|
37
37
|
res.finish
|
|
38
|
-
rescue
|
|
38
|
+
rescue => e
|
|
39
|
+
# In development, let the exception through so Rack::ShowExceptions --
|
|
40
|
+
# already in App#default_middleware_stack -- can render the backtrace.
|
|
41
|
+
raise if app.env.development?
|
|
42
|
+
|
|
39
43
|
app.class.config.logger.error("Error: #{e.message}")
|
|
40
44
|
app.class.config.logger.error(e.backtrace.first)
|
|
41
|
-
[500, {"content-type" =>
|
|
45
|
+
[500, {"content-type" => CONTENT_TYPE[:TEXT]}, ["Internal Server Error"]]
|
|
42
46
|
end
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
private
|
|
46
50
|
|
|
47
|
-
#
|
|
51
|
+
# Split a request path into decoded segments.
|
|
48
52
|
#
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
# Segments are decoded after splitting, so a percent-encoded slash stays
|
|
54
|
+
# inside its segment instead of splitting the path. The escape check keeps
|
|
55
|
+
# the common path free of the decoding cost.
|
|
56
|
+
#
|
|
57
|
+
# @param [String] path The raw PATH_INFO
|
|
58
|
+
# @return [Array<String>] The decoded segments
|
|
59
|
+
def split_path(path)
|
|
60
|
+
parts = path.split("/").reject(&:empty?)
|
|
61
|
+
return parts unless path.include?("%")
|
|
52
62
|
|
|
53
|
-
|
|
63
|
+
parts.map! { |part| Rack::Utils.unescape_path(part) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The context class for this app, compiled once.
|
|
67
|
+
#
|
|
68
|
+
# Safe to memoize: Helpers.for always returns the same Module object for a
|
|
69
|
+
# given app class, and Ruby's include is live, so helpers defined after the
|
|
70
|
+
# first request still resolve through it.
|
|
71
|
+
#
|
|
72
|
+
# @return [Class] The context class
|
|
73
|
+
def context_class
|
|
74
|
+
@context_class ||= begin
|
|
75
|
+
helpers_module = Helpers.for(app.class)
|
|
54
76
|
|
|
55
|
-
|
|
56
|
-
|
|
77
|
+
Class.new do
|
|
78
|
+
def initialize(app)
|
|
79
|
+
@app = app
|
|
80
|
+
end
|
|
57
81
|
|
|
58
|
-
|
|
82
|
+
attr_reader :app
|
|
83
|
+
|
|
84
|
+
include helpers_module
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Create a context object with app's helper methods
|
|
90
|
+
#
|
|
91
|
+
# @return [Object] A context object with helper methods
|
|
92
|
+
def create_context
|
|
93
|
+
context_class.new(app)
|
|
59
94
|
end
|
|
60
95
|
end
|
|
61
96
|
end
|
data/lib/lennarb/response.rb
CHANGED
|
@@ -8,17 +8,17 @@ module Lennarb
|
|
|
8
8
|
attr_accessor :status
|
|
9
9
|
|
|
10
10
|
# @!attribute [r] body
|
|
11
|
-
# @
|
|
11
|
+
# @return [Array]
|
|
12
12
|
#
|
|
13
13
|
attr_reader :body
|
|
14
14
|
|
|
15
15
|
# @!attribute [r] headers
|
|
16
|
-
# @
|
|
16
|
+
# @return [Hash]
|
|
17
17
|
#
|
|
18
18
|
attr_reader :headers
|
|
19
19
|
|
|
20
20
|
# @!attribute [r] length
|
|
21
|
-
# @
|
|
21
|
+
# @return [Integer]
|
|
22
22
|
#
|
|
23
23
|
attr_reader :length
|
|
24
24
|
|
|
@@ -35,7 +35,7 @@ module Lennarb
|
|
|
35
35
|
|
|
36
36
|
# Initialize the response object
|
|
37
37
|
#
|
|
38
|
-
# @
|
|
38
|
+
# @return [Response]
|
|
39
39
|
#
|
|
40
40
|
def initialize
|
|
41
41
|
@status = 200
|
|
@@ -48,7 +48,7 @@ module Lennarb
|
|
|
48
48
|
#
|
|
49
49
|
# @param [String] key
|
|
50
50
|
#
|
|
51
|
-
# @
|
|
51
|
+
# @return [String] value
|
|
52
52
|
#
|
|
53
53
|
def [](key)
|
|
54
54
|
@headers[key]
|
|
@@ -59,7 +59,7 @@ module Lennarb
|
|
|
59
59
|
# @param [String] key
|
|
60
60
|
# @param [String] value
|
|
61
61
|
#
|
|
62
|
-
# @
|
|
62
|
+
# @return [String] value
|
|
63
63
|
#
|
|
64
64
|
def []=(key, value)
|
|
65
65
|
@headers[key] = value
|
|
@@ -69,7 +69,7 @@ module Lennarb
|
|
|
69
69
|
#
|
|
70
70
|
# @param [String] str
|
|
71
71
|
#
|
|
72
|
-
# @
|
|
72
|
+
# @return [String] str
|
|
73
73
|
#
|
|
74
74
|
def write(str)
|
|
75
75
|
str = str.to_s
|
|
@@ -82,7 +82,7 @@ module Lennarb
|
|
|
82
82
|
#
|
|
83
83
|
# @param [String] str
|
|
84
84
|
#
|
|
85
|
-
# @
|
|
85
|
+
# @return [String] str
|
|
86
86
|
#
|
|
87
87
|
def text(str)
|
|
88
88
|
@headers[CONTENT_TYPE] = Lennarb::CONTENT_TYPE[:TEXT]
|
|
@@ -93,7 +93,7 @@ module Lennarb
|
|
|
93
93
|
#
|
|
94
94
|
# @param [String] str
|
|
95
95
|
#
|
|
96
|
-
# @
|
|
96
|
+
# @return [String] str
|
|
97
97
|
#
|
|
98
98
|
def html(str)
|
|
99
99
|
@headers[CONTENT_TYPE] = Lennarb::CONTENT_TYPE[:HTML]
|
|
@@ -104,16 +104,22 @@ module Lennarb
|
|
|
104
104
|
#
|
|
105
105
|
# @param [String] str
|
|
106
106
|
#
|
|
107
|
-
# @
|
|
107
|
+
# @return [String] str
|
|
108
108
|
#
|
|
109
109
|
def json(str)
|
|
110
110
|
json_str = JSON.generate(str)
|
|
111
111
|
@headers[CONTENT_TYPE] = Lennarb::CONTENT_TYPE[:JSON]
|
|
112
112
|
write(json_str)
|
|
113
|
-
|
|
113
|
+
# Rescues JSON::JSONError rather than JSON::GeneratorError: a circular or
|
|
114
|
+
# over-deep object graph raises JSON::NestingError, which descends from
|
|
115
|
+
# ParserError, not GeneratorError, and so escaped this rescue entirely.
|
|
116
|
+
#
|
|
117
|
+
# The body is static because the exception message can carry `inspect`
|
|
118
|
+
# output of the object being serialized, which may hold credentials.
|
|
119
|
+
rescue JSON::JSONError
|
|
114
120
|
@status = 500
|
|
115
121
|
@headers[CONTENT_TYPE] = Lennarb::CONTENT_TYPE[:TEXT]
|
|
116
|
-
write("JSON generation error
|
|
122
|
+
write("JSON generation error")
|
|
117
123
|
end
|
|
118
124
|
|
|
119
125
|
# Redirect the response
|
|
@@ -130,7 +136,7 @@ module Lennarb
|
|
|
130
136
|
|
|
131
137
|
# Finish the response
|
|
132
138
|
#
|
|
133
|
-
# @
|
|
139
|
+
# @return [Array] response
|
|
134
140
|
#
|
|
135
141
|
def finish
|
|
136
142
|
[@status, @headers, @body]
|
data/lib/lennarb/route_node.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Lennarb
|
|
|
13
13
|
|
|
14
14
|
# Initialize the route node.
|
|
15
15
|
#
|
|
16
|
-
# @
|
|
16
|
+
# @return [RouteNode]
|
|
17
17
|
#
|
|
18
18
|
def initialize
|
|
19
19
|
@blocks = {}
|
|
@@ -28,7 +28,7 @@ module Lennarb
|
|
|
28
28
|
# @param http_method [String] The HTTP method.
|
|
29
29
|
# @param block [Proc] The block to be executed when the route is matched.
|
|
30
30
|
#
|
|
31
|
-
# @
|
|
31
|
+
# @return [void]
|
|
32
32
|
#
|
|
33
33
|
def add_route(parts, http_method, block)
|
|
34
34
|
current_node = self
|
|
@@ -55,7 +55,7 @@ module Lennarb
|
|
|
55
55
|
# @param http_method [String] The HTTP method.
|
|
56
56
|
# @param params [Hash] The parameters of the route.
|
|
57
57
|
#
|
|
58
|
-
# @
|
|
58
|
+
# @return [Array<Proc, Hash>]
|
|
59
59
|
#
|
|
60
60
|
def match_route(parts, http_method, params: {})
|
|
61
61
|
if parts.empty?
|
|
@@ -85,7 +85,7 @@ module Lennarb
|
|
|
85
85
|
#
|
|
86
86
|
# @param other [RouteNode] The other route node.
|
|
87
87
|
#
|
|
88
|
-
# @
|
|
88
|
+
# @return [void|DuplicateRouteError]
|
|
89
89
|
#
|
|
90
90
|
def merge!(other)
|
|
91
91
|
other.blocks.each do |http_method, block|
|
data/lib/lennarb/routes.rb
CHANGED
|
@@ -35,12 +35,28 @@ module Lennarb
|
|
|
35
35
|
@store.match_route(parts, http_method)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
#
|
|
38
|
+
# Copy the routes from another Routes instance into this one.
|
|
39
|
+
#
|
|
40
|
+
# The copy is deep: node objects are rebuilt rather than shared, so routes
|
|
41
|
+
# registered on the source afterwards cannot leak into this instance.
|
|
42
|
+
#
|
|
43
|
+
# @param other [Routes] The routes to copy from
|
|
44
|
+
# @return [self]
|
|
45
|
+
# @api private
|
|
46
|
+
def merge!(other)
|
|
47
|
+
@store.merge!(deep_copy(other.store))
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Freeze the routes to prevent further modification.
|
|
52
|
+
#
|
|
53
|
+
# Freezes the whole tree, not only the root, so a frozen copy really is
|
|
54
|
+
# immutable.
|
|
39
55
|
#
|
|
40
56
|
# @return [self] The frozen routes
|
|
41
57
|
def freeze
|
|
42
58
|
@frozen = true
|
|
43
|
-
@store
|
|
59
|
+
deep_freeze(@store)
|
|
44
60
|
self
|
|
45
61
|
end
|
|
46
62
|
|
|
@@ -63,5 +79,39 @@ module Lennarb
|
|
|
63
79
|
parts = path.split("/").reject(&:empty?)
|
|
64
80
|
@store.add_route(parts, http_method, block)
|
|
65
81
|
end
|
|
82
|
+
|
|
83
|
+
# The underlying route tree.
|
|
84
|
+
#
|
|
85
|
+
# Protected so that merge! can reach a sibling instance's store without
|
|
86
|
+
# exposing the tree publicly.
|
|
87
|
+
#
|
|
88
|
+
# @return [RouteNode]
|
|
89
|
+
# @api private
|
|
90
|
+
protected attr_reader :store
|
|
91
|
+
|
|
92
|
+
# Rebuild a route tree, sharing no node objects with the original.
|
|
93
|
+
#
|
|
94
|
+
# @param node [RouteNode] The node to copy
|
|
95
|
+
# @return [RouteNode] The copy
|
|
96
|
+
def deep_copy(node)
|
|
97
|
+
copy = RouteNode.new
|
|
98
|
+
copy.param_key = node.param_key
|
|
99
|
+
copy.blocks = node.blocks.dup
|
|
100
|
+
|
|
101
|
+
node.static_children.each { |part, child| copy.static_children[part] = deep_copy(child) }
|
|
102
|
+
node.dynamic_children.each { |param, child| copy.dynamic_children[param] = deep_copy(child) }
|
|
103
|
+
|
|
104
|
+
copy
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Freeze a route tree from the leaves up.
|
|
108
|
+
#
|
|
109
|
+
# @param node [RouteNode] The node to freeze
|
|
110
|
+
# @return [RouteNode] The frozen node
|
|
111
|
+
def deep_freeze(node)
|
|
112
|
+
node.static_children.each_value { deep_freeze(it) }
|
|
113
|
+
node.dynamic_children.each_value { deep_freeze(it) }
|
|
114
|
+
node.freeze
|
|
115
|
+
end
|
|
66
116
|
end
|
|
67
117
|
end
|
data/lib/lennarb/version.rb
CHANGED
data/readme.md
CHANGED
|
@@ -63,23 +63,18 @@ Create a simple application with routes:
|
|
|
63
63
|
```ruby
|
|
64
64
|
require "lennarb"
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
res.
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
get("/hello/:name") do |req, res|
|
|
73
|
-
name = req.params[:name]
|
|
74
|
-
res.html("Hello, #{name}!")
|
|
75
|
-
end
|
|
76
|
-
end
|
|
66
|
+
class App < Lennarb::App
|
|
67
|
+
get("/") { |req, res| res.html("<h1>Welcome to Lennarb!</h1>") }
|
|
68
|
+
get("/hello/:name") { |req, res| res.html("Hello, #{req.params[:name]}!") }
|
|
69
|
+
post("/users") { |req, res| res.json(id: 1, **req.json_body) }
|
|
77
70
|
end
|
|
78
71
|
|
|
79
|
-
|
|
80
|
-
run app # In config.ru
|
|
72
|
+
run App.new.initialize! # In config.ru
|
|
81
73
|
```
|
|
82
74
|
|
|
75
|
+
Subclassing is the canonical form: each subclass gets its own routes, so
|
|
76
|
+
several applications can coexist in one process.
|
|
77
|
+
|
|
83
78
|
Start with: `rackup`
|
|
84
79
|
|
|
85
80
|
## Basic Usage
|
|
@@ -98,15 +93,13 @@ class MyApp < Lennarb::App
|
|
|
98
93
|
optional :port, int, 9292
|
|
99
94
|
end
|
|
100
95
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
res.html("<h1>Welcome!</h1>")
|
|
105
|
-
end
|
|
96
|
+
get("/") do |req, res|
|
|
97
|
+
res.html("<h1>Welcome!</h1>")
|
|
98
|
+
end
|
|
106
99
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
100
|
+
post("/users") do |req, res|
|
|
101
|
+
# Access request data
|
|
102
|
+
data = req.body
|
|
110
103
|
res.json({status: "created", data: data})
|
|
111
104
|
end
|
|
112
105
|
end
|
|
@@ -160,18 +153,14 @@ For larger applications, use `Lennarb::Base` to mount multiple apps:
|
|
|
160
153
|
|
|
161
154
|
```ruby
|
|
162
155
|
class API < Lennarb::App
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
res.json([{id: 1, name: "Alice"}, {id: 2, name: "Bob"}])
|
|
166
|
-
end
|
|
156
|
+
get("/users") do |req, res|
|
|
157
|
+
res.json([{id: 1, name: "Alice"}, {id: 2, name: "Bob"}])
|
|
167
158
|
end
|
|
168
159
|
end
|
|
169
160
|
|
|
170
|
-
class Admin < Lennarb::App
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
res.html("<h1>Admin Dashboard</h1>")
|
|
174
|
-
end
|
|
161
|
+
class Admin < Lennarb::App
|
|
162
|
+
get("/dashboard") do |req, res|
|
|
163
|
+
res.html("<h1>Admin Dashboard</h1>")
|
|
175
164
|
end
|
|
176
165
|
end
|
|
177
166
|
|
|
@@ -193,11 +182,10 @@ run Application.new.initialize!
|
|
|
193
182
|
|
|
194
183
|
For more detailed information, please see:
|
|
195
184
|
|
|
196
|
-
- [Getting Started](https://
|
|
197
|
-
- [Response](https://
|
|
198
|
-
- [
|
|
199
|
-
- [
|
|
200
|
-
- [Performance](https://aristotelesbr.github.io/lennarb/guides/performance/index.html) - Benchmarks showing Lennarb's routing algorithm efficiency
|
|
185
|
+
- [Getting Started](https://github.com/aristotelesbr/lennarb/tree/main/guides/getting-started) - Setup and first steps
|
|
186
|
+
- [Response](https://github.com/aristotelesbr/lennarb/tree/main/guides/response) - Response handling
|
|
187
|
+
- [Mounting Applications](https://github.com/aristotelesbr/lennarb/tree/main/guides/mounting-applications) - Working with multiple apps
|
|
188
|
+
- [Performance](https://github.com/aristotelesbr/lennarb/tree/main/guides/performance) - Benchmarks showing Lennarb's routing algorithm efficiency
|
|
201
189
|
|
|
202
190
|
## Contributing
|
|
203
191
|
|
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.5.
|
|
4
|
+
version: 1.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Aristóteles Coutinho
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 2026-09-09 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: bigdecimal
|
|
@@ -125,16 +125,30 @@ dependencies:
|
|
|
125
125
|
name: minitest
|
|
126
126
|
requirement: !ruby/object:Gem::Requirement
|
|
127
127
|
requirements:
|
|
128
|
-
- - "
|
|
128
|
+
- - "~>"
|
|
129
129
|
- !ruby/object:Gem::Version
|
|
130
|
-
version: '0'
|
|
130
|
+
version: '6.0'
|
|
131
131
|
type: :development
|
|
132
132
|
prerelease: false
|
|
133
133
|
version_requirements: !ruby/object:Gem::Requirement
|
|
134
134
|
requirements:
|
|
135
|
-
- - "
|
|
135
|
+
- - "~>"
|
|
136
136
|
- !ruby/object:Gem::Version
|
|
137
|
-
version: '0'
|
|
137
|
+
version: '6.0'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: minitest-mock
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - "~>"
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '5.27'
|
|
145
|
+
type: :development
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - "~>"
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '5.27'
|
|
138
152
|
- !ruby/object:Gem::Dependency
|
|
139
153
|
name: minitest-utils
|
|
140
154
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -254,6 +268,7 @@ files:
|
|
|
254
268
|
- LICENCE
|
|
255
269
|
- README.pt-BR.md
|
|
256
270
|
- Rakefile
|
|
271
|
+
- benchmark/hot_path.rb
|
|
257
272
|
- benchmark/memory.png
|
|
258
273
|
- benchmark/rps.png
|
|
259
274
|
- benchmark/runtime_with_startup.png
|
|
@@ -307,7 +322,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
307
322
|
requirements:
|
|
308
323
|
- - ">="
|
|
309
324
|
- !ruby/object:Gem::Version
|
|
310
|
-
version: '
|
|
325
|
+
version: '3.4'
|
|
311
326
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
312
327
|
requirements:
|
|
313
328
|
- - ">="
|