jets 2.3.19 → 3.0.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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/.gitmodules +0 -3
  3. data/.python-version +1 -1
  4. data/.ruby-version +1 -1
  5. data/CHANGELOG.md +28 -2
  6. data/README.md +2 -2
  7. data/backers.md +1 -0
  8. data/jets.gemspec +11 -10
  9. data/lib/jets.rb +9 -13
  10. data/lib/jets/application.rb +9 -2
  11. data/lib/jets/application/defaults.rb +17 -15
  12. data/lib/jets/autoloaders.rb +15 -1
  13. data/lib/jets/booter.rb +3 -3
  14. data/lib/jets/builders/code_builder.rb +16 -15
  15. data/lib/jets/builders/gem_replacer.rb +3 -16
  16. data/lib/jets/builders/lambda_layer.rb +4 -5
  17. data/lib/jets/builders/ruby_packager.rb +18 -42
  18. data/lib/jets/builders/tidy.rb +1 -2
  19. data/lib/jets/bundle.rb +6 -0
  20. data/lib/jets/cfn/builders/api_gateway_builder.rb +61 -7
  21. data/lib/jets/cfn/ship.rb +2 -1
  22. data/lib/jets/cli.rb +6 -1
  23. data/lib/jets/commands/base.rb +1 -1
  24. data/lib/jets/commands/call.rb +14 -1
  25. data/lib/jets/commands/clean.rb +1 -1
  26. data/lib/jets/commands/clean/base.rb +1 -1
  27. data/lib/jets/commands/configure.rb +51 -0
  28. data/lib/jets/commands/delete.rb +2 -2
  29. data/lib/jets/commands/gems.rb +2 -10
  30. data/lib/jets/commands/help/call.md +8 -0
  31. data/lib/jets/commands/help/gems/check.md +3 -5
  32. data/lib/jets/commands/main.rb +9 -1
  33. data/lib/jets/commands/new.rb +9 -1
  34. data/lib/jets/commands/sequence.rb +6 -0
  35. data/lib/jets/commands/templates/skeleton/Gemfile.tt +1 -1
  36. data/lib/jets/commands/templates/skeleton/config/application.rb.tt +1 -1
  37. data/lib/jets/commands/templates/skeleton/public/index.html.tt +1 -1
  38. data/lib/jets/commands/url.rb +1 -0
  39. data/lib/jets/controller/base.rb +14 -4
  40. data/lib/jets/controller/middleware/main.rb +2 -1
  41. data/lib/jets/controller/params.rb +26 -4
  42. data/lib/jets/controller/rack/env.rb +18 -1
  43. data/lib/jets/controller/rendering.rb +5 -2
  44. data/lib/jets/controller/rendering/rack_renderer.rb +7 -1
  45. data/lib/jets/core.rb +12 -4
  46. data/lib/jets/dotenv/ssm.rb +18 -4
  47. data/lib/jets/generator.rb +2 -3
  48. data/lib/jets/internal/app/functions/jets/base_path.rb +10 -149
  49. data/lib/jets/internal/app/functions/jets/base_path_mapping.rb +81 -0
  50. data/lib/jets/internal/app/shared/functions/jets/s3_bucket_config.rb +14 -24
  51. data/lib/jets/resource/api_gateway/base_path/function.rb +6 -1
  52. data/lib/jets/resource/api_gateway/deployment.rb +2 -0
  53. data/lib/jets/resource/api_gateway/rest_api/logical_id.rb +34 -0
  54. data/lib/jets/resource/api_gateway/rest_api/logical_id/message.rb +49 -0
  55. data/lib/jets/resource/child_stack/api_deployment.rb +2 -0
  56. data/lib/jets/resource/lambda/function.rb +1 -1
  57. data/lib/jets/router/dsl.rb +7 -1
  58. data/lib/jets/router/method_creator/code.rb +1 -1
  59. data/lib/jets/router/scope.rb +7 -3
  60. data/lib/jets/spec_helpers/controllers.rb +10 -3
  61. data/lib/jets/spec_helpers/controllers/request.rb +12 -5
  62. data/lib/jets/stack/main/dsl/lambda.rb +1 -1
  63. data/lib/jets/turbo.rb +1 -0
  64. data/lib/jets/version.rb +1 -1
  65. metadata +51 -58
  66. data/vendor/cfn-status/CHANGELOG.md +0 -14
  67. data/vendor/cfn-status/Gemfile +0 -4
  68. data/vendor/cfn-status/LICENSE.txt +0 -21
  69. data/vendor/cfn-status/README.md +0 -56
  70. data/vendor/cfn-status/Rakefile +0 -6
  71. data/vendor/cfn-status/bin/console +0 -14
  72. data/vendor/cfn-status/bin/setup +0 -8
  73. data/vendor/cfn-status/cfn-status.gemspec +0 -30
  74. data/vendor/cfn-status/lib/cfn-status.rb +0 -1
  75. data/vendor/cfn-status/lib/cfn_status.rb +0 -245
  76. data/vendor/cfn-status/lib/cfn_status/aws_service.rb +0 -51
  77. data/vendor/cfn-status/lib/cfn_status/version.rb +0 -3
  78. data/vendor/cfn-status/spec/fixtures/cfn/pages/fresh/describe_stack_events-1.json +0 -1103
  79. data/vendor/cfn-status/spec/fixtures/cfn/pages/fresh/describe_stack_events-2.json +0 -1104
  80. data/vendor/cfn-status/spec/fixtures/cfn/pages/fresh/describe_stack_events-3.json +0 -1103
  81. data/vendor/cfn-status/spec/fixtures/cfn/pages/updating/describe_stack_events-1.json +0 -1103
  82. data/vendor/cfn-status/spec/fixtures/cfn/pages/updating/describe_stack_events-2.json +0 -1104
  83. data/vendor/cfn-status/spec/fixtures/cfn/pages/updating/describe_stack_events-3.json +0 -1103
  84. data/vendor/cfn-status/spec/fixtures/cfn/stack-events-complete.json +0 -1080
  85. data/vendor/cfn-status/spec/fixtures/cfn/stack-events-in-progress.json +0 -1080
  86. data/vendor/cfn-status/spec/fixtures/cfn/stack-events-update-rollback-complete.json +0 -1086
  87. data/vendor/cfn-status/spec/lib/cfn_status_spec.rb +0 -153
  88. data/vendor/cfn-status/spec/spec_helper.rb +0 -14
