lennarb 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,314 @@
1
+ module Lennarb
2
+ # Base class for mounting applications with middleware support.
3
+ # This class serves as a proxy for mounting Lennarb::App instances,
4
+ # providing a lightweight router to dispatch requests to the appropriate app.
5
+ #
6
+ # @example Creating a mounting application
7
+ # class Application < Lennarb::Base
8
+ # # Define base-level middleware
9
+ # middleware do
10
+ # use Rack::Session::Cookie, secret: "your_secret"
11
+ # use Rack::Protection
12
+ # end
13
+ #
14
+ # # Mount other applications
15
+ # mount(Blog, at: "/blog")
16
+ # mount(Admin, at: "/admin")
17
+ # end
18
+ #
19
+ # # Start the server
20
+ # run Application.new
21
+ #
22
+ # @since 1.4.0
23
+ class Base
24
+ # This error is raised whenever the app is initialized more than once.
25
+ # @since 1.0.0
26
+ AlreadyInitializedError = Class.new(StandardError)
27
+
28
+ class << self
29
+ # Store for mounted applications at class level
30
+ # @return [Hash] mounted applications by path
31
+ # @since 1.4.0
32
+ def mounted_apps
33
+ @mounted_apps ||= {}
34
+ end
35
+
36
+ # Define the app's middleware stack at class level.
37
+ # This allows defining middleware that will be applied before
38
+ # routing to any mounted applications.
39
+ #
40
+ # @yield [middleware] Block to configure middleware
41
+ # @return [Lennarb::MiddlewareStack] the middleware stack
42
+ # @since 1.4.0
43
+ #
44
+ # @example
45
+ # middleware do
46
+ # use Rack::Session::Cookie, secret: "your_secret"
47
+ # use Rack::Protection
48
+ # end
49
+ def middleware(&block)
50
+ @middleware ||= MiddlewareStack.new
51
+ @middleware.instance_eval(&block) if block_given?
52
+ @middleware
53
+ end
54
+
55
+ # Define the app's configuration at class level
56
+ # @param [Array<Symbol>] envs Environments to apply the configuration to
57
+ # @yield [config] Block to configure the application
58
+ # @return [Lennarb::Config] the configuration
59
+ # @since 1.4.0
60
+ #
61
+ # @example Configure for all environments
62
+ # config do
63
+ # set :title, "My Application"
64
+ # end
65
+ #
66
+ # @example Configure for specific environments
67
+ # config :development, :test do
68
+ # set :debug, true
69
+ # end
70
+ def config(*envs, &block)
71
+ @config ||= Config.new
72
+ @config.instance_eval(&block) if block_given?
73
+ @config
74
+ end
75
+
76
+ # Mount a component at the given path.
77
+ # @param [Class] component The component to mount (must be a Lennarb::App subclass)
78
+ # @param [Hash] options The mounting options
79
+ # @option options [String] :at The path to mount the component at (defaults to "/")
80
+ # @raise [ArgumentError] If the component is not a Lennarb::App subclass
81
+ # @return [void]
82
+ # @since 1.4.0
83
+ #
84
+ # @example
85
+ # mount(Blog, at: "/blog")
86
+ # mount(Admin, at: "/admin")
87
+ def mount(component, at: nil)
88
+ if component.is_a?(Class) && component < Lennarb::App
89
+ path = normalize_mount_path(at || "/")
90
+ mounted_apps[path] = component
91
+ else
92
+ raise ArgumentError, "Component must be a Lennarb::App subclass"
93
+ end
94
+ end
95
+
96
+ # Normalize the mount path.
97
+ # @param [String] path The path to normalize
98
+ # @return [String] The normalized path
99
+ # @since 1.4.0
100
+ # @api private
101
+ private def normalize_mount_path(path)
102
+ path = "/#{path}" unless path.start_with?("/")
103
+ path = path[0..-2] if path.end_with?("/") && path != "/"
104
+ path
105
+ end
106
+ end
107
+
108
+ # The current environment.
109
+ # @return [Lennarb::Environment] The environment
110
+ # @since 1.0.0
111
+ attr_reader :env
112
+
113
+ # The root directory of the application.
114
+ # @return [Pathname] The root directory
115
+ # @since 1.0.0
116
+ attr_accessor :root
117
+
118
+ # Get the mounted applications.
119
+ # @return [Hash] The mounted applications by path
120
+ # @since 1.4.0
121
+ attr_reader :mounted_apps
122
+
123
+ # Initialize a new Base instance.
124
+ # @yield [self] Block to configure the application
125
+ # @return [Base] The initialized application
126
+ # @since 1.0.0
127
+ def initialize(&block)
128
+ @initialized = false
129
+ @mounted_apps = {}
130
+ @middleware = nil
131
+ self.root = Pathname.pwd
132
+ @env = Environment.new(compute_env)
133
+
134
+ # Initialize mounted applications from class definition
135
+ self.class.mounted_apps.each do |path, app_class|
136
+ @mounted_apps[path] = app_class
137
+ end
138
+
139
+ instance_eval(&block) if block_given?
140
+ end
141
+
142
+ # Define the app's middleware stack.
143
+ # Middleware defined here will be applied to all requests before
144
+ # they are routed to mounted applications.
145
+ #
146
+ # @yield [middleware] Block to configure middleware
147
+ # @return [Lennarb::MiddlewareStack] the middleware stack
148
+ # @since 1.4.0
149
+ #
150
+ # @example
151
+ # middleware do
152
+ # use Rack::Session::Cookie, secret: "your_secret"
153
+ # use Rack::Protection
154
+ # end
155
+ def middleware(&block)
156
+ @middleware ||= MiddlewareStack.new
157
+ @middleware.instance_eval(&block) if block_given?
158
+ @middleware
159
+ end
160
+
161
+ # Define the app's configuration.
162
+ # @param [Array<Symbol>] envs Environments to apply the configuration to
163
+ # @yield [config] Block to configure the application
164
+ # @return [Lennarb::Config] the configuration
165
+ # @since 1.4.0
166
+ def config(*envs, &block)
167
+ @config ||= Config.new
168
+
169
+ write = block_given? &&
170
+ (envs.map(&:to_sym).include?(env.to_sym) || envs.empty?)
171
+
172
+ @config.instance_eval(&block) if write
173
+
174
+ @config
175
+ end
176
+
177
+ # Check if the app is initialized.
178
+ # @return [Boolean] true if initialized, false otherwise
179
+ # @since 1.4.0
180
+ def initialized?
181
+ @initialized
182
+ end
183
+
184
+ # Initialize the app.
185
+ # @return [self] The initialized app
186
+ # @raise [AlreadyInitializedError] If the app is already initialized
187
+ # @since 1.4.0
188
+ def initialize!
189
+ raise AlreadyInitializedError if initialized?
190
+ @initialized = true
191
+ self
192
+ end
193
+
194
+ # Set the environment for the application.
195
+ # @param [String, Symbol] value The environment name
196
+ # @raise [AlreadyInitializedError] If the app is already initialized
197
+ # @return [Lennarb::Environment] The new environment
198
+ # @since 1.4.0
199
+ def env=(value)
200
+ raise AlreadyInitializedError if initialized?
201
+ @env = Environment.new(value)
202
+ end
203
+
204
+ # Freeze the app.
205
+ # @return [void]
206
+ # @since 1.0.0
207
+ def freeze!
208
+ app.freeze
209
+ end
210
+
211
+ # The Rack app with all middlewares and mounted applications.
212
+ # This builds a middleware stack around the URL map of mounted applications.
213
+ #
214
+ # @return [#call] The Rack application
215
+ # @since 1.0.0
216
+ def app
217
+ @app ||= begin
218
+ url_map = build_url_map
219
+
220
+ stack = middleware.to_a
221
+
222
+ Rack::Builder.app do
223
+ stack.each { |middleware, args, block| use(middleware, *args, &block) }
224
+ run url_map
225
+ end
226
+ end
227
+ end
228
+
229
+ # Call the app - main Rack entry point.
230
+ # This method is called by Rack when a request is received.
231
+ #
232
+ # @param [Hash] env The Rack environment
233
+ # @return [Array(Integer, Hash, #each)] The Rack response
234
+ # @since 1.0.0
235
+ def call(env)
236
+ # Store reference to the current app in the env
237
+ env[RACK_LENNA_APP] = self
238
+
239
+ # Call the app with middlewares
240
+ app.call(env)
241
+ end
242
+
243
+ # Mount a component at the given path (instance method)
244
+ # @param [Class] component The component to mount (must be a Lennarb::App subclass)
245
+ # @param [Hash] options The mounting options
246
+ # @option options [String] :at The path to mount the component at (defaults to "/")
247
+ # @raise [ArgumentError] If the component is not a Lennarb::App subclass
248
+ # @return [void]
249
+ # @since 1.4.0
250
+ #
251
+ # @example
252
+ # mount(Blog, at: "/blog")
253
+ def mount(component, at: nil)
254
+ if component.is_a?(Class) && component < Lennarb::App
255
+ path = normalize_mount_path(at || "/")
256
+ @mounted_apps[path] = component
257
+ else
258
+ raise ArgumentError, "Component must be a Lennarb::App subclass"
259
+ end
260
+ end
261
+
262
+ private
263
+
264
+ # Build the URL map with all mounted applications.
265
+ # @return [Rack::URLMap] The URL map
266
+ # @since 1.4.0
267
+ # @api private
268
+ def build_url_map
269
+ url_map = {}
270
+
271
+ if mounted_apps.any?
272
+ mounted_apps.each do |path, app_class|
273
+ app_instance = app_class.new
274
+ app_instance.initialize!
275
+ url_map[path] = app_instance
276
+ end
277
+ end
278
+
279
+ # If no app is mounted at root path, provide a default handler
280
+ url_map["/"] = default_app_handler unless url_map.key?("/")
281
+
282
+ Rack::URLMap.new(url_map)
283
+ end
284
+
285
+ # Default handler for when no app is mounted at root path.
286
+ # @return [#call] A simple not found handler
287
+ # @since 1.4.0
288
+ # @api private
289
+ def default_app_handler
290
+ # config.logger.warn("No application mounted at root path. Default handler will be used.")
291
+ ->(env) { [404, {"content-type" => "text/plain"}, ["Not Found"]] }
292
+ end
293
+
294
+ # Normalize the mount path.
295
+ # @param [String] path The path to normalize
296
+ # @return [String] The normalized path
297
+ # @since 1.4.0
298
+ # @api private
299
+ def normalize_mount_path(path)
300
+ path = "/#{path}" unless path.start_with?("/")
301
+ path = path[0..-2] if path.end_with?("/") && path != "/"
302
+ path
303
+ end
304
+
305
+ # Compute the current environment from environment variables.
306
+ # @return [String] The environment name
307
+ # @since 1.4.0
308
+ # @api private
309
+ def compute_env
310
+ env = ENV_NAMES.map { |name| ENV[name] }.compact.first.to_s
311
+ env.empty? ? "development" : env
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,52 @@
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
+ undef_method :credential
7
+
8
+ attr_reader :app
9
+ attr_accessor :stderr_output
10
+
11
+ def initialize(app = nil, silent: !ENV["LENNARB_SILENT_LOGS"].nil?, **options)
12
+ self.stderr_output = if silent
13
+ nil
14
+ else
15
+ $stderr
16
+ end
17
+
18
+ @app = app
19
+ block = proc { true }
20
+ super(**options, &block)
21
+ apply_defaults_settings
22
+ end
23
+
24
+ # @private
25
+ def to_s = "#<Lennarb::Config>"
26
+
27
+ # @private
28
+ def mandatory(*, **)
29
+ super
30
+ rescue SuperConfig::MissingEnvironmentVariable => error
31
+ raise MissingEnvironmentVariable, error.message
32
+ end
33
+
34
+ # @private
35
+ def property(*, **, &)
36
+ super
37
+ rescue SuperConfig::MissingCallable
38
+ raise MissingCallable,
39
+ "arg[1] must respond to #call or a block must be provided"
40
+ end
41
+
42
+ private def apply_defaults_settings
43
+ set :logger,
44
+ Lennarb::Logger.new(
45
+ ::Logger.new(stderr_output),
46
+ colorize: true,
47
+ tag: :lennarb
48
+ )
49
+ set :enable_reloading, false
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,12 @@
1
+ module Lennarb
2
+ # The root app directory of the app.
3
+ RACK_LENNA_APP = "lennarb.app"
4
+ # The current environment. Defaults to "development".
5
+ ENV_NAMES = %w[LENNA_ENV APP_ENV RACK_ENV]
6
+ # The HTTP methods.
7
+ HTTP_METHODS = %i[GET POST PUT PATCH DELETE HEAD OPTIONS]
8
+ # The HTTP status codes.
9
+ CONTENT_TYPE = {HTML: "text/html", TEXT: "text/plain", JSON: "application/json"}
10
+ # The current environment. Defaults to "development".
11
+ NAMES = %i[development test production local]
12
+ end
@@ -0,0 +1,80 @@
1
+ module Lennarb
2
+ # Manage the environment of the application.
3
+ #
4
+ # @example
5
+ # app.env.development? # => true
6
+ # app.env.test? # => false
7
+ #
8
+ class Environment
9
+ # Returns the name of the environment.
10
+ # @param name [Symbol]
11
+ #
12
+ attr_reader :name
13
+
14
+ # Initialize the environment.
15
+ # @param name [String, Symbol] The name of the environment.
16
+ #
17
+ def initialize(name)
18
+ @name = name.to_sym
19
+
20
+ return if NAMES.include?(@name)
21
+
22
+ raise ArgumentError, "Invalid environment: #{@name.inspect}"
23
+ end
24
+
25
+ # Returns true if the environment is development.
26
+ #
27
+ def development? = name == :development
28
+
29
+ # Returns true if the environment is test.
30
+ #
31
+ def test? = name == :test
32
+
33
+ # Returns true if the environment is production.
34
+ #
35
+ def production? = name == :production
36
+
37
+ # Returns true if the environment is local (either `test` or `development`).
38
+ #
39
+ def local? = test? || development?
40
+
41
+ # Implements equality for the environment.
42
+ #
43
+ def ==(other) = name == other || name.to_s == other
44
+ alias_method :eql?, :==
45
+ alias_method :equal?, :==
46
+ alias_method :===, :==
47
+
48
+ # Returns the name of the environment as a symbol.
49
+ # @retrn [Symbol]
50
+ #
51
+ def to_sym = name
52
+
53
+ # Returns the name of the environment as a string.
54
+ # @retrn [String]
55
+ #
56
+ def to_s = name.to_s
57
+
58
+ # Returns the name of the environment as a string.
59
+ # @retrn [String]
60
+ def inspect = to_s.inspect
61
+
62
+ # Yields a block if the environment is the same as the given environment.
63
+ # - To match all environments use `:any` or `:all`.
64
+ # - To match local environments use `:local`.
65
+ # @param envs [Array<Symbol>] The environment(s) to check.
66
+ #
67
+ # @example
68
+ # app.env.on(:development) do
69
+ # # Code to run in development
70
+ # end
71
+ def on(*envs)
72
+ matched = envs.include?(:any) ||
73
+ envs.include?(:all) ||
74
+ envs.include?(name) ||
75
+ (envs.include?(:local) && local?)
76
+
77
+ yield if matched
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,17 @@
1
+ module Lennarb
2
+ # Lite implementation of app.
3
+ #
4
+ Error = Class.new(StandardError)
5
+ # This error is raised whenever the app is initialized more than once.
6
+ #
7
+ DuplicateRouteError = Class.new(StandardError)
8
+ # This error is raised whenever the app is initialized more than once.
9
+ #
10
+ MissingEnvironmentVariable = Class.new(StandardError)
11
+ # This error is raised whenever the app is initialized more than once.
12
+ #
13
+ MissingCallable = Class.new(StandardError)
14
+ # This error is raised whenever the app is initialized more than once.
15
+ #
16
+ RoutesFrozenError = Class.new(RuntimeError)
17
+ end
@@ -0,0 +1,40 @@
1
+ module Lennarb
2
+ # Simple helpers module for Lennarb applications.
3
+ # Provides helper methods to be used in routes and hooks.
4
+ module Helpers
5
+ # Store helpers for app classes
6
+ @app_helpers = {}
7
+
8
+ class << self
9
+ # Get the app helpers map
10
+ attr_reader :app_helpers
11
+
12
+ # Get helpers module for an app class
13
+ #
14
+ # @param [Class] app_class The application class
15
+ # @return [Module] The helpers module
16
+ def for(app_class)
17
+ app_helpers[app_class] ||= Module.new
18
+ end
19
+
20
+ # Define helpers for an app class
21
+ #
22
+ # @param [Class] app_class The application class
23
+ # @param [Module, Proc] mod_or_block The module to include or block with helper definitions
24
+ # @return [Module] The helpers module
25
+ def define(app_class, mod_or_block = nil, &block)
26
+ mod = self.for(app_class)
27
+
28
+ case mod_or_block
29
+ when Module
30
+ mod.include(mod_or_block)
31
+ when Proc
32
+ mod.module_eval(&mod_or_block)
33
+ end
34
+
35
+ mod.module_eval(&block) if block_given?
36
+ mod
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,71 @@
1
+ module Lennarb
2
+ # Provides hook functionality for Lennarb applications.
3
+ # Hooks execute code before and after route handlers.
4
+ #
5
+ # @example
6
+ # Hooks.add(MyApp, :before) do |req, res|
7
+ # res.headers["X-My-Header"] = "MyValue"
8
+ # end
9
+ # Hooks.add(MyApp, :after) do |req, res|
10
+ # res.body << "Goodbye!"
11
+ # end
12
+ #
13
+ # @note
14
+ # - Hooks are executed in the order they are added.
15
+ # - The context of the hook is the object that calls the route handler.
16
+ # - Hooks can modify the request and response objects.
17
+ # - Hooks can be used to implement middleware-like functionality.
18
+ # - Hooks are not thread-safe. Use with caution in multi-threaded environments.
19
+ module Hooks
20
+ # Valid hook types
21
+ TYPES = [:before, :after].freeze
22
+
23
+ # Store hooks for each app class
24
+ @app_hooks = {}
25
+
26
+ class << self
27
+ # Get the hooks hash
28
+ #
29
+ # @return [Hash] The hooks hash with app classes as keys
30
+ attr_reader :app_hooks
31
+
32
+ # Get the hooks for an app class
33
+ #
34
+ # @param [Class] app_class The application class
35
+ # @return [Hash] The hooks hash with :before and :after keys
36
+ def for(app_class)
37
+ app_hooks[app_class] ||= {before: [], after: []}
38
+ end
39
+
40
+ # Add a hook for an app class
41
+ #
42
+ # @param [Class] app_class The application class
43
+ # @param [Symbol] type The hook type (:before or :after)
44
+ # @param [Proc] block The hook block
45
+ # @return [Array] The hooks array for the given type
46
+ def add(app_class, type, &block)
47
+ raise ArgumentError, "Invalid hook type: #{type}" unless TYPES.include?(type)
48
+
49
+ hooks = self.for(app_class)
50
+ hooks[type] << block if block_given?
51
+ hooks[type]
52
+ end
53
+
54
+ # Execute hooks of a given type
55
+ #
56
+ # @param [Object] context The execution context
57
+ # @param [Class] app_class The application class
58
+ # @param [Symbol] type The hook type to execute
59
+ # @param [Request] req The request object
60
+ # @param [Response] res The response object
61
+ # @return [void]
62
+ def execute(context, app_class, type, req, res)
63
+ hooks = self.for(app_class)[type]
64
+
65
+ hooks.each do |hook|
66
+ context.instance_exec(req, res, &hook)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,100 @@
1
+ module Lennarb
2
+ class Logger
3
+ # @api private
4
+ LEVELS = {
5
+ debug: ::Logger::DEBUG,
6
+ info: ::Logger::INFO,
7
+ warn: ::Logger::WARN,
8
+ error: ::Logger::ERROR,
9
+ fatal: ::Logger::FATAL
10
+ }.freeze
11
+
12
+ # @api private
13
+ DEFAULT_FORMATTER = proc do |options|
14
+ line = [options[:tag], options[:message]].compact.join(" ")
15
+ "#{line}\n"
16
+ end
17
+
18
+ # Initialize a new logger
19
+ #
20
+ # @param [::Logger] logger Logger instance to use
21
+ # @param [Proc] formatter Custom formatter for messages
22
+ # @param [Array, String, Symbol, nil] tag Tag to identify log messages
23
+ # @param [Symbol, nil] tag_color Color for the tag
24
+ # @param [Symbol, nil] message_color Color for the message
25
+ # @param [Boolean] colorize Force colorization even when not TTY
26
+ def initialize(
27
+ logger = ::Logger.new($stdout, level: ::Logger::INFO),
28
+ formatter: DEFAULT_FORMATTER,
29
+ tag: nil,
30
+ tag_color: nil,
31
+ message_color: nil,
32
+ colorize: false
33
+ )
34
+ @logger = logger.dup
35
+ @logger.formatter = proc { |*args| format_log(*args) }
36
+ @tag = Array(tag)
37
+ @formatter = formatter
38
+ @tag_color = tag_color
39
+ @message_color = message_color
40
+ @colorize = colorize || $stdout.tty?
41
+ end
42
+
43
+ # Create a new logger with additional tags
44
+ #
45
+ # @param [Array<Symbol, String>] tags Additional tags
46
+ # @yield [logger] Block to execute with the new logger
47
+ # @return [Logger] New instance with added tags
48
+ #
49
+ # @example With block
50
+ # logger.tagged(:api) { |l| l.info("message") }
51
+ #
52
+ # @example Without block
53
+ # api_logger = logger.tagged(:api)
54
+ # api_logger.info("message")
55
+ def tagged(*tags)
56
+ new_logger = Logger.new(
57
+ @logger,
58
+ formatter: @formatter,
59
+ tag: @tag.dup.concat(tags),
60
+ tag_color: @tag_color,
61
+ message_color: @message_color,
62
+ colorize: @colorize
63
+ )
64
+
65
+ yield new_logger if block_given?
66
+
67
+ new_logger
68
+ end
69
+
70
+ # Define methods for each log level
71
+ # debug, info, warn, error, fatal
72
+ LEVELS.each_key do |level|
73
+ define_method(level) do |message = nil, &block|
74
+ return self if @logger.level > LEVELS[level]
75
+
76
+ message = block.call if block && !message
77
+ @logger.add(LEVELS[level], message)
78
+
79
+ self
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ # Format the log message
86
+ def format_log(_severity, _time, _progname, message)
87
+ tag = @tag.map { |t| "[#{t}]" }.join(" ")
88
+ tag = colorize_text(tag, @tag_color)
89
+ message = colorize_text(message, @message_color)
90
+
91
+ @formatter.call(message: message, tag: tag.empty? ? nil : tag)
92
+ end
93
+
94
+ def colorize_text(text, color)
95
+ return text.to_s unless @colorize
96
+ return text.to_s if color.nil?
97
+ text.to_s.colorize(color)
98
+ end
99
+ end
100
+ end