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.
- checksums.yaml +4 -4
- data/.github/workflows/coverage.yaml +11 -33
- data/.github/workflows/documentation.yaml +32 -13
- data/.github/workflows/test.yaml +2 -1
- data/.gitignore +9 -0
- data/.yardopts +8 -0
- data/CODE_OF_CONDUCT.md +118 -0
- data/CONTRIBUTING.md +155 -0
- data/README.pt-BR.md +147 -0
- data/Rakefile +35 -2
- data/changelog.md +76 -0
- data/gems.rb +3 -22
- data/guides/getting-started/readme.md +48 -30
- data/guides/mounting-applications/readme.md +38 -0
- data/guides/response/readme.md +6 -6
- data/lennarb.gemspec +5 -2
- data/lib/lennarb/app.rb +253 -0
- data/lib/lennarb/base.rb +314 -0
- data/lib/lennarb/config.rb +52 -0
- data/lib/lennarb/constants.rb +12 -0
- data/lib/lennarb/environment.rb +80 -0
- data/lib/lennarb/errors.rb +17 -0
- data/lib/lennarb/helpers.rb +40 -0
- data/lib/lennarb/hooks.rb +71 -0
- data/lib/lennarb/logger.rb +100 -0
- data/lib/lennarb/middleware/request_logger.rb +117 -0
- data/lib/lennarb/middleware_stack.rb +56 -0
- data/lib/lennarb/parameter_filter.rb +76 -0
- data/lib/lennarb/request.rb +211 -33
- data/lib/lennarb/request_handler.rb +61 -0
- data/lib/lennarb/response.rb +34 -28
- data/lib/lennarb/route_node.rb +58 -4
- data/lib/lennarb/routes.rb +67 -0
- data/lib/lennarb/version.rb +2 -4
- data/lib/lennarb.rb +35 -67
- data/logo/lennarb.svg +11 -0
- data/readme.md +183 -34
- metadata +67 -7
- data/lib/lennarb/constansts.rb +0 -1
- data/logo/lennarb.png +0 -0
@@ -0,0 +1,117 @@
|
|
1
|
+
module Lennarb
|
2
|
+
module Middleware
|
3
|
+
# Request logger for the Lennarb framework
|
4
|
+
# Logs HTTP request details with color formatting
|
5
|
+
#
|
6
|
+
# @example Output:
|
7
|
+
# GET /users/123 (30ms)
|
8
|
+
# Status: 200 OK
|
9
|
+
# Params: {"id"=>"123", "password"=>"[FILTERED]"}
|
10
|
+
#
|
11
|
+
class RequestLogger
|
12
|
+
def initialize(app)
|
13
|
+
@app = app
|
14
|
+
end
|
15
|
+
|
16
|
+
# Get logger from application configuration
|
17
|
+
def logger = Lennarb::App.app.config.logger
|
18
|
+
|
19
|
+
# Process the request and log information
|
20
|
+
#
|
21
|
+
# @param [Hash] env Rack environment
|
22
|
+
# @return [Array] Rack response [status, headers, body]
|
23
|
+
def call(env)
|
24
|
+
request = Lennarb::Request.new(env)
|
25
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
26
|
+
|
27
|
+
status, headers, body = @app.call(env)
|
28
|
+
|
29
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
30
|
+
|
31
|
+
log_request(request, status, headers, duration)
|
32
|
+
|
33
|
+
[status, headers, body]
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
# Format duration in a human-readable way
|
39
|
+
#
|
40
|
+
# @param [Float] seconds Duration in seconds
|
41
|
+
# @return [String] Formatted duration
|
42
|
+
def format_duration(seconds)
|
43
|
+
if seconds < 1
|
44
|
+
"#{(seconds * 1000).round}ms"
|
45
|
+
elsif seconds < 60
|
46
|
+
format("%.2fs", seconds)
|
47
|
+
else
|
48
|
+
minutes = (seconds / 60).to_i
|
49
|
+
seconds = (seconds % 60).round
|
50
|
+
"#{minutes}m #{seconds}s"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Log the complete request
|
55
|
+
def log_request(request, status, headers, duration)
|
56
|
+
logger.info { request_line(request, duration, status) }
|
57
|
+
|
58
|
+
logger.info { status_line(status) }
|
59
|
+
|
60
|
+
if request.params.any?
|
61
|
+
logger.info { params_line(request.params) }
|
62
|
+
end
|
63
|
+
|
64
|
+
if headers["Location"]
|
65
|
+
logger.info { redirect_line(headers["Location"]) }
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Format the request line
|
70
|
+
def request_line(request, duration, status)
|
71
|
+
method = request.request_method
|
72
|
+
path = filter_path(request.path)
|
73
|
+
duration_text = "(#{format_duration(duration)})"
|
74
|
+
|
75
|
+
"#{method} #{path} #{duration_text}".colorize(status_to_color(status)).bold
|
76
|
+
end
|
77
|
+
|
78
|
+
# Format the status line
|
79
|
+
def status_line(status)
|
80
|
+
status_text = "#{status} #{Rack::Utils::HTTP_STATUS_CODES[status]}"
|
81
|
+
"Status: #{status_text}".colorize(status_to_color(status)).bold
|
82
|
+
end
|
83
|
+
|
84
|
+
# Format the parameters line
|
85
|
+
def params_line(params)
|
86
|
+
filtered = filter_params(params)
|
87
|
+
"Params: #{filtered.inspect}"
|
88
|
+
end
|
89
|
+
|
90
|
+
# Format the redirect line
|
91
|
+
def redirect_line(location)
|
92
|
+
"Redirect: #{location}".colorize(:yellow)
|
93
|
+
end
|
94
|
+
|
95
|
+
# Filter the request path
|
96
|
+
def filter_path(path)
|
97
|
+
path
|
98
|
+
end
|
99
|
+
|
100
|
+
# Filter request parameters
|
101
|
+
def filter_params(params)
|
102
|
+
ParameterFilter.new.filter(params)
|
103
|
+
end
|
104
|
+
|
105
|
+
# Determine color based on HTTP status
|
106
|
+
def status_to_color(status)
|
107
|
+
case status
|
108
|
+
when 200..299 then :green
|
109
|
+
when 300..399 then :yellow
|
110
|
+
when 400..499 then :magenta
|
111
|
+
when 500..599 then :red
|
112
|
+
else :white
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Lennarb
|
2
|
+
# Basic middleware stack implementation.
|
3
|
+
#
|
4
|
+
class MiddlewareStack
|
5
|
+
attr_reader :app
|
6
|
+
|
7
|
+
# The app's middleware stack.
|
8
|
+
#
|
9
|
+
# @param [Rack::Builder] app
|
10
|
+
#
|
11
|
+
def initialize(app = nil)
|
12
|
+
@app = app
|
13
|
+
@store = []
|
14
|
+
end
|
15
|
+
|
16
|
+
# Insert a middleware in the stack.
|
17
|
+
#
|
18
|
+
# @param [Class] middleware
|
19
|
+
# @param [Array] args
|
20
|
+
# @param [Proc] block
|
21
|
+
#
|
22
|
+
# @retrn [void]
|
23
|
+
#
|
24
|
+
def use(middleware, *args, &block)
|
25
|
+
@store << [middleware, args, block]
|
26
|
+
end
|
27
|
+
|
28
|
+
# Add a middleware to the beginning of the stack.
|
29
|
+
#
|
30
|
+
# @param [Class] middleware
|
31
|
+
# @param [Array] args
|
32
|
+
# @param [Proc] block
|
33
|
+
#
|
34
|
+
# @retrn [void]
|
35
|
+
#
|
36
|
+
def unshift(middleware, *args, &block)
|
37
|
+
@store.unshift([middleware, args, block])
|
38
|
+
end
|
39
|
+
|
40
|
+
# Clear the middleware stack.
|
41
|
+
#
|
42
|
+
# @retrn [void]
|
43
|
+
#
|
44
|
+
def clear
|
45
|
+
@store.clear
|
46
|
+
end
|
47
|
+
|
48
|
+
# Convert the middleware stack to an array.
|
49
|
+
#
|
50
|
+
# @retrn [Array]
|
51
|
+
#
|
52
|
+
def to_a
|
53
|
+
@store
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
module Lennarb
|
2
|
+
# Filtra parâmetros sensíveis de logs e exceções.
|
3
|
+
# Útil para evitar o vazamento de informações confidenciais.
|
4
|
+
#
|
5
|
+
# Por padrão, as seguintes chaves de parâmetros são filtradas:
|
6
|
+
#
|
7
|
+
# - `passw`
|
8
|
+
# - `email`
|
9
|
+
# - `secret`
|
10
|
+
# - `token`
|
11
|
+
# - `_key`
|
12
|
+
# - `crypt`
|
13
|
+
# - `salt`
|
14
|
+
# - `certificate`
|
15
|
+
# - `otp`
|
16
|
+
# - `ssn`
|
17
|
+
# - `cvv`
|
18
|
+
# - `cvc`
|
19
|
+
# - `signature`
|
20
|
+
#
|
21
|
+
# @example
|
22
|
+
# filter = Lennarb::ParameterFilter.new
|
23
|
+
# filter.filter({ password: "secret", user: { email: "test@example.com" } })
|
24
|
+
# # => { password: "[filtered]", user: { email: "[filtered]" } }
|
25
|
+
#
|
26
|
+
class ParameterFilter
|
27
|
+
# @api private
|
28
|
+
DEFAULT_MASK = "[FILTERED]"
|
29
|
+
|
30
|
+
# @api private
|
31
|
+
DEFAULT_FILTERS = %w[
|
32
|
+
passw email secret token _key crypt salt certificate otp ssn cvv cvc
|
33
|
+
signature
|
34
|
+
].freeze
|
35
|
+
|
36
|
+
# Inicializa um novo filtro de parâmetros
|
37
|
+
#
|
38
|
+
# @param [Array<String, Regexp>] filters Lista de padrões para filtrar
|
39
|
+
def initialize(filters = DEFAULT_FILTERS)
|
40
|
+
@filter = Regexp.union(filters.map(&:to_s))
|
41
|
+
end
|
42
|
+
|
43
|
+
# Filtra os parâmetros conforme o filtro configurado
|
44
|
+
#
|
45
|
+
# @param [Hash, Array] params Parâmetros a serem filtrados
|
46
|
+
# @param [String] mask Valor que substituirá os parâmetros filtrados
|
47
|
+
# @return [Hash, Array] Parâmetros filtrados
|
48
|
+
def filter(params, mask: DEFAULT_MASK)
|
49
|
+
filter_object(params.dup, mask)
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
# Filtra recursivamente um objeto (hash ou array)
|
55
|
+
#
|
56
|
+
# @param [Object] object Objeto a ser filtrado
|
57
|
+
# @param [String] mask Valor que substituirá os parâmetros filtrados
|
58
|
+
# @return [Object] Objeto filtrado
|
59
|
+
def filter_object(object, mask)
|
60
|
+
case object
|
61
|
+
when Hash
|
62
|
+
object.each do |key, value|
|
63
|
+
object[key] = if key.to_s.match?(@filter)
|
64
|
+
mask
|
65
|
+
else
|
66
|
+
filter_object(value, mask)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
when Array
|
70
|
+
object = object.map { filter_object(it, mask) }
|
71
|
+
end
|
72
|
+
|
73
|
+
object
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
data/lib/lennarb/request.rb
CHANGED
@@ -1,86 +1,264 @@
|
|
1
|
-
|
1
|
+
module Lennarb
|
2
|
+
# Request object
|
3
|
+
#
|
2
4
|
class Request < Rack::Request
|
3
5
|
# The environment variables of the request
|
4
6
|
#
|
5
|
-
# @
|
6
|
-
#
|
7
|
+
# @return [Hash]
|
7
8
|
attr_reader :env
|
8
9
|
|
9
10
|
# Initialize the request object
|
10
11
|
#
|
11
|
-
# @
|
12
|
-
# @
|
12
|
+
# @param [Hash] env
|
13
|
+
# @param [Hash] route_params
|
13
14
|
#
|
14
|
-
# @
|
15
|
+
# @return [Request]
|
15
16
|
#
|
16
17
|
def initialize(env, route_params = {})
|
17
18
|
super(env)
|
18
|
-
@route_params = route_params
|
19
|
+
@route_params = route_params || {}
|
19
20
|
end
|
20
21
|
|
21
|
-
# Get the request
|
22
|
+
# Get the request parameters merged with route parameters
|
22
23
|
#
|
23
|
-
# @
|
24
|
+
# @return [Hash]
|
24
25
|
#
|
25
|
-
def params
|
26
|
+
def params
|
27
|
+
@params ||= super.merge(@route_params)&.transform_keys(&:to_sym)
|
28
|
+
end
|
26
29
|
|
27
|
-
# Get the request path
|
30
|
+
# Get the request path without query string
|
28
31
|
#
|
29
|
-
# @
|
32
|
+
# @return [String]
|
30
33
|
#
|
31
|
-
def path
|
34
|
+
def path
|
35
|
+
@path ||= super.split("?").first
|
36
|
+
end
|
32
37
|
|
33
38
|
# Read the body of the request
|
34
39
|
#
|
35
|
-
# @
|
40
|
+
# @return [String]
|
36
41
|
#
|
37
|
-
def body
|
42
|
+
def body
|
43
|
+
@body ||= super.read
|
44
|
+
end
|
38
45
|
|
39
46
|
# Get the query parameters
|
40
47
|
#
|
41
|
-
# @
|
48
|
+
# @return [Hash]
|
42
49
|
#
|
43
50
|
def query_params
|
44
|
-
@query_params ||= Rack::Utils.parse_nested_query(query_string).transform_keys(&:to_sym)
|
51
|
+
@query_params ||= Rack::Utils.parse_nested_query(query_string || "").transform_keys(&:to_sym)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Set a value in the environment
|
55
|
+
#
|
56
|
+
# @param [String] key
|
57
|
+
# @param [Object] value
|
58
|
+
# @return [Object] the value
|
59
|
+
#
|
60
|
+
def []=(key, value)
|
61
|
+
env[key] = value
|
62
|
+
end
|
63
|
+
|
64
|
+
# Get a value from the environment
|
65
|
+
#
|
66
|
+
# @param [String] key
|
67
|
+
# @return [Object]
|
68
|
+
#
|
69
|
+
def [](key)
|
70
|
+
env[key]
|
45
71
|
end
|
46
72
|
|
47
73
|
# Get the headers of the request
|
48
74
|
#
|
75
|
+
# @return [Hash]
|
76
|
+
#
|
49
77
|
def headers
|
50
|
-
@headers ||= env.
|
78
|
+
@headers ||= env.each_with_object({}) do |(key, value), result|
|
79
|
+
if key.start_with?("HTTP_")
|
80
|
+
header_name = key.sub("HTTP_", "").split("_").map(&:capitalize).join("-")
|
81
|
+
result[header_name] = value
|
82
|
+
end
|
83
|
+
end
|
51
84
|
end
|
52
85
|
|
53
|
-
|
86
|
+
# Get the client IP address
|
87
|
+
#
|
88
|
+
# @return [String]
|
89
|
+
#
|
90
|
+
def ip
|
91
|
+
ip_address
|
92
|
+
end
|
54
93
|
|
55
|
-
|
94
|
+
# Check if the request is secure (HTTPS)
|
95
|
+
#
|
96
|
+
# @return [Boolean]
|
97
|
+
#
|
98
|
+
def secure?
|
99
|
+
scheme == "https"
|
100
|
+
end
|
56
101
|
|
57
|
-
|
102
|
+
# Shorthand methods for common headers
|
58
103
|
|
59
|
-
|
104
|
+
# Get the user agent
|
105
|
+
#
|
106
|
+
# @return [String, nil]
|
107
|
+
#
|
108
|
+
def user_agent
|
109
|
+
env["HTTP_USER_AGENT"]
|
110
|
+
end
|
60
111
|
|
61
|
-
|
112
|
+
# Get the accept header
|
113
|
+
#
|
114
|
+
# @return [String, nil]
|
115
|
+
#
|
116
|
+
def accept
|
117
|
+
env["HTTP_ACCEPT"]
|
118
|
+
end
|
62
119
|
|
63
|
-
|
120
|
+
# Get the referer header
|
121
|
+
#
|
122
|
+
# @return [String, nil]
|
123
|
+
#
|
124
|
+
def referer
|
125
|
+
env["HTTP_REFERER"]
|
126
|
+
end
|
64
127
|
|
65
|
-
|
128
|
+
# Get the host header
|
129
|
+
#
|
130
|
+
# @return [String, nil]
|
131
|
+
#
|
132
|
+
def host
|
133
|
+
env["HTTP_HOST"]
|
134
|
+
end
|
66
135
|
|
67
|
-
|
136
|
+
# Get the content length header
|
137
|
+
#
|
138
|
+
# @return [String, nil]
|
139
|
+
#
|
140
|
+
def content_length
|
141
|
+
env["HTTP_CONTENT_LENGTH"]
|
142
|
+
end
|
68
143
|
|
69
|
-
|
144
|
+
# Get the content type header
|
145
|
+
#
|
146
|
+
# @return [String, nil]
|
147
|
+
#
|
148
|
+
def content_type
|
149
|
+
env["HTTP_CONTENT_TYPE"]
|
150
|
+
end
|
70
151
|
|
71
|
-
|
72
|
-
|
152
|
+
# Check if the request is an XHR request
|
153
|
+
#
|
154
|
+
# @return [Boolean]
|
155
|
+
#
|
156
|
+
def xhr?
|
157
|
+
env["HTTP_X_REQUESTED_WITH"]&.casecmp("XMLHttpRequest")&.zero? || false
|
73
158
|
end
|
74
159
|
|
75
|
-
|
76
|
-
|
160
|
+
# Check if the request is a JSON request
|
161
|
+
#
|
162
|
+
# @return [Boolean]
|
163
|
+
#
|
164
|
+
def json?
|
165
|
+
content_type&.include?("application/json")
|
166
|
+
end
|
167
|
+
|
168
|
+
# Parse JSON body if content type is application/json
|
169
|
+
#
|
170
|
+
# @return [Hash, nil]
|
171
|
+
#
|
172
|
+
def json_body
|
173
|
+
return nil unless json?
|
174
|
+
@json_body ||= begin
|
175
|
+
JSON.parse(body, symbolize_names: true)
|
176
|
+
rescue JSON::ParserError
|
177
|
+
nil
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# Check if the request is an AJAX request (alias for xhr?)
|
182
|
+
#
|
183
|
+
# @return [Boolean]
|
184
|
+
#
|
185
|
+
def ajax?
|
186
|
+
xhr?
|
187
|
+
end
|
188
|
+
|
189
|
+
# Get the requested format (.html, .json, etc)
|
190
|
+
#
|
191
|
+
# @return [Symbol, nil]
|
192
|
+
#
|
193
|
+
def format
|
194
|
+
@format ||= begin
|
195
|
+
path_info = env["PATH_INFO"]
|
196
|
+
return nil unless path_info.include?(".")
|
197
|
+
|
198
|
+
extension = File.extname(path_info).delete(".")
|
199
|
+
extension.empty? ? nil : extension.to_sym
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
# Check if the request is a GET request
|
204
|
+
#
|
205
|
+
# @return [Boolean]
|
206
|
+
#
|
207
|
+
def get?
|
208
|
+
request_method == "GET"
|
209
|
+
end
|
210
|
+
|
211
|
+
# Check if the request is a POST request
|
212
|
+
#
|
213
|
+
# @return [Boolean]
|
214
|
+
#
|
215
|
+
def post?
|
216
|
+
request_method == "POST"
|
217
|
+
end
|
218
|
+
|
219
|
+
# Check if the request is a PUT request
|
220
|
+
#
|
221
|
+
# @return [Boolean]
|
222
|
+
#
|
223
|
+
def put?
|
224
|
+
request_method == "PUT"
|
225
|
+
end
|
226
|
+
|
227
|
+
# Check if the request is a DELETE request
|
228
|
+
#
|
229
|
+
# @return [Boolean]
|
230
|
+
#
|
231
|
+
def delete?
|
232
|
+
request_method == "DELETE"
|
233
|
+
end
|
234
|
+
|
235
|
+
# Check if the request is a HEAD request
|
236
|
+
#
|
237
|
+
# @return [Boolean]
|
238
|
+
#
|
239
|
+
def head?
|
240
|
+
request_method == "HEAD"
|
241
|
+
end
|
242
|
+
|
243
|
+
# Check if the request is a PATCH request
|
244
|
+
#
|
245
|
+
# @return [Boolean]
|
246
|
+
#
|
247
|
+
def patch?
|
248
|
+
request_method == "PATCH"
|
77
249
|
end
|
78
250
|
|
79
251
|
private
|
80
252
|
|
253
|
+
# Get the client IP address
|
254
|
+
#
|
255
|
+
# @return [String]
|
256
|
+
#
|
81
257
|
def ip_address
|
82
|
-
forwarded_for =
|
83
|
-
|
258
|
+
forwarded_for = env["HTTP_X_FORWARDED_FOR"]
|
259
|
+
return forwarded_for.split(",").map(&:strip).first if forwarded_for
|
260
|
+
|
261
|
+
env["REMOTE_ADDR"]
|
84
262
|
end
|
85
263
|
end
|
86
264
|
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Lennarb
|
2
|
+
# Handles requests and executes routes with helpers and hooks.
|
3
|
+
#
|
4
|
+
class RequestHandler
|
5
|
+
attr_reader :app
|
6
|
+
|
7
|
+
# Initialize the request handler
|
8
|
+
#
|
9
|
+
# @param [Lennarb::App] app The application instance
|
10
|
+
def initialize(app)
|
11
|
+
@app = app
|
12
|
+
end
|
13
|
+
|
14
|
+
# Handle a request according to Rack interface
|
15
|
+
#
|
16
|
+
# @param [Hash] env The Rack environment
|
17
|
+
# @return [Array] Rack response [status, headers, body]
|
18
|
+
def call(env)
|
19
|
+
http_method = env[Rack::REQUEST_METHOD].to_sym
|
20
|
+
parts = env[Rack::PATH_INFO].split("/").reject(&:empty?)
|
21
|
+
block, params = app.routes.match_route(parts, http_method)
|
22
|
+
|
23
|
+
return [404, {"content-type" => CONTENT_TYPE[:TEXT]}, ["Not Found"]] unless block
|
24
|
+
|
25
|
+
req = Request.new(env, params || {})
|
26
|
+
res = Response.new
|
27
|
+
|
28
|
+
catch(:halt) do
|
29
|
+
context = create_context
|
30
|
+
|
31
|
+
Hooks.execute(context, app.class, :before, req, res)
|
32
|
+
|
33
|
+
context.instance_exec(req, res, params, &block)
|
34
|
+
|
35
|
+
Hooks.execute(context, app.class, :after, req, res)
|
36
|
+
|
37
|
+
res.finish
|
38
|
+
rescue Lennarb::Error => e
|
39
|
+
app.class.config.logger.error("Error: #{e.message}")
|
40
|
+
app.class.config.logger.error(e.backtrace.first)
|
41
|
+
[500, {"content-type" => "text/plain"}, ["Internal Server Error"]]
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
# Create a context object with app's helper methods
|
48
|
+
#
|
49
|
+
# @return [Object] A context object with helper methods
|
50
|
+
def create_context
|
51
|
+
context = Object.new
|
52
|
+
|
53
|
+
context.define_singleton_method(:app) { app }
|
54
|
+
|
55
|
+
helpers_module = Helpers.for(app.class)
|
56
|
+
context.extend(helpers_module) if helpers_module
|
57
|
+
|
58
|
+
context
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|