@@ -12,10 +12,9 @@ module Jets::Builders
12
12
  def install
13
13
  return unless gemfile_exist?
14
14
 
15
- reconfigure_ruby_version
16
15
  clean_old_submodules
17
16
  bundle_install
18
- setup_bundle_config
17
+ copy_bundle_config
19
18
  copy_cache_gems
20
19
  end
21
20
 
@@ -46,6 +45,7 @@ module Jets::Builders
46
45
  headline "Bundling: running bundle install in cache area: #{cache_area}."
47
46
 
48
47
  copy_gemfiles(full_project_path)
48
+ copy_bundled_gems(full_project_path)
49
49
 
50
50
  # Uncomment out to always remove the cache/vendor/gems to debug
51
51
  # FileUtils.rm_rf("#{cache_area}/vendor/gems")
@@ -57,16 +57,17 @@ module Jets::Builders
57
57
  # bundle config gems.myprivatesource.com user:pass
58
58
  #
59
59
 
60
- FileUtils.rm_rf("#{cache_area}/.bundle")
60
+ create_bundle_config
61
61
  require "bundler" # dynamically require bundler so user can use any bundler
62
62
  Bundler.with_unbundled_env do
63
63
  sh(
64
64
  "cd #{cache_area} && " \
65
- "env bundle install --path #{cache_area}/vendor/gems --without development test"
65
+ "env bundle install"
66
66
  )
67
67
  end
68
+ create_bundle_config(frozen: true)
68
69
 
69
- rewrite_gemfile_lock("#{cache_area}/Gemfile.lock")
70
+ remove_bundled_with("#{cache_area}/Gemfile.lock")
70
71
 
71
72
  # Copy the Gemfile.lock back to the project in case it was updated.
72
73
  # For example we add the jets-rails to the Gemfile.
@@ -94,14 +95,6 @@ module Jets::Builders
94
95
  Tidy.new(path).cleanup!
95
96
  end
96
97
 
