jets 1.1.5 → 1.2.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/Gemfile.lock +10 -6
  4. data/README/testing.md +5 -1
  5. data/jets.gemspec +1 -0
  6. data/lib/jets.rb +5 -1
  7. data/lib/jets/application.rb +39 -19
  8. data/lib/jets/aws_services.rb +16 -10
  9. data/lib/jets/aws_services/stack_status.rb +7 -0
  10. data/lib/jets/booter.rb +6 -2
  11. data/lib/jets/builders/code_builder.rb +14 -0
  12. data/lib/jets/builders/handler_generator.rb +15 -0
  13. data/lib/jets/builders/shim_vars/app.rb +4 -3
  14. data/lib/jets/builders/shim_vars/shared.rb +8 -4
  15. data/lib/jets/builders/templates/shim.js +7 -3
  16. data/lib/jets/camelizer.rb +2 -1
  17. data/lib/jets/cfn/builders.rb +0 -1
  18. data/lib/jets/cfn/builders/api_deployment_builder.rb +27 -0
  19. data/lib/jets/cfn/builders/api_gateway_builder.rb +22 -2
  20. data/lib/jets/cfn/ship.rb +38 -6
  21. data/lib/jets/commands/call.rb +0 -1
  22. data/lib/jets/commands/call/guesser.rb +0 -3
  23. data/lib/jets/commands/clean/log.rb +18 -0
  24. data/lib/jets/commands/console.rb +1 -1
  25. data/lib/jets/commands/import/sequence.rb +2 -3
  26. data/lib/jets/commands/runner.rb +1 -1
  27. data/lib/jets/commands/sequence.rb +0 -1
  28. data/lib/jets/commands/templates/skeleton/config/application.rb.tt +11 -0
  29. data/lib/jets/commands/url.rb +32 -7
  30. data/lib/jets/controller/base.rb +21 -5
  31. data/lib/jets/controller/layout.rb +0 -3
  32. data/lib/jets/controller/middleware/local/api_gateway.rb +2 -5
  33. data/lib/jets/controller/middleware/local/mimic_aws_call.rb +2 -2
  34. data/lib/jets/controller/params.rb +42 -10
  35. data/lib/jets/controller/rack/adapter.rb +5 -2
  36. data/lib/jets/controller/rack/env.rb +17 -8
  37. data/lib/jets/controller/renderers/rack_renderer.rb +1 -1
  38. data/lib/jets/controller/rendering.rb +4 -1
  39. data/lib/jets/core.rb +8 -16
  40. data/lib/jets/internal/app/functions/jets/base_path.rb +153 -0
  41. data/lib/jets/klass.rb +38 -5
  42. data/lib/jets/lambda/dsl.rb +0 -2
  43. data/lib/jets/mega/request.rb +44 -13
  44. data/lib/jets/mega/request/source.rb +21 -0
  45. data/lib/jets/middleware/configurator.rb +1 -1
  46. data/lib/jets/middleware/default_stack.rb +2 -2
  47. data/lib/jets/resource.rb +1 -0
  48. data/lib/jets/resource/api_gateway.rb +5 -3
  49. data/lib/jets/resource/api_gateway/base_path.rb +5 -0
  50. data/lib/jets/resource/api_gateway/base_path/function.rb +42 -0
  51. data/lib/jets/resource/api_gateway/base_path/mapping.rb +44 -0
  52. data/lib/jets/resource/api_gateway/base_path/role.rb +76 -0
  53. data/lib/jets/resource/api_gateway/cors.rb +1 -1
  54. data/lib/jets/resource/api_gateway/deployment.rb +9 -5
  55. data/lib/jets/resource/api_gateway/domain_name.rb +56 -0
  56. data/lib/jets/resource/api_gateway/method.rb +3 -4
  57. data/lib/jets/resource/api_gateway/resource.rb +4 -3
  58. data/lib/jets/resource/api_gateway/rest_api.rb +42 -14
  59. data/lib/jets/resource/api_gateway/rest_api/change_detection.rb +42 -0
  60. data/lib/jets/resource/api_gateway/rest_api/logical_id.rb +59 -0
  61. data/lib/jets/resource/api_gateway/rest_api/routes.rb +127 -0
  62. data/lib/jets/resource/child_stack/api_deployment.rb +5 -1
  63. data/lib/jets/resource/function.rb +3 -20
  64. data/lib/jets/resource/function/environment.rb +23 -0
  65. data/lib/jets/resource/iam/application_role.rb +1 -1
  66. data/lib/jets/resource/route53.rb +3 -0
  67. data/lib/jets/resource/route53/record_set.rb +70 -0
  68. data/lib/jets/router.rb +2 -0
  69. data/lib/jets/ruby_server.rb +6 -3
  70. data/lib/jets/stack.rb +1 -3
  71. data/lib/jets/stack/main/dsl.rb +1 -1
  72. data/lib/jets/stack/main/extensions/lambda.rb +4 -2
  73. data/lib/jets/turbine.rb +0 -3
  74. data/lib/jets/version.rb +1 -1
  75. data/vendor/jets-gems/lib/jets/gems.rb +1 -0
  76. data/vendor/jets-gems/lib/jets/gems/agree.rb +41 -0
  77. data/vendor/jets-gems/lib/jets/gems/check.rb +15 -2
  78. metadata +30 -2
