lennarb 1.1.0 → 1.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/changelog.md +20 -0
- data/lib/lennarb/plugin.rb +45 -31
- data/lib/lennarb/plugins/hooks.rb +117 -0
- data/lib/lennarb/plugins/mount.rb +66 -0
- data/lib/lennarb/request.rb +76 -37
- data/lib/lennarb/response.rb +133 -133
- data/lib/lennarb/route_node.rb +50 -56
- data/lib/lennarb/version.rb +2 -2
- data/lib/lennarb.rb +140 -114
- data/license.md +1 -2
- metadata +5 -4
- data/lib/lennarb/application/base.rb +0 -253
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0fafe6e0a4a4fcb12d0cb7d75187b89c5ff2a8f4847bdab4db666d670fd1d40e
|
4
|
+
data.tar.gz: 43739df71271e6410f691b19d0e31c64ea5e883ae79150a3d0055d17a200fe8a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8015dceb914f8bf52ee77eff7faf3adca26cdb020a3acba0ce3cc58ecd7eec338e84afe87fcd691867e087a646875b06a9f278c220b25f4f627bc87ff7288306
|
7
|
+
data.tar.gz: c9a7b74731ac3fe4e607396647931656628e1df03968069891d65189b0f387df44d89054191a34a41bdaf7a3381ad6c2486f91fba13dfd4b5a3b04a79f299af7
|
data/changelog.md
CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
7
7
|
|
8
8
|
## [Unreleased]
|
9
9
|
|
10
|
+
## [1.3.0] - 2024-11-21
|
11
|
+
|
12
|
+
### Added
|
13
|
+
|
14
|
+
- Add `Lennarb::Plugin` module to manage the plugins in the project. Now, the `Lennarb` class is the main class of the project.
|
15
|
+
|
16
|
+
- Automatically loads plugins from the default directory
|
17
|
+
|
18
|
+
- Supports custom plugin directories via `LENNARB_PLUGINS_PATH`
|
19
|
+
|
20
|
+
- Configurable through environment variables
|
21
|
+
|
22
|
+
### Changed
|
23
|
+
|
24
|
+
- Change the `finish` method from `Lennarb` class to call `halt(@res.finish)` method to finish the response.
|
25
|
+
|
26
|
+
### Removed
|
27
|
+
|
28
|
+
- Remove `Lennarb::ApplicationBase` class from the project. Now, the `Lennarb` class is the main class of the project.
|
29
|
+
|
10
30
|
## [0.6.1] - 2024-05-17
|
11
31
|
|
12
32
|
### Added
|
data/lib/lennarb/plugin.rb
CHANGED
@@ -1,35 +1,49 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
# Released under the MIT License.
|
4
|
-
# Copyright, 2023-2024, by Aristóteles Coutinho.
|
5
|
-
|
6
3
|
class Lennarb
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
4
|
+
module Plugin
|
5
|
+
class Error < StandardError; end
|
6
|
+
|
7
|
+
@registry = {}
|
8
|
+
@defaults_loaded = false
|
9
|
+
|
10
|
+
class << self
|
11
|
+
attr_reader :registry
|
12
|
+
|
13
|
+
def register(name, mod)
|
14
|
+
registry[name.to_sym] = mod
|
15
|
+
end
|
16
|
+
|
17
|
+
def load(name)
|
18
|
+
registry[name.to_sym] || raise(Error, "Plugin #{name} not found")
|
19
|
+
end
|
20
|
+
|
21
|
+
def load_defaults!
|
22
|
+
return if @defaults_loaded
|
23
|
+
|
24
|
+
# 1. Register default plugins
|
25
|
+
plugins_path = File.expand_path('plugins', __dir__)
|
26
|
+
load_plugins_from_directory(plugins_path)
|
27
|
+
|
28
|
+
# # 2. Register custom plugins
|
29
|
+
ENV.fetch('LENNARB_PLUGINS_PATH', nil)&.split(File::PATH_SEPARATOR)&.each do |path|
|
30
|
+
load_plugins_from_directory(path)
|
31
|
+
end
|
32
|
+
|
33
|
+
@defaults_loaded = true
|
34
|
+
end
|
35
|
+
|
36
|
+
def load_defaults?
|
37
|
+
ENV.fetch('LENNARB_AUTO_LOAD_DEFAULTS', 'true') == 'true'
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def load_plugins_from_directory(path)
|
43
|
+
raise Error, "Plugin directory '#{path}' does not exist" unless File.directory?(path)
|
44
|
+
|
45
|
+
Dir["#{path}/**/*.rb"].each { require _1 }
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
35
49
|
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Lennarb
|
4
|
+
module Plugins
|
5
|
+
module Hooks
|
6
|
+
def self.configure(app)
|
7
|
+
app.instance_variable_set(:@_before_hooks, {})
|
8
|
+
app.instance_variable_set(:@_after_hooks, {})
|
9
|
+
app.extend(ClassMethods)
|
10
|
+
app.include(InstanceMethods)
|
11
|
+
end
|
12
|
+
|
13
|
+
module ClassMethods
|
14
|
+
def before(paths = '*', &block)
|
15
|
+
paths = Array(paths)
|
16
|
+
@_before_hooks ||= {}
|
17
|
+
|
18
|
+
paths.each do |path|
|
19
|
+
@_before_hooks[path] ||= []
|
20
|
+
@_before_hooks[path] << block
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def after(paths = '*', &block)
|
25
|
+
paths = Array(paths)
|
26
|
+
@_after_hooks ||= {}
|
27
|
+
|
28
|
+
paths.each do |path|
|
29
|
+
@_after_hooks[path] ||= []
|
30
|
+
@_after_hooks[path] << block
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def inherited(subclass)
|
35
|
+
super
|
36
|
+
subclass.instance_variable_set(:@_before_hooks, @_before_hooks&.dup || {})
|
37
|
+
subclass.instance_variable_set(:@_after_hooks, @_after_hooks&.dup || {})
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
module InstanceMethods
|
42
|
+
def call(env)
|
43
|
+
catch(:halt) do
|
44
|
+
req = Lennarb::Request.new(env)
|
45
|
+
res = Lennarb::Response.new
|
46
|
+
|
47
|
+
path = env[Rack::PATH_INFO]
|
48
|
+
|
49
|
+
execute_hooks(self.class.instance_variable_get(:@_before_hooks), '*', req, res)
|
50
|
+
|
51
|
+
execute_matching_hooks(self.class.instance_variable_get(:@_before_hooks), path, req, res)
|
52
|
+
|
53
|
+
status, headers, body = super
|
54
|
+
res.status = status
|
55
|
+
headers.each { |k, v| res[k] = v }
|
56
|
+
body.each { |chunk| res.write(chunk) }
|
57
|
+
|
58
|
+
execute_matching_hooks(self.class.instance_variable_get(:@_after_hooks), path, req, res)
|
59
|
+
|
60
|
+
execute_hooks(self.class.instance_variable_get(:@_after_hooks), '*', req, res)
|
61
|
+
|
62
|
+
res.finish
|
63
|
+
end
|
64
|
+
rescue StandardError => e
|
65
|
+
handle_error(e)
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def execute_hooks(hooks, path, req, res)
|
71
|
+
return unless hooks&.key?(path)
|
72
|
+
|
73
|
+
hooks[path].each do |hook|
|
74
|
+
instance_exec(req, res, &hook)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def execute_matching_hooks(hooks, current_path, req, res)
|
79
|
+
return unless hooks
|
80
|
+
|
81
|
+
hooks.each do |pattern, pattern_hooks|
|
82
|
+
next if pattern == '*'
|
83
|
+
|
84
|
+
next unless matches_pattern?(pattern, current_path)
|
85
|
+
|
86
|
+
pattern_hooks.each do |hook|
|
87
|
+
instance_exec(req, res, &hook)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def matches_pattern?(pattern, path)
|
93
|
+
return true if pattern == path
|
94
|
+
|
95
|
+
pattern_parts = pattern.split('/')
|
96
|
+
path_parts = path.split('/')
|
97
|
+
|
98
|
+
return false if pattern_parts.length != path_parts.length
|
99
|
+
|
100
|
+
pattern_parts.zip(path_parts).all? do |pattern_part, path_part|
|
101
|
+
pattern_part.start_with?(':') || pattern_part == path_part
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def handle_error(error)
|
106
|
+
case error
|
107
|
+
when ArgumentError
|
108
|
+
[400, { 'Content-Type' => 'text/plain' }, ["Bad Request: #{error.message}"]]
|
109
|
+
else
|
110
|
+
[500, { 'Content-Type' => 'text/plain' }, ['Internal Server Error']]
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
Lennarb::Plugin.register(:hooks, Hooks)
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Lennarb
|
4
|
+
module Plugins
|
5
|
+
module Mount
|
6
|
+
def self.configure(app)
|
7
|
+
app.instance_variable_set(:@_mounted_apps, {})
|
8
|
+
app.extend(ClassMethods)
|
9
|
+
app.include(InstanceMethods)
|
10
|
+
end
|
11
|
+
|
12
|
+
module ClassMethods
|
13
|
+
def mount(app_class, at:)
|
14
|
+
raise ArgumentError, 'Expected a Lennarb class' unless app_class.is_a?(Class) && app_class <= Lennarb
|
15
|
+
raise ArgumentError, 'Mount path must start with /' unless at.start_with?('/')
|
16
|
+
|
17
|
+
@_mounted_apps ||= {}
|
18
|
+
normalized_path = normalize_mount_path(at)
|
19
|
+
|
20
|
+
mounted_app = app_class.new
|
21
|
+
mounted_app.freeze!
|
22
|
+
|
23
|
+
@_mounted_apps[normalized_path] = mounted_app
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def normalize_mount_path(path)
|
29
|
+
path.chomp('/')
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
module InstanceMethods
|
34
|
+
def call(env)
|
35
|
+
path_info = env[Rack::PATH_INFO]
|
36
|
+
|
37
|
+
self.class.instance_variable_get(:@_mounted_apps)&.each do |mount_path, app|
|
38
|
+
next unless path_info.start_with?("#{mount_path}/") || path_info == mount_path
|
39
|
+
|
40
|
+
env[Rack::PATH_INFO] = path_info[mount_path.length..]
|
41
|
+
env[Rack::PATH_INFO] = '/' if env[Rack::PATH_INFO].empty?
|
42
|
+
env[Rack::SCRIPT_NAME] = "#{env[Rack::SCRIPT_NAME]}#{mount_path}"
|
43
|
+
|
44
|
+
return app.call(env)
|
45
|
+
end
|
46
|
+
|
47
|
+
super
|
48
|
+
rescue StandardError => e
|
49
|
+
handle_error(e)
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def handle_error(error)
|
55
|
+
case error
|
56
|
+
when ArgumentError
|
57
|
+
[400, { 'content-type' => 'text/plain' }, ["Bad Request: #{error.message}"]]
|
58
|
+
else
|
59
|
+
[500, { 'content-type' => 'text/plain' }, ['Internal Server Error']]
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
Lennarb::Plugin.register(:mount, Mount)
|
65
|
+
end
|
66
|
+
end
|
data/lib/lennarb/request.rb
CHANGED
@@ -4,41 +4,80 @@
|
|
4
4
|
# Copyright, 2023-2024, by Aristóteles Coutinho.
|
5
5
|
|
6
6
|
class Lennarb
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
7
|
+
class Request < Rack::Request
|
8
|
+
# The environment variables of the request
|
9
|
+
#
|
10
|
+
# @returns [Hash]
|
11
|
+
#
|
12
|
+
attr_reader :env
|
13
|
+
|
14
|
+
# Initialize the request object
|
15
|
+
#
|
16
|
+
# @parameter [Hash] env
|
17
|
+
# @parameter [Hash] route_params
|
18
|
+
#
|
19
|
+
# @returns [Request]
|
20
|
+
#
|
21
|
+
def initialize(env, route_params = {})
|
22
|
+
super(env)
|
23
|
+
@route_params = route_params
|
24
|
+
end
|
25
|
+
|
26
|
+
# Get the request body
|
27
|
+
#
|
28
|
+
# @returns [String]
|
29
|
+
#
|
30
|
+
def params = @params ||= super.merge(@route_params)&.transform_keys(&:to_sym)
|
31
|
+
|
32
|
+
# Get the request path
|
33
|
+
#
|
34
|
+
# @returns [String]
|
35
|
+
#
|
36
|
+
def path = @path ||= super.split('?').first
|
37
|
+
|
38
|
+
# Read the body of the request
|
39
|
+
#
|
40
|
+
# @returns [String]
|
41
|
+
#
|
42
|
+
def body = @body ||= super.read
|
43
|
+
|
44
|
+
# Get the query parameters
|
45
|
+
#
|
46
|
+
# @returns [Hash]
|
47
|
+
#
|
48
|
+
def query_params
|
49
|
+
@query_params ||= Rack::Utils.parse_nested_query(query_string).transform_keys(&:to_sym)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Get the headers of the request
|
53
|
+
#
|
54
|
+
def headers
|
55
|
+
@headers ||= env.select { |key, _| key.start_with?('HTTP_') }
|
56
|
+
end
|
57
|
+
|
58
|
+
def ip = ip_address
|
59
|
+
def secure? = scheme == 'https'
|
60
|
+
def user_agent = headers['HTTP_USER_AGENT']
|
61
|
+
def accept = headers['HTTP_ACCEPT']
|
62
|
+
def referer = headers['HTTP_REFERER']
|
63
|
+
def host = headers['HTTP_HOST']
|
64
|
+
def content_length = headers['HTTP_CONTENT_LENGTH']
|
65
|
+
def content_type = headers['HTTP_CONTENT_TYPE']
|
66
|
+
def xhr? = headers['HTTP_X_REQUESTED_WITH']&.casecmp('XMLHttpRequest')&.zero?
|
67
|
+
|
68
|
+
def []=(key, value)
|
69
|
+
env[key] = value
|
70
|
+
end
|
71
|
+
|
72
|
+
def [](key)
|
73
|
+
env[key]
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def ip_address
|
79
|
+
forwarded_for = headers['HTTP_X_FORWARDED_FOR']
|
80
|
+
forwarded_for ? forwarded_for.split(',').first.strip : env['REMOTE_ADDR']
|
81
|
+
end
|
82
|
+
end
|
44
83
|
end
|