97
- # This is in case the user has a 2.5.x variant.
98
- # Force usage of ruby version that jets supports
99
- # The lambda server only has ruby 2.5.0 installed.
100
- def reconfigure_ruby_version
101
- ruby_version = "#{@full_app_root}/.ruby-version"
102
- IO.write(ruby_version, Jets::RUBY_VERSION)
103
- end
104
-
105
98
  # When using submodules, bundler leaves old submodules behind. Over time this inflates
106
99
  # the size of the the cache gems. So we'll clean it up.
107
100
  def clean_old_submodules
@@ -137,6 +130,12 @@ module Jets::Builders
137
130
  end
138
131
  end
139
132
 
133
+ def copy_bundled_gems(full_project_path)
134
+ src = "#{full_project_path}/bundled_gems"
135
+ return unless File.exist?(src)
136
+ Jets::Util.cp_r(src, "#{cache_area}/bundled_gems")
137
+ end
138
+
140
139
  def copy_gemfiles(full_project_path)
141
140
  FileUtils.mkdir_p(cache_area)
142
141
  FileUtils.cp("#{full_project_path}/Gemfile", "#{cache_area}/Gemfile")
@@ -150,10 +149,9 @@ module Jets::Builders
150
149
 
151
150
  # Remove the BUNDLED WITH line since we don't control the bundler gem version on AWS Lambda
152
151
  # And this can cause issues with require 'bundler/setup'
153
- def rewrite_gemfile_lock(gemfile_lock)
152
+ def remove_bundled_with(gemfile_lock)
154
153
  lines = IO.readlines(gemfile_lock)
155
154
 
156
- # Remove BUNDLED WITH
157
155
  # amount is the number of lines to remove
158
156
  new_lines, capture, count, amount = [], true, 0, 2
159
157
  lines.each do |l|
@@ -167,34 +165,11 @@ module Jets::Builders
167
165
  end
168
166
  end
169
167
 
170
- # Replace things like nokogiri (1.11.1-x86_64-darwin) => nokogiri (1.11.1)
171
- lines, new_lines = new_lines, []
172
- lines.each do |l|
173
- if l.include?("-x86_64-darwin")
174
- l = l.sub('-x86_64-darwin','')
175
- end
176
- new_lines << l
177
- end
178
-
179
- # Make sure platform is ruby
180
- lines, new_lines, marker = new_lines, [], false
181
- lines.each do |l|
182
- if marker # the next loop has the platform we want to replace
183
- new_lines << " ruby\n"
184
- marker = false
185
- next
186
- end
187
- marker = l.include?('PLATFORMS')
188
- new_lines << l
189
- end
190
-
191
168
  content = new_lines.join('')
192
169
  IO.write(gemfile_lock, content)
193
170
  end
194
171
 
195
- def setup_bundle_config
196
- ensure_build_cache_bundle_config_exists!
197
-
172
+ def copy_bundle_config
198
173
  # Override project's .bundle/config and ensure that .bundle/config matches
199
174
  # at these 2 spots:
200
175
  # app_root/.bundle/config
@@ -209,11 +184,12 @@ module Jets::Builders
209
184
  # this only happens with ssh debugging, not when the ci.sh script gets ran.
210
185
  # But on macosx it exists.
211
186
  # Dont know why this is the case.
212
- def ensure_build_cache_bundle_config_exists!
187
+ def create_bundle_config(frozen: false)
188
+ FileUtils.rm_rf("#{cache_area}/.bundle")
189
+ frozen_line = %Q|BUNDLE_FROZEN: "true"\n| if frozen
213
190
  text =<<-EOL
214
191
  ---
215
- BUNDLE_FROZEN: "true"
216
- BUNDLE_PATH: "vendor/gems"
192
+ #{frozen_line}BUNDLE_PATH: "vendor/gems"
217
193
  BUNDLE_WITHOUT: "development:test"
218
194
  EOL
219
195
  bundle_config = "#{cache_area}/.bundle/config"
@@ -81,8 +81,7 @@ module Jets::Builders
81
81
  # Reason do not remove the cache folder generally is because some gems have
82
82
  # actual cache folders that they used.
83
83
  def remove_gem_cache
84
- ruby_minor_version = Jets::RUBY_VERSION.split('.')[0..1].join('.') + '.0'
85
- cache_path = "#{@project_root}/vendor/gems/ruby/#{ruby_minor_version}/cache"
84
+ cache_path = "#{@project_root}/vendor/gems/ruby/#{Jets.ruby_folder}/cache"
86
85
  FileUtils.rm_rf(cache_path)
