jets 1.1.5 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
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