pakyow-core 0.8rc1 → 0.8.rc4

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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/pakyow-core/lib/core/app.rb +448 -0
  3. data/pakyow-core/lib/core/base.rb +35 -12
  4. data/pakyow-core/lib/core/{configuration → config}/app.rb +38 -35
  5. data/pakyow-core/lib/core/config/base.rb +30 -0
  6. data/pakyow-core/lib/core/config/cookies.rb +21 -0
  7. data/pakyow-core/lib/core/config/logger.rb +37 -0
  8. data/pakyow-core/lib/core/{configuration → config}/server.rb +3 -1
  9. data/pakyow-core/lib/core/exceptions.rb +3 -0
  10. data/pakyow-core/lib/core/helpers.rb +20 -16
  11. data/pakyow-core/lib/core/loader.rb +1 -1
  12. data/pakyow-core/lib/core/middleware/logger.rb +170 -16
  13. data/pakyow-core/lib/core/middleware/static.rb +20 -8
  14. data/pakyow-core/lib/core/multilog.rb +19 -0
  15. data/pakyow-core/lib/core/request.rb +52 -30
  16. data/pakyow-core/lib/core/route_eval.rb +390 -0
  17. data/pakyow-core/lib/core/route_lookup.rb +17 -5
  18. data/pakyow-core/lib/core/route_set.rb +17 -210
  19. data/pakyow-core/lib/core/route_template_defaults.rb +18 -12
  20. data/pakyow-core/lib/core/router.rb +41 -31
  21. data/pakyow-core/lib/utils/dir.rb +19 -0
  22. data/pakyow-core/lib/utils/hash.rb +14 -4
  23. data/pakyow-core/lib/views/errors/404.html +77 -0
  24. data/pakyow-core/lib/views/errors/500.html +56 -0
  25. metadata +30 -53
  26. data/pakyow-core/bin/pakyow +0 -18
  27. data/pakyow-core/lib/commands/USAGE +0 -9
  28. data/pakyow-core/lib/commands/USAGE-CONSOLE +0 -12
  29. data/pakyow-core/lib/commands/USAGE-NEW +0 -11
  30. data/pakyow-core/lib/commands/USAGE-SERVER +0 -12
  31. data/pakyow-core/lib/commands/console.rb +0 -18
  32. data/pakyow-core/lib/commands/server.rb +0 -8
  33. data/pakyow-core/lib/core/application.rb +0 -330
  34. data/pakyow-core/lib/core/cache.rb +0 -25
  35. data/pakyow-core/lib/core/configuration/base.rb +0 -31
  36. data/pakyow-core/lib/core/fn_context.rb +0 -5
  37. data/pakyow-core/lib/core/log.rb +0 -39
  38. data/pakyow-core/lib/core/middleware/not_found.rb +0 -40
  39. data/pakyow-core/lib/core/middleware/presenter.rb +0 -25
  40. data/pakyow-core/lib/core/middleware/router.rb +0 -33
  41. data/pakyow-core/lib/core/middleware/setup.rb +0 -15
  42. data/pakyow-core/lib/core/presenter_base.rb +0 -11
  43. data/pakyow-core/lib/core/route_template.rb +0 -77
  44. data/pakyow-core/lib/generators/pakyow/app/app_generator.rb +0 -36
  45. data/pakyow-core/lib/generators/pakyow/app/templates/README +0 -54
  46. data/pakyow-core/lib/generators/pakyow/app/templates/app.rb +0 -12
  47. data/pakyow-core/lib/generators/pakyow/app/templates/config.ru +0 -3
  48. data/pakyow-core/lib/generators/pakyow/app/templates/public/favicon.ico +0 -0
  49. data/pakyow-core/lib/generators/pakyow/app/templates/rakefile +0 -2
  50. data/pakyow-core/lib/generators/pakyow/app/templates/views/main.html +0 -1
  51. data/pakyow-core/lib/generators/pakyow/app/templates/views/pakyow.html +0 -12
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 6603c6f6c576749182546ee51b5f99a237ba2ab2
4
+ data.tar.gz: a986e3adf2e4c33a16e8565b48984947bc1e00e5
5
+ SHA512:
6
+ metadata.gz: 13942daed315f9bf380e9ff718d07458c89d630918d781377117e11aec1dfcab916c5692c769e6148ea37ec74e931c402ae303be62db75f2a77005b293b95d5c
7
+ data.tar.gz: 81cddf4aadc79a683129e3420cdf95e42f1e863cbfc123dbe93b92685ac830e533f3f76ca0cae149cc02b4300c5c9a5cb08419023763c7192f5b234ed07da3cf
@@ -0,0 +1,448 @@
1
+ module Pakyow
2
+ class App
3
+ class << self
4
+ def reset
5
+ @@routes = {}
6
+ @@config = {}
7
+
8
+ @@stacks = {:before => {}, :after => {}}
9
+ %w(init load process route match error).each {|name|
10
+ @@stacks[:before][name.to_sym] = []
11
+ @@stacks[:after][name.to_sym] = []
12
+ }
13
+ end
14
+
15
+ # Defines an app
16
+ #
17
+ def define(&block)
18
+ # sets the path to the app file so it can be reloaded later
19
+ config.app.path = StringUtils.parse_path_from_caller(caller[0])
20
+
21
+ self.instance_eval(&block)
22
+ end
23
+
24
+ # Defines a route set.
25
+ #
26
+ #TODO default route set should be config option (also for bindings)
27
+ def routes(set_name = :main, &block)
28
+ if set_name && block
29
+ @@routes[set_name] = block
30
+ else
31
+ @@routes
32
+ end
33
+ end
34
+
35
+ # Defines middleware to be loaded.
36
+ #
37
+ def middleware(&block)
38
+ # prevents middleware from being loaded on each
39
+ # request when auto_reload is enabled
40
+ return if prepared?
41
+
42
+ # tell builder about the middleware
43
+ builder.instance_eval(&block)
44
+ end
45
+
46
+ # Creates an environment.
47
+ #
48
+ def configure(env, &block)
49
+ @@config[env] = block
50
+ end
51
+
52
+ # Fetches a stack (before | after) by name.
53
+ #
54
+ def stack(which, name)
55
+ @@stacks[which][name]
56
+ end
57
+
58
+ # Adds a block to the before stack for `stack_name`.
59
+ #
60
+ def before(stack_name, &block)
61
+ @@stacks[:before][stack_name.to_sym] << block
62
+ end
63
+
64
+ # Adds a block to the after stack for `stack_name`.
65
+ #
66
+ def after(stack_name, &block)
67
+ @@stacks[:after][stack_name.to_sym] << block
68
+ end
69
+
70
+ # Runs the application. Accepts the environment(s) to run, for example:
71
+ # run(:development)
72
+ # run([:development, :staging])
73
+ #
74
+ def run(*args)
75
+ return if running?
76
+
77
+ @running = true
78
+ builder.run(prepare(args))
79
+ detect_handler.run(builder, :Host => config.server.host, :Port => config.server.port) do |server|
80
+ trap(:INT) { stop(server) }
81
+ trap(:TERM) { stop(server) }
82
+ end
83
+ end
84
+
85
+ # Stages the application. Everything is loaded but the application is
86
+ # not started. Accepts the same arguments as #run.
87
+ #
88
+ def stage(*args)
89
+ return if staged?
90
+ @staged = true
91
+ prepare(args)
92
+ end
93
+
94
+ def builder
95
+ @builder ||= Rack::Builder.new
96
+ end
97
+
98
+ def prepared?
99
+ @prepared
100
+ end
101
+
102
+ # Returns true if the application is running.
103
+ #
104
+ def running?
105
+ @running
106
+ end
107
+
108
+ # Returns true if the application is staged.
109
+ #
110
+ def staged?
111
+ @staged
112
+ end
113
+
114
+ # Convenience method for base configuration class.
115
+ #
116
+ def config
117
+ Pakyow::Config::Base
118
+ end
119
+
120
+ def load_config(envs)
121
+ # run global config first
122
+ if global_proc = @@config[:global]
123
+ config.instance_eval(&global_proc)
124
+ end
125
+
126
+ # then run other envs
127
+
128
+ envs.each do |env|
129
+ next unless config_proc = @@config[env.to_sym]
130
+ config.instance_eval(&config_proc)
131
+ end
132
+
133
+ config.app.loaded_envs = envs
134
+ end
135
+
136
+ protected
137
+
138
+ # Prepares the application for running or staging and returns an instance
139
+ # of the application.
140
+ def prepare(envs)
141
+ return if prepared?
142
+
143
+ # configure
144
+ envs = envs.empty? || envs.first.nil? ? [config.app.default_environment] : envs
145
+ load_config(envs)
146
+
147
+ # load middleware
148
+ builder.use(Rack::MethodOverride)
149
+ builder.use(Middleware::Static) if config.app.static
150
+ builder.use(Middleware::Logger) if config.app.log
151
+ builder.use(Middleware::Reloader) if config.app.auto_reload
152
+
153
+ @prepared = true
154
+
155
+ $:.unshift(Dir.pwd) unless $:.include? Dir.pwd
156
+
157
+ return self.new
158
+ end
159
+
160
+ def detect_handler
161
+ handlers = ['puma', 'thin', 'mongrel', 'webrick']
162
+ handlers.unshift(config.server.handler) if config.server.handler
163
+
164
+ handlers.each do |handler|
165
+ begin
166
+ return Rack::Handler.get(handler)
167
+ rescue LoadError
168
+ rescue NameError
169
+ end
170
+ end
171
+ end
172
+
173
+ def stop(server)
174
+ if server.respond_to?('stop!')
175
+ server.stop!
176
+ elsif server.respond_to?('stop')
177
+ server.stop
178
+ else
179
+ # exit ungracefully if necessary...
180
+ Process.exit!
181
+ end
182
+ end
183
+ end
184
+
185
+ include Helpers
186
+ include AppHelpers
187
+
188
+ attr_accessor :request, :response
189
+
190
+ def initialize
191
+ Pakyow.app = self
192
+ Pakyow.configure_logger
193
+
194
+ call_stack(:before, :init)
195
+
196
+ load_app
197
+
198
+ call_stack(:after, :init)
199
+ end
200
+
201
+ # Returns the primary (first) loaded env.
202
+ #
203
+ def env
204
+ config.app.loaded_envs[0]
205
+ end
206
+
207
+ def app
208
+ self
209
+ end
210
+
211
+ def call(env)
212
+ dup.process(env)
213
+ end
214
+
215
+ # Called on every request.
216
+ #
217
+ def process(env)
218
+ call_stack(:before, :process)
219
+
220
+ @response = Response.new
221
+ @request = Request.new(env)
222
+ @request.app = self
223
+ @request.setup
224
+
225
+ set_initial_cookies
226
+
227
+ @found = false
228
+ catch(:halt) {
229
+ unless config.app.ignore_routes
230
+ call_stack(:before, :route)
231
+
232
+ @found = @router.perform(@request, self) {
233
+ call_stack(:after, :match)
234
+ }
235
+
236
+ call_stack(:after, :route)
237
+ end
238
+
239
+ unless found?
240
+ handle(404, false)
241
+
242
+ if config.app.errors_in_browser
243
+ @response["Content-Type"] = 'text/html'
244
+
245
+ view_file = File.join(File.expand_path('../../', __FILE__), 'views', 'errors', '404.html')
246
+ content = File.open(view_file).read
247
+
248
+ path = StringUtils.normalize_path(request.path)
249
+ path = '/' if path.empty?
250
+
251
+ content.gsub!('{view_path}', path == '/' ? 'index.html' : "#{path}.html")
252
+ content.gsub!('{route_path}', path)
253
+
254
+ @response.body = []
255
+ @response.body << content
256
+ end
257
+ end
258
+ }
259
+
260
+ set_cookies
261
+
262
+ call_stack(:after, :process)
263
+
264
+ @response.finish
265
+ rescue StandardError => error
266
+ call_stack(:before, :error)
267
+
268
+ @request.error = error
269
+
270
+ handle(500, false) unless found?
271
+
272
+ if config.app.errors_in_browser
273
+ @response["Content-Type"] = 'text/html'
274
+
275
+ view_file = File.join(File.expand_path('../../', __FILE__), 'views', 'errors', '500.html')
276
+ content = File.open(view_file).read
277
+
278
+ path = StringUtils.normalize_path(request.path)
279
+ path = '/' if path.empty?
280
+
281
+ nice_source = error.backtrace[0].match(/^(.+?):(\d+)(|:in `(.+)')$/)
282
+
283
+ content.gsub!('{file}', nice_source[1].gsub(File.expand_path(Config::App.root) + '/', ''))
284
+ content.gsub!('{line}', nice_source[2])
285
+
286
+ content.gsub!('{msg}', error.to_s)
287
+ content.gsub!('{trace}', error.backtrace.join('<br>'))
288
+
289
+ @response.body = []
290
+ @response.body << content
291
+ end
292
+
293
+ call_stack(:after, :error)
294
+
295
+ @response.finish
296
+ end
297
+
298
+ def found?
299
+ @found
300
+ end
301
+
302
+ # This is NOT a useless method, it's a part of the external api
303
+ def reload
304
+ # reload the app file
305
+ load(config.app.path)
306
+
307
+ # reset config
308
+ envs = config.app.loaded_envs
309
+ config.reset!
310
+
311
+ # reload config
312
+ self.class.load_config(envs)
313
+
314
+ load_app
315
+ end
316
+
317
+ # APP ACTIONS
318
+
319
+ # Interrupts the application and returns response immediately.
320
+ #
321
+ def halt
322
+ throw :halt, @response
323
+ end
324
+
325
+ # Routes the request to different logic.
326
+ #
327
+ def reroute(path, method = nil)
328
+ @request.setup(path, method)
329
+
330
+ call_stack(:before, :route)
331
+ call_stack(:after, :match)
332
+ @router.reroute(@request)
333
+ call_stack(:after, :route)
334
+ end
335
+
336
+ # Sends data in the response (immediately). Accepts a string of data or a File,
337
+ # mime-type (auto-detected; defaults to octet-stream), and optional file name.
338
+ #
339
+ # If a File, mime type will be guessed. Otherwise mime type and file name will
340
+ # default to whatever is set in the response.
341
+ #
342
+ def send(file_or_data, type = nil, send_as = nil)
343
+ if file_or_data.class == File
344
+ data = file_or_data.read
345
+
346
+ # auto set type based on file type
347
+ type = Rack::Mime.mime_type("." + StringUtils.split_at_last_dot(file_or_data.path)[1])
348
+ else
349
+ data = file_or_data
350
+ end
351
+
352
+ headers = {}
353
+ headers["Content-Type"] = type if type
354
+ headers["Content-disposition"] = "attachment; filename=#{send_as}" if send_as
355
+
356
+ @response = Response.new(data, @response.status, @response.header.merge(headers))
357
+ halt
358
+ end
359
+
360
+ # Redirects to location (immediately).
361
+ #
362
+ def redirect(location, status_code = 302)
363
+ location = router.path(location) if location.is_a?(Symbol)
364
+
365
+ headers = response ? response.header : {}
366
+ headers = headers.merge({'Location' => location})
367
+
368
+ app.response = Response.new('', status_code, headers)
369
+ halt
370
+ end
371
+
372
+ def handle(name_or_code, from_logic = true)
373
+ call_stack(:before, :route)
374
+ @router.handle(name_or_code, self, from_logic)
375
+ call_stack(:after, :route)
376
+ end
377
+
378
+ # Convenience method for defining routes on an app instance.
379
+ #
380
+ def routes(set_name = :main, &block)
381
+ self.class.routes(set_name, &block)
382
+ load_routes
383
+ end
384
+
385
+ protected
386
+
387
+ def call_stack(which, stack)
388
+ self.class.stack(which, stack).each {|block|
389
+ self.instance_exec(&block)
390
+ }
391
+ end
392
+
393
+ # Reloads all application files in path and presenter (if specified).
394
+ #
395
+ def load_app
396
+ call_stack(:before, :load)
397
+
398
+ # load src files
399
+ @loader = Loader.new
400
+ @loader.load_from_path(config.app.src_dir)
401
+
402
+ # load the routes
403
+ load_routes
404
+
405
+ call_stack(:after, :load)
406
+ end
407
+
408
+ def load_routes
409
+ @router = Router.instance.reset
410
+ self.class.routes.each_pair {|set_name, block|
411
+ @router.set(set_name, &block)
412
+ }
413
+ end
414
+
415
+ def set_cookies
416
+ @request.cookies.each_pair {|k, v|
417
+ @response.unset_cookie(k) if v.nil?
418
+
419
+ # cookie is already set with value, ignore
420
+ next if @initial_cookies.include?(k.to_s) && @initial_cookies[k.to_s] == v
421
+
422
+ # set cookie with defaults
423
+ @response.set_cookie(k, {
424
+ :path => config.cookies.path,
425
+ :expires => config.cookies.expiration,
426
+ :value => v
427
+ })
428
+ }
429
+
430
+ # delete cookies that are no longer present
431
+ @initial_cookies.each {|k|
432
+ @response.delete_cookie(k) unless @request.cookies.key?(k.to_s)
433
+ }
434
+ end
435
+
436
+ # Stores set cookies at beginning of request cycle
437
+ # for comparison at the end of the cycle
438
+ def set_initial_cookies
439
+ @initial_cookies = {}
440
+ @request.cookies.each {|k,v|
441
+ @initial_cookies[k] = v
442
+ }
443
+ end
444
+
445
+ end
446
+
447
+ App.reset
448
+ end
@@ -1,27 +1,25 @@
1
- require 'core/configuration/base'
1
+ require 'core/config/base'
2
+ require 'core/config/app'
3
+ require 'core/config/server'
4
+ require 'core/config/cookies'
5
+ require 'core/config/logger'
2
6
  require 'core/helpers'
3
- require 'core/log'
7
+ require 'core/multilog'
4
8
  require 'core/request'
5
9
  require 'core/response'
6
10
  require 'core/loader'
7
11
  require 'core/router'
8
12
  require 'core/route_set'
9
- require 'core/route_template'
13
+ require 'core/route_eval'
10
14
  require 'core/route_template_defaults'
11
15
  require 'core/route_lookup'
12
- require 'core/application'
13
- require 'core/cache'
14
- require 'core/presenter_base'
15
- require 'core/fn_context'
16
+ require 'core/app'
17
+ require 'core/exceptions'
16
18
 
17
19
  # middlewares
18
20
  require 'core/middleware/logger'
19
21
  require 'core/middleware/static'
20
22
  require 'core/middleware/reloader'
21
- require 'core/middleware/presenter'
22
- require 'core/middleware/not_found'
23
- require 'core/middleware/router'
24
- require 'core/middleware/setup'
25
23
 
26
24
  # utils
27
25
  require 'utils/string'
@@ -29,5 +27,30 @@ require 'utils/hash'
29
27
  require 'utils/dir'
30
28
 
31
29
  module Pakyow
32
- attr_accessor :app
30
+ attr_accessor :app, :logger
31
+
32
+ def configure_logger
33
+ conf = Config::Base
34
+
35
+ logs = []
36
+
37
+ if File.directory?(conf.logger.path)
38
+ log_path = File.join(conf.logger.path, conf.logger.name)
39
+
40
+ begin
41
+ log = File.open(log_path, 'a')
42
+ log.sync if conf.logger.sync
43
+
44
+ logs << log
45
+ rescue StandardError => e
46
+ warn "Error opening '#{log_path}' for writing"
47
+ end
48
+ end
49
+
50
+ logs << $stdout if conf.app.log_output
51
+
52
+ io = logs.count > 1 ? MultiLog.new(*logs) : logs[0]
53
+
54
+ Pakyow.logger = Logger.new(io, conf.logger.level, conf.logger.colorize, conf.logger.auto_flush)
55
+ end
33
56
  end
@@ -1,59 +1,58 @@
1
1
  module Pakyow
2
- module Configuration
2
+ module Config
3
3
  class App
4
+ Config::Base.register_config(:app, self)
5
+
4
6
  class << self
5
- attr_accessor :dev_mode, :log, :public_dir, :root, :log_dir,
6
- :presenter, :default_action, :ignore_routes, :error_level,
7
- :default_environment, :application_path, :log_name, :src_dir,
8
- :auto_reload, :errors_in_browser, :static, :all_views_visible
9
-
10
- # Displays development-specific warnings.
11
- #
12
- def dev_mode
13
- @dev_mode.nil? ? true : @dev_mode
7
+ attr_accessor :log, :resources, :root, :default_action, :ignore_routes,
8
+ :default_environment, :path, :src_dir, :auto_reload, :errors_in_browser,
9
+ :static, :all_views_visible, :loaded_envs, :log_output
10
+
11
+ def method_missing(name, *args)
12
+ if name[-1,1] == '='
13
+ name = name[0..-2]
14
+ instance_variable_set("@#{name}", *args)
15
+ else
16
+ instance_variable_get("@#{name}")
17
+ end
14
18
  end
15
-
19
+
16
20
  def auto_reload
17
21
  @auto_reload.nil? ? true : @auto_reload
18
22
  end
19
-
23
+
20
24
  def errors_in_browser
21
25
  @errors_in_browser.nil? ? true : @errors_in_browser
22
26
  end
23
-
27
+
24
28
  # Log requests?
25
29
  def log
26
30
  @log.nil? ? true : @log
27
31
  end
28
-
32
+
33
+ def log_output
34
+ @log_output || true
35
+ end
36
+
29
37
  # Root directory
30
38
  def root
31
39
  @root || File.dirname('')
32
40
  end
33
-
34
- # Public directory
35
- def public_dir
36
- @public_dir || "#{root}/public"
37
- end
38
-
39
- # Log directory
40
- def log_dir
41
- @log_dir || "#{root}/logs"
42
- end
43
41
 
44
- def log_name
45
- @log_name || "requests.log"
42
+ # Resources directory
43
+ def resources
44
+ @resources ||= { :default => "#{root}/public" }
46
45
  end
47
46
 
48
47
  def src_dir
49
- @src_dir || "#{root}/lib"
48
+ @src_dir || "#{root}/app/lib"
50
49
  end
51
-
50
+
52
51
  # Default action
53
52
  def default_action
54
53
  @default_action || :index
55
54
  end
56
-
55
+
57
56
  # Mockup mode
58
57
  def ignore_routes
59
58
  @ignore_routes.nil? ? false : @ignore_routes
@@ -62,24 +61,28 @@ module Pakyow
62
61
  def all_views_visible
63
62
  @all_views_visible.nil? ? true : @all_views_visible
64
63
  end
65
-
64
+
66
65
  def default_environment
67
66
  @default_environment || :development
68
67
  end
69
-
68
+
70
69
  # The path to the application class
71
- def application_path
72
- @application_path
70
+ def path
71
+ @path
73
72
  end
74
-
73
+
75
74
  # Handle static files?
76
75
  #
77
- # For best performance, should be set to false if static files are
76
+ # For best performance, should be set to false if static files are
78
77
  # handled by a web server (e.g. Nginx)
79
78
  #
80
79
  def static
81
80
  @static || true
82
81
  end
82
+
83
+ def loaded_envs
84
+ @loaded_envs
85
+ end
83
86
  end
84
87
  end
85
88
  end
@@ -0,0 +1,30 @@
1
+ module Pakyow
2
+ module Config
3
+ class Base
4
+ @@configs = {}
5
+ def self.register_config(name, klass)
6
+ @@configs[name] = klass
7
+
8
+ define_singleton_method name do
9
+ @@configs[name]
10
+ end
11
+ end
12
+
13
+ # Resets all config
14
+ def self.reset!
15
+ @@configs.keys.each do |type|
16
+ klass = self.send(type.to_sym)
17
+ klass.instance_variables.each do |var|
18
+ # Assumes path shouldn't be reset, since it's only set
19
+ # once when Pakyow::Application is inherited.
20
+ next if var.to_sym == :'@path'
21
+ begin
22
+ klass.send("#{var.to_s.gsub('@', '')}=", nil)
23
+ rescue
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ module Pakyow
2
+ module Config
3
+ class Cookies
4
+ Config::Base.register_config(:cookies, self)
5
+
6
+ class << self
7
+ attr_accessor :path, :expiration
8
+
9
+ # What path should the cookie be created for?
10
+ def path
11
+ @path || '/'
12
+ end
13
+
14
+ # When does the cookie expire?
15
+ def expiration
16
+ @expiration || Time.now + 60 * 60 * 24 * 7 # one week
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end