87
86
  end
88
87
 
data/lib/jets/bundle.rb CHANGED
@@ -15,6 +15,7 @@ module Jets
15
15
  # Later in Jets::Booter, Bundle.require is called and includes the Jets.env group.
16
16
  #
17
17
  def setup
18
+ return unless jets_project?
18
19
  return unless bundler_enabled?
19
20
  Kernel.require "bundler/setup"
20
21
  Bundler.setup # Same as Bundler.setup(:default)
@@ -38,6 +39,7 @@ module Jets
38
39
  # rescued gracefully. This is done in Jets::Commands::RakeTasks.load! In the case when user is in another
39
40
  # project with another Gemfile, the load errors will also be rescued.
40
41
  def require
42
+ return unless jets_project?
41
43
  return unless bundler_enabled?
42
44
  Kernel.require "bundler/setup"
43
45
  Bundler.require(*bundler_groups)
@@ -77,6 +79,10 @@ module Jets
77
79
  [:default, Jets.env.to_sym]
78
80
  end
79
81
 
82
+ def jets_project?
83
+ File.exist?("config/application.rb")
84
+ end
85
+
80
86
  extend self
81
87
  end
82
88
  end
@@ -1,5 +1,6 @@
1
1
  module Jets::Cfn::Builders
2
2
  class ApiGatewayBuilder
3
+ extend Memoist
3
4
  include Interface
4
5
  include Jets::AwsServices
5
6
 
@@ -43,22 +44,75 @@ module Jets::Cfn::Builders
43
44
  end
44
45
 
45
46
  def add_domain_name
46
- domain_name = Jets::Resource::ApiGateway::DomainName.new
47
- add_resource(domain_name)
48
- add_outputs(domain_name.outputs)
47
+ add_outputs(create_domain_name)
49
48
  end
50
49
 
51
50
  def add_route53_dns
52
51
  dns = Jets::Resource::Route53::RecordSet.new
53
- add_resource(dns)
54
- add_outputs(dns.outputs)
52
+ if !existing_domain_name?(dns.domain_name) or existing_dns_record_on_stack?
53
+ add_resource(dns)
54
+ add_outputs(dns.outputs)
55
+ end
56
+ end
57
+
58
+ def create_domain_name()
59
+ resource = Jets::Resource::ApiGateway::DomainName.new
60
+
61
+ return {
62
+ "DomainName" => resource.domain_name
63
+ } if (existing_domain_name?(resource) and !existing_domain_name_on_stack?)
64
+
65
+ add_resource(resource)
66
+ return resource.outputs
67
+ end
68
+
69
+ def existing_domain_name?(resource)
70
+ apigateway.get_domain_name({
71
+ domain_name: resource.domain_name
72
+ })
73
+ return true
74
+ rescue
75
+ return false
76
+ end
77
+ memoize :existing_domain_name?
78
+
79
+ def existing_domain_name_on_stack?
80
+ cfn.describe_stack_resource({
81
+ stack_name: api_gateway_physical_resource_id,
82
+ logical_resource_id: "DomainName"
83
+ })
84
+ return true
85
+ rescue
86
+ return false
87
+ end
88
+
89
+ def existing_dns_record_on_stack?
90
+ cfn.describe_stack_resource({
91
+ stack_name: api_gateway_physical_resource_id,
92
+ logical_resource_id: "DnsRecord"
93
+ })
94
+ return true
95
+ rescue
96
+ return false
97
+ end
98
+
99
+ def api_gateway_physical_resource_id
100
+ cfn.describe_stack_resource({
101
+ stack_name: Jets::Naming.parent_stack_name,
102
+ logical_resource_id: "ApiGateway"
103
+ })
104
+ .stack_resource_detail
105
+ .physical_resource_id
106
+ rescue
107
+ return nil
55
108
  end
109
+ memoize :api_gateway_physical_resource_id
56
110
 
57
111
  # Adds route related Resources and Outputs
58
112
  # Delegates to ApiResourcesBuilder