@@ -56,7 +56,8 @@ module Jets
56
56
  # Some keys have special mappings
57
57
  def special_map
58
58
  {
59
- "TemplateUrl" => "TemplateURL"
59
+ "TemplateUrl" => "TemplateURL",
60
+ "Ttl" => "TTL",
60
61
  }
61
62
  end
62
63
  end
@@ -1,4 +1,3 @@
1
- require 'active_support/core_ext/hash'
2
1
  require 'yaml'
3
2
 
4
3
  class Jets::Cfn
@@ -16,6 +16,33 @@ class Jets::Cfn::Builders
16
16
  add_resource(deployment)
17
17
  add_parameters(deployment.parameters)
18
18
  add_outputs(deployment.outputs)
19
+
20
+ add_base_path_mapping
21
+ end
22
+
23
+ # Because Jets generates a new timestamped logical id for the API Deployment
24
+ # resource it also creates a new root base path mapping and fails. Additionally,
25
+ # the base path mapping depends on the API Deploy for the stage name.
26
+ #
27
+ # We resolve this by using a custom resource that does an in-place update.
28
+ #
29
+ # Note, also tried to change the domain name of to something like demo-dev-[random].mydomain.com
30
+ # but that does not work because the domain name has to match the route53 record exactly.
31
+ #
32
+ def add_base_path_mapping
33
+ return unless Jets.custom_domain?
34
+
35
+ function = Jets::Resource::ApiGateway::BasePath::Function.new
36
+ add_resource(function)
37
+ add_outputs(function.outputs)
38
+
39
+ mapping = Jets::Resource::ApiGateway::BasePath::Mapping.new
40
+ add_resource(mapping)
41
+ add_outputs(mapping.outputs)
42
+
43
+ iam_role = Jets::Resource::ApiGateway::BasePath::Role.new
44
+ add_resource(iam_role)
45
+ add_outputs(iam_role.outputs)
19
46
  end
20
47
 
21
48
  # template_path is an interface method
@@ -13,6 +13,7 @@ class Jets::Cfn::Builders
13
13
  return unless @options[:templates] || @options[:stack_type] != :minimal
14
14
 
15
15
  add_gateway_rest_api
16
+ add_custom_domain
16
17
  add_gateway_routes
17
18
  end
18
19
 
@@ -33,7 +34,26 @@ class Jets::Cfn::Builders
33
34
  add_outputs(rest_api.outputs)
34
35
 
35
36
  deployment = Jets::Resource::ApiGateway::Deployment.new
36
- add_output("RestApiUrl", Value: deployment.outputs["RestApiUrl"])
37
+ outputs = deployment.outputs(true)
38
+ add_output("RestApiUrl", Value: outputs["RestApiUrl"])
39
+ end
40
+
41
+ def add_custom_domain
42
+ return unless Jets.custom_domain?
43
+ add_domain_name
44
+ add_route53_dns
45
+ end
46
+
47
+ def add_domain_name
48
+ domain_name = Jets::Resource::ApiGateway::DomainName.new
49
+ add_resource(domain_name)
50
+ add_outputs(domain_name.outputs)
51
+ end
52
+
53
+ def add_route53_dns
54
+ dns = Jets::Resource::Route53::RecordSet.new
55
+ add_resource(dns)
56
+ add_outputs(dns.outputs)
37
57
  end
38
58
 
39
59
  # Adds route related Resources and Outputs
@@ -51,7 +71,7 @@ class Jets::Cfn::Builders
51
71
  homepage = path == ''