59
- PAGE_LIMIT = Integer(ENV['JETS_AWS_OUTPUTS_LIMIT'] || 60) # Allow override for testing
113
+ PAGE_LIMIT = Integer(ENV['JETS_AWS_OUTPUTS_LIMIT'] || 200) # Allow override for testing
60
114
  def add_gateway_routes
61
- # Reject homepage. Otherwise we have 60 - 1 resources on the first page.
115
+ # Reject homepage. Otherwise we have 200 - 1 resources on the first page.
62
116
  # There's a next call in ApiResources.add_gateway_resources to skip the homepage.
63
117
  all_paths = Jets::Router.all_paths.reject { |p| p == '' }
64
118
  all_paths.each_slice(PAGE_LIMIT).each_with_index do |paths, i|
data/lib/jets/cfn/ship.rb CHANGED
@@ -102,7 +102,7 @@ module Jets::Cfn
102
102
  end
103
103
 
104
104
  def clean_deploy_logs
105
- Jets::Commands::Clean::Log.new.clean_deploys
105
+ Jets::Commands::Clean::Log.new(@options).clean_deploys
106
106
  end
107
107
 
108
108
  def endpoint_unavailable?
@@ -146,6 +146,7 @@ module Jets::Cfn
146
146
  # domain_name is a method on the Jets::Resource::ApiGateway::Domain instance
147
147
  url = "https://#{domain_name.domain_name}"
148
148
  puts "Custom Domain: #{url}"
149
+ puts "App Domain: #{Jets.config.app.domain}" if Jets.config.app.domain
149
150
  end
150
151
 
151
152
  # All CloudFormation states listed here:
data/lib/jets/cli.rb CHANGED
@@ -135,12 +135,17 @@ class Jets::CLI
135
135
  return Jets::Commands::Base.klass_from_namespace(namespace)
136
136
  end
137
137
 
138
+ return unless jets_project?
138
139
  rake_task_found = Jets::Commands::RakeCommand.namespaced_commands.include?(full_command)
139
140
  if rake_task_found
140
141
  return Jets::Commands::RakeCommand
141
142
  end
142
143
  end
143
144
 
145
+ def jets_project?
146
+ File.exist?("config/application.rb")
147
+ end
148
+
144
149
  # ["-h", "-?", "--help", "-D", "help"]
145
150
  def help_flags
146
151
  Thor::HELP_MAPPINGS + ["help"]
@@ -161,7 +166,7 @@ class Jets::CLI
161
166
  shell.say "Commands:"
162
167
  shell.print_table(thor_list, :indent => 2, :truncate => true)
163
168
 
164
- unless rake_list.empty?
169
+ if jets_project? && !rake_list.empty?
165
170
  shell.say "\nCommands via rake:"
166
171
  shell.print_table(rake_list, :indent => 2, :truncate => true)
167
172
  end
@@ -138,7 +138,7 @@ class Jets::Commands::Base < Thor
138
138
  def eager_load!
139
139
  return if Jets::Turbo.afterburner?
140
140
 
141
- Jets::Autoloaders.once.eager_load
141
+ Jets::Autoloaders.cli.eager_load
142
142
  end
143
143
  memoize :eager_load!
144
144
  end
@@ -59,8 +59,9 @@ class Jets::Commands::Call
59
59
  payload: transformed_event, # "fileb://file-path/input.json", <= JSON
60
60
  qualifier: @qualifier, # "1",
61
61
  }
62
+
62
63
  begin
63
- resp = aws_lambda.invoke(options)
64
+ resp = lambda_client.invoke(options)
64
65
  rescue Aws::Lambda::Errors::ResourceNotFoundException
65
66
  puts "The function #{function_name} was not found. Maybe check the spelling or the AWS_PROFILE?".color(:red)
66
67
  return
@@ -166,4 +167,16 @@ class Jets::Commands::Call
166
167
  $stderr.puts(text)
167
168
  end
168
169
 
170
+ def lambda_client
171
+ opt = {}
172
+ opt = opt.merge({retry_limit: @options[:retry_limit]}) if @options[:retry_limit].present?
173
+ opt = opt.merge({http_read_timeout: @options[:read_timeout]}) if @options[:read_timeout].present?
174
+
175
+ if opt.empty?
176
+ aws_lambda
177
+ else
178
+ Aws::Lambda::Client.new(opt)
179
+ end
180
+ end
181
+
169
182
  end