52
72
  next if homepage # handled by RootResourceId output already
53
73
 
54
- resource = Jets::Resource::ApiGateway::Resource.new(path)
74
+ resource = Jets::Resource::ApiGateway::Resource.new(path, internal: true)
55
75
  add_resource(resource)
56
76
  add_outputs(resource.outputs)
57
77
  end
@@ -1,5 +1,6 @@
1
1
  class Jets::Cfn
2
2
  class Ship
3
+ extend Memoist
3
4
  include Jets::AwsServices
4
5
 
5
6
  def initialize(options)
@@ -32,7 +33,9 @@ class Jets::Cfn
32
33
 
33
34
  wait_for_stack
34
35
  prewarm
36
+ clean_deploy_logs
35
37
  show_api_endpoint
38
+ show_custom_domain
36
39
  end
37
40
 
38
41
  def save_stack
@@ -89,15 +92,34 @@ class Jets::Cfn
89
92
  end
90
93
  end
91
94
 
92
- def show_api_endpoint
93
- return unless @options[:stack_type] == :full # s3 bucket is available
94
- return if Jets::Router.routes.empty?
95
- resp, status = stack_status
96
- return if status.include?("ROLLBACK")
95
+ def clean_deploy_logs
96
+ Jets::Commands::Clean::Log.new.clean_deploys
97
+ end
97
98
 
99
+ def endpoint_unavailable?
100
+ return true unless @options[:stack_type] == :full # s3 bucket is available
101
+ return true if Jets::Router.routes.empty?
102
+ _, status = stack_status
103
+ return true if status.include?("ROLLBACK")
104
+ return true unless api_gateway
105
+ end
106
+
107
+ # Do not memoize this because on first stack run it will be nil
108
+ # It only gets called one more time so just let it get called.
109
+ def api_gateway
98
110
  resp = cfn.describe_stack_resources(stack_name: @parent_stack_name)
99
111
  resources = resp.stack_resources
100
- api_gateway = resources.find { |resource| resource.logical_resource_id == "ApiGateway" }
112
+ resources.find { |resource| resource.logical_resource_id == "ApiGateway" }
113
+ end
114
+ memoize :api_gateway
115
+
116
+ def endpoint_available?
117
+ !endpoint_unavailable?
118
+ end
119
+
120
+ def show_api_endpoint
121
+ return unless endpoint_available?
122
+
101
123
  stack_id = api_gateway["physical_resource_id"]
102
124
 
103
125
  resp = cfn.describe_stacks(stack_name: stack_id)
@@ -107,6 +129,16 @@ class Jets::Cfn
107
129
  puts "API Gateway Endpoint: #{endpoint}"
108
130
  end
109
131
 
132
+ def show_custom_domain
133
+ return unless endpoint_available? && Jets.custom_domain?
134
+
135
+ domain_name = Jets::Resource::ApiGateway::DomainName.new
136
+ # Looks funny but its right.
137
+ # domain_name is a method on the Jets::Resource::ApiGateway::Domain instance
138
+ url = "https://#{domain_name.domain_name}"
139
+ puts "Custom Domain: #{url}"
140
+ end
141
+
110
142
  # All CloudFormation states listed here:
111
143
  # http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-describing-stacks.html
112
144
  def stack_status
@@ -1,6 +1,5 @@
1
1
  require "base64"
2
2
  require "json"
3
- require "active_support/core_ext/string"
4
3
 
5
4
  class Jets::Commands::Call
6
5
  autoload :BaseGuesser, "jets/commands/call/base_guesser"
@@ -1,6 +1,3 @@
1
- require "active_support/core_ext/hash"
2
- require "active_support/core_ext/object"
3
-
4
1
  # Guesser transforms the user provided function name to the actual lambda
5
2
  # function name.
6
3
  #
@@ -25,6 +25,24 @@ class Jets::Commands::Clean
25
25
  say "Removed CloudWatch logs for #{prefix_guess}"
26
26
  end
27
27
 
28
+ def clean_deploys
29
+ groups = deploy_log_groups.sort_by do |g|
30
+ g.log_group_name
31
+ end
32
+ # Keep the last 2 recent log groups so we can see the deleted logic
33
+ groups = groups[0..-3]
34
+ groups.each do |g|
35
+ logs.delete_log_group(log_group_name: g.log_group_name) unless @options[:noop]
36
+ end
37
+ end
38
+
39
+ def deploy_log_groups
40
+ log_groups.select do |g|
41
+ !keep_log_group?(g.log_group_name) &&
42
+ g.log_group_name.include?('jets-base-path')
43
+ end
44
+ end
45
+
28
46
  private
29
47
  def prefix_guess
30
48
  Jets::Naming.parent_stack_name
@@ -1,7 +1,7 @@
1
1
  class Jets::Commands::Console
2
2
  def self.run
3
3
  puts Jets::Booter.message
4
- Jets.eager_load!
4
+ Jets.boot
5
5
 
6
6
  # Thanks: https://mutelight.org/bin-console
7
7
  require "irb"
@@ -1,8 +1,7 @@
1
- require 'fileutils'
1
+ require 'bundler'
2
2
  require 'colorize'
3
- require 'active_support/core_ext/string'
3
+ require 'fileutils'
4
4
  require 'thor'
5
- require 'bundler'
6
5
 
7
6
  class Jets::Commands::Import
8
7
  class Sequence < Thor::Group
@@ -1,6 +1,6 @@
1
1
  class Jets::Commands::Runner
2
2
  def self.run(code)
3
- Jets.eager_load!
3
+ Jets.boot
4
4
 
5
5
  if code =~ %r{^file://}
6
6
  path = code.sub('file://', '')
@@ -1,6 +1,5 @@
1
1
  require 'fileutils'
2
2
  require 'colorize'
3
- require 'active_support/core_ext/string'
4
3
  require 'thor'
5
4
  require 'bundler'
6
5
 
@@ -47,4 +47,15 @@ Jets.application.configure do
47
47
  # config.assets.cache_control = nil # IE: "public, max-age=3600" # override max_age for more fine-grain control.
48
48
  # config.assets.base_url = nil # IE: https://cloudfront.com/my/base/path, defaults to the s3 bucket url
49
49
  # IE: https://s3-us-west-2.amazonaws.com/demo-dev-s3bucket-1inlzkvujq8zb
50
+
51
+ # config.api.endpoint_type = 'PRIVATE' # Default is 'EDGE' https://amzn.to/2r0Iu2L
52
+ # config.api.authorization_type = "AWS_IAM" # default is 'NONE' https://amzn.to/2qZ7zLh
53
+
54
+
55
+ # config.domain.hosted_zone_name = "example.com"
56
+ # us-west-2 REGIONAL endpoint
57
+ # config.domain.cert_arn = "arn:aws:acm:us-west-2:112233445566:certificate/8d8919ce-a710-4050-976b-b33da991e123"
58
+ # us-east-1 EDGE endpoint
59
+ # config.domain.cert_arn = "arn:aws:acm:us-east-1:112233445566:certificate/d68472ba-04f8-45ba-b9db-14f839d57123"
60
+ # config.domain.endpoint_type = "EDGE"
50
61
  end
@@ -16,14 +16,16 @@ module Jets::Commands
16
16
  stack = cfn.describe_stacks(stack_name: stack_name).stacks.first
17
17
 
18
18
  api_gateway_stack_arn = lookup(stack[:outputs], "ApiGateway")
19
- if api_gateway_stack_arn
20
- STDOUT.puts get_url(api_gateway_stack_arn)
19
+ if api_gateway_stack_arn && endpoint_available?
20
+ api_gateway_endpoint = get_gateway_endpoint(api_gateway_stack_arn)
21
+ STDOUT.puts "API Gateway Endpoint: #{api_gateway_endpoint}"
22
+ show_custom_domain
21
23
  else
22
24
  puts "API Gateway not found. This jets app does have an API Gateway associated with it. Please double check your config/routes.rb if you were expecting to see a url for the app. Also check that #{stack_name.colorize(:green)} is a jets app."
23
25
  end
24
26
  end
25
27
 
26
- def get_url(api_gateway_stack_arn)
28
+ def get_gateway_endpoint(api_gateway_stack_arn)
27
29
  stack = cfn.describe_stacks(stack_name: api_gateway_stack_arn).stacks.first
28
30
  rest_api = lookup(stack[:outputs], "RestApi")
29
31
  region_id = lookup(stack[:outputs], "Region")
@@ -34,10 +36,33 @@ module Jets::Commands
34
36
  "https://#{rest_api}.execute-api.#{region_id}.amazonaws.com/#{stage_name}"
35
37
  end
36
38
 
37
- # Lookup output value
38
- def lookup(outputs, key)
39
- out = outputs.find { |o| o.output_key == key }
40
- out&.output_value
39
+ def show_custom_domain
40
+ return unless endpoint_available? && Jets.custom_domain?
41
+
42
+ domain_name = Jets::Resource::ApiGateway::DomainName.new
43
+ # Looks funny but its right.
44
+ # domain_name is a method on the Jets::Resource::ApiGateway::Domain instance
45
+ url = "https://#{domain_name.domain_name}"
46
+ puts "Custom Domain: #{url}"
47
+ end
48
+
49
+ def endpoint_unavailable?
50
+ return false if Jets::Router.routes.empty?
51
+ resp, status = stack_status
52
+ return false if status.include?("ROLLBACK")
53
+ end
54
+
55
+ def endpoint_available?
56
+ !endpoint_unavailable?
57
+ end
58
+
59
+ # All CloudFormation states listed here:
60
+ # http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-describing-stacks.html
61
+ def stack_status
62
+ resp = cfn.describe_stacks(stack_name: @parent_stack_name)
63
+ status = resp.stacks[0].stack_status
64
+ [resp, status]
41
65
  end
66
+
42
67
  end
43
68
  end
@@ -1,5 +1,3 @@
1
- require "active_support/core_ext/hash"
2
- require "active_support/core_ext/object"
3
1
  require "json"
4
2
  require "rack/utils" # Rack::Utils.parse_nested_query
5
3
 
@@ -37,9 +35,7 @@ class Jets::Controller
37
35
 
38
36
  def dispatch!
39
37
  t1 = Time.now
40
- Jets.logger.info "Processing by #{self.class.name}##{@meth}"
41
- Jets.logger.info " Event: #{@event.inspect}"
42
- Jets.logger.info " Parameters: #{params(raw: true).to_h.inspect}"
38
+ log_info_start
43
39
 
44
40
  run_before_actions
45
41
  send(@meth)
@@ -53,6 +49,26 @@ class Jets::Controller
53
49
  triplet # status, headers, body
54
50
  end
55
51
 
52
+ def log_info_start
53
+ display_event = @event.dup
54
+ display_event['body'] = '[BASE64_ENCODED]' if @event['isBase64Encoded']
55
+ # Interesting, JSON.dump makes logging look like JSON.pretty_generate in
56
+ # CloudWatch but not locally. This is what we want.
57
+ ip = request.ip
58
+ Jets.logger.info "Started #{@event['httpMethod']} \"#{@event['path']}\" for #{ip} at #{Time.now}"
59
+ Jets.logger.info "Processing #{self.class.name}##{@meth}"
60
+ Jets.logger.info " Event: #{json_dump(display_event)}"
61
+ Jets.logger.info " Parameters: #{JSON.dump(params(raw: true).to_h)}"
62
+ end
63
+
64
+ # Handles binary data safely
65
+ def json_dump(data)
66
+ JSON.dump(data)
67
+ rescue Encoding::UndefinedConversionError
68
+ data['body'] = '[BINARY]'
69
+ JSON.dump(data)
70
+ end
71
+
56
72
  def self.process(event, context={}, meth)
57
73
  controller = new(event, context, meth)
58
74
  # Using send because process! is private method in Jets::RackController so
@@ -1,6 +1,3 @@
1
- require 'active_support'
2
- require 'active_support/core_ext'
3
-
4
1
  class Jets::Controller
5
2
  module Layout
6
3
  extend ActiveSupport::Concern
@@ -63,10 +63,10 @@ class Jets::Controller::Middleware::Local
63
63
  h[key] = v
64
64
  h
65
65
  end
66
- # Content type is not prepended with HTTP_ but is part of Lambda's event headers thankfully
66
+ # Content type is not prepended with HTTP_
67
67
  headers["Content-Type"] = @env["CONTENT_TYPE"] if @env["CONTENT_TYPE"]
68
68
 
69
- # Adjust the casing so it matches the Lambda AWS Proxy's structure
69
+ # Adjust the casing so it matches the Lambda AWS Proxy structure
70
70
  CASING_MAP.each do |nice_casing, bad_casing|
71
71
  if headers.key?(nice_casing)
72
72
  headers[bad_casing] = headers.delete(nice_casing)
@@ -83,9 +83,6 @@ class Jets::Controller::Middleware::Local
83
83
  # To get the post body:
84
84
  # rack.input: #<StringIO:0x007f8ccf8db9a0>
85
85
  def get_body
86
- # @env["rack.input"] is always provided by rack and we should make
87
- # the test data always have rack.input to mimic rack defaulting to
88
- # StringIO.new to help make testing easier
89
86
  input = @env["rack.input"] || StringIO.new
90
87
  body = input.read
91
88
  input.rewind # IMPORTANT or else it screws up other middlewares that use the body
@@ -27,12 +27,12 @@ class Jets::Controller::Middleware::Local
27
27
  end
28
28
 
29
29
  def event
30
- ApiGateway.new(@route, @env).event
30
+ @env['adapter.event'] || ApiGateway.new(@route, @env).event
31
31
  end
32
32
  memoize :event
33
33
 
34
34
  def context
35
- {}
35
+ @env['adapter.context'] || {"fake" => "context in mimic_aws_call.rb"}
36
36
  end
37
37
  end
38
38
  end
@@ -1,8 +1,11 @@
1
1
  require "action_controller/metal/strong_parameters"
2
+ require "action_dispatch"
2
3
  require "rack"
3
4
 
4
5
  class Jets::Controller
5
6
  module Params
7
+ extend Memoist
8
+
6
9
  # Merge all the parameters together for convenience. Users still have
7
10
  # access via events.
8
11
  #
@@ -10,46 +13,75 @@ class Jets::Controller
10
13
  # 1. path parameters have highest precdence
11
14
  # 2. query string parameters
12
15
  # 3. body parameters
13
- def params(raw: false, path_parameters: true)
14
- query_string_params = event["queryStringParameters"] || {}
16
+ def params(raw: false, path_parameters: true, body_parameters: true)
15
17
  path_params = event["pathParameters"] || {}
16
- params = body_params
17
- .deep_merge(query_string_params)
18
+
19
+ params = {}
20
+ params = params.deep_merge(body_params) if body_parameters
21
+ params = params.deep_merge(query_parameters) # always
18
22
  params = params.deep_merge(path_params) if path_parameters
19
23
 
20
24
  if raw
21
25
  params
22
26
  else
27
+ params = ActionDispatch::Request::Utils.normalize_encode_params(params) # for file uploads
23
28
  ActionController::Parameters.new(params)
24
29
  end
25
30
  end
26
31
 
27
- private
32
+ def query_parameters
33
+ event["queryStringParameters"] || {}
34
+ end
35
+
28
36
  def body_params
29
- body = event["body"]
37
+ body = event['isBase64Encoded'] ? base64_decode(event["body"]) : event["body"]
30
38
  return {} if body.nil?
31
39
 
32
- # Try json parsing
33
40
  parsed_json = parse_json(body)
34
41
  return parsed_json if parsed_json
35
42
 
36
-
37
- # For content-type application/x-www-form-urlencoded CGI.parse the body
38
43
  headers = event["headers"] || {}
39
- headers = headers.transform_keys { |key| key.downcase }
40
44
  # API Gateway seems to use either: content-type or Content-Type
45
+ headers = headers.transform_keys { |key| key.downcase }
41
46
  content_type = headers["content-type"]
47
+
42
48
  if content_type.to_s.include?("application/x-www-form-urlencoded")
43
49
  return ::Rack::Utils.parse_nested_query(body)
50
+ elsif content_type.to_s.include?("multipart/form-data")
51
+ return parse_multipart(body)
44
52
  end
45
53
 
46
54
  {} # fallback to empty Hash
47
55
  end
56
+ memoize :body_params
57
+
58
+ private
59
+
60
+ def parse_multipart(body)
61
+ boundary = ::Rack::Multipart::Parser.parse_boundary(headers["content-type"])
62
+ options = multipart_options(body, boundary)
63
+ env = ::Rack::MockRequest.env_for("/", options)
64
+ ::Rack::Multipart.parse_multipart(env) # params Hash
65
+ end
66
+
67
+ def multipart_options(data, boundary = "AaB03x")
68
+ type = %(multipart/form-data; boundary=#{boundary})
69
+ length = data.bytesize
70
+
71
+ { "CONTENT_TYPE" => type,
72
+ "CONTENT_LENGTH" => length.to_s,
73
+ :input => StringIO.new(data) }
74
+ end
48
75
 
49
76
  def parse_json(text)
50
77
  JSON.parse(text)
51
78
  rescue JSON::ParserError
52
79
  nil
53
80
  end
81
+
82
+ def base64_decode(body)
83
+ return nil if body.nil?
84
+ Base64.decode64(body)
85
+ end
54
86
  end
55
87
  end