@@ -2,7 +2,7 @@ module Jets::Commands
2
2
  class Clean < Jets::Commands::Base
3
3
  class_option :noop, type: :boolean, desc: "noop or dry-run mode"
4
4
  class_option :mute, type: :boolean, desc: "mute output"
5
- class_option :sure, type: :boolean, desc: "bypass are you sure prompt"
5
+ class_option :yes, type: :boolean, desc: "bypass are you sure prompt"
6
6
 
7
7
  desc "log", "Cleans CloudWatch log groups assocated with app"
8
8
  long_desc Help.text('clean:log')
@@ -11,7 +11,7 @@ class Jets::Commands::Clean
11
11
  end
12
12
 
13
13
  def are_you_sure?(message)
14
- return true if @options[:sure]
14
+ return true if @options[:yes]
15
15
 
16
16
  puts "Are you sure that you want to #{message}? (y/N)"
17
17
  yes = $stdin.gets.strip
@@ -0,0 +1,51 @@
1
+ module Jets::Commands
2
+ class Configure
3
+ extend Memoist
4
+
5
+ def initialize(options)
6
+ @options = options
7
+ end
8
+
9
+ def run
10
+ data = load_yaml
11
+ data['key'] = token
12
+ FileUtils.mkdir_p(File.dirname(path))
13
+ IO.write(path, YAML.dump(data))
14
+ puts "Updated #{pretty(path)}"
15
+ end
16
+
17
+ def load_yaml
18
+ if File.exist?(path)
19
+ YAML.load_file(path)
20
+ else
21
+ {}
22
+ end
23
+ rescue Psych::SyntaxError => e
24
+ puts "WARN: There was an error reading #{pretty(path)}".color(:yellow)
25
+ puts "WARN: #{e.class} #{e.message}".color(:yellow)
26
+ {}
27
+ end
28
+
29
+ def pretty(path)
30
+ path.sub(ENV['HOME'], '~')
31
+ end
32
+
33
+ def path
34
+ "#{ENV['HOME']}/.jets/config.yml"
35
+ end
36
+
37
+ def token
38
+ @options[:token] || prompt
39
+ end
40
+ memoize :token
41
+
42
+ def prompt
43
+ puts <<~EOL
44
+ You are about to configure your ~/.jets/config.yml
45
+ You can get a token from serverlessgems.com
46
+ EOL
47
+ print "Please provide your token: "
48
+ $stdin.gets.strip
49
+ end
50
+ end
51
+ end
@@ -39,7 +39,7 @@ class Jets::Commands::Delete
39
39
 
40
40
  def delete_logs
41
41
  puts "Deleting CloudWatch logs"
42
- log = Jets::Commands::Clean::Log.new(mute: true, sure: true)
42
+ log = Jets::Commands::Clean::Log.new(mute: true, yes: true)
43
43
  log.clean
44
44
  end
45
45
 
@@ -114,7 +114,7 @@ class Jets::Commands::Delete
114
114
  end
115
115
 
116
116
  def are_you_sure?
117
- if @options[:sure]
117
+ if @options[:yes]
118
118
  sure = 'y'
119
119
  else
120
120
  puts "Are you sure you want to want to delete the #{Jets.config.project_namespace.color(:green)} project? (y/N)"
@@ -2,20 +2,12 @@ module Jets::Commands
2
2
  class Gems < Jets::Commands::Base
3
3
  desc "check", "Check if pre-built Lambda gems are available from the sources"
4
4
  long_desc Help.text("gems:check")
5
+ option :show_source, type: :boolean, desc: "Show source"
5
6
  def check
6
- check = Jets::Gems::Check.new
7
+ check = Jets::Gems::Check.new(@options)
7
8
  check.run! # exits early if missing gems found
8
9
  # If reach here, means all gems are ok.
9
10
  puts "Congrats! All gems are available in as pre-built Lambda gems 👍"
10
11
  end
11
-
12
- desc "sources", "List pre-built Lambda gem sources"
13
- long_desc Help.text("gems:sources")
14
- def sources
15
- puts "Your pre-built Lambda gem sources are:"
16
- Jets.config.gems.sources.each do |source|
17
- puts " #{source}"
18
- end
19
- end
20
12
  end
21
13
  end