shark-on-lambda 0.6.10 → 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/push-gem.yml +67 -0
  3. data/.github/workflows/tests.yml +32 -0
  4. data/README.md +10 -12
  5. data/changelog.md +11 -0
  6. data/gems.locked +73 -23
  7. data/lib/shark_on_lambda/api_gateway_handler.rb +61 -0
  8. data/lib/shark_on_lambda/application.rb +17 -0
  9. data/lib/shark_on_lambda/base_controller.rb +50 -0
  10. data/lib/shark_on_lambda/configuration.rb +9 -1
  11. data/lib/shark_on_lambda/dispatcher.rb +26 -0
  12. data/lib/shark_on_lambda/errors/base.rb +47 -0
  13. data/lib/shark_on_lambda/errors/base_serializer.rb +18 -0
  14. data/lib/shark_on_lambda/jsonapi_controller.rb +29 -0
  15. data/lib/shark_on_lambda/jsonapi_parameters.rb +66 -0
  16. data/lib/shark_on_lambda/jsonapi_renderer.rb +115 -0
  17. data/lib/shark_on_lambda/middleware/base.rb +23 -0
  18. data/lib/shark_on_lambda/middleware/jsonapi_rescuer.rb +31 -0
  19. data/lib/shark_on_lambda/middleware/rescuer.rb +38 -0
  20. data/lib/shark_on_lambda/query.rb +67 -0
  21. data/lib/shark_on_lambda/rack_adapters/api_gateway.rb +127 -0
  22. data/lib/shark_on_lambda/request.rb +9 -0
  23. data/lib/shark_on_lambda/response.rb +6 -0
  24. data/lib/shark_on_lambda/rspec/env_builder.rb +64 -0
  25. data/lib/shark_on_lambda/rspec/helpers.rb +84 -0
  26. data/lib/shark_on_lambda/rspec/jsonapi_helpers.rb +27 -0
  27. data/lib/shark_on_lambda/version.rb +1 -1
  28. data/lib/shark_on_lambda.rb +25 -28
  29. data/shark-on-lambda.gemspec +8 -3
  30. metadata +104 -24
  31. data/.gitlab-ci.yml +0 -39
  32. data/.travis.yml +0 -14
  33. data/lib/shark_on_lambda/api_gateway/base_controller.rb +0 -76
  34. data/lib/shark_on_lambda/api_gateway/base_handler.rb +0 -82
  35. data/lib/shark_on_lambda/api_gateway/concerns/http_response_validation.rb +0 -61
  36. data/lib/shark_on_lambda/api_gateway/errors.rb +0 -49
  37. data/lib/shark_on_lambda/api_gateway/headers.rb +0 -37
  38. data/lib/shark_on_lambda/api_gateway/jsonapi_controller.rb +0 -77
  39. data/lib/shark_on_lambda/api_gateway/jsonapi_parameters.rb +0 -68
  40. data/lib/shark_on_lambda/api_gateway/jsonapi_renderer.rb +0 -105
  41. data/lib/shark_on_lambda/api_gateway/parameters.rb +0 -18
  42. data/lib/shark_on_lambda/api_gateway/query.rb +0 -69
  43. data/lib/shark_on_lambda/api_gateway/request.rb +0 -148
  44. data/lib/shark_on_lambda/api_gateway/response.rb +0 -82
  45. data/lib/shark_on_lambda/api_gateway/serializers/base_error_serializer.rb +0 -20
  46. data/lib/shark_on_lambda/concerns/filter_actions.rb +0 -93
  47. data/lib/shark_on_lambda/tasks/build.rake +0 -146
  48. data/lib/shark_on_lambda/tasks.rb +0 -3
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ class JsonapiParameters
5
+ def initialize(params = {})
6
+ @class = default_serializer_classes
7
+ @fields = HashWithIndifferentAccess.new
8
+ @include = []
9
+
10
+ parse_params(params) if params.present?
11
+ end
12
+
13
+ def classes(serializer_classes = {})
14
+ @class = default_serializer_classes.merge(serializer_classes)
15
+ end
16
+
17
+ def fields(serialized_fields = {})
18
+ @fields = serialized_fields.with_indifferent_access
19
+ end
20
+
21
+ def includes(*includes_list)
22
+ @include = includes_list
23
+ end
24
+
25
+ def to_h
26
+ {
27
+ class: @class,
28
+ fields: @fields,
29
+ include: @include
30
+ }
31
+ end
32
+ alias to_hash to_h
33
+
34
+ protected
35
+
36
+ def default_serializer_classes
37
+ HashWithIndifferentAccess.new do |hash, key|
38
+ serializer_service = Inferrers::SerializerInferrer.new(key)
39
+ serializer_class = serializer_service.serializer_class
40
+ hash[key] = serializer_class
41
+ end
42
+ end
43
+
44
+ def parse_fields_params(fields_params)
45
+ return if fields_params.blank?
46
+
47
+ serialized_fields = fields_params.transform_values do |fields_param|
48
+ fields = fields_param.split(',')
49
+ fields.map!(&:strip)
50
+ fields.map!(&:to_sym)
51
+ end
52
+ fields(serialized_fields)
53
+ end
54
+
55
+ def parse_include_params(include_params)
56
+ include_params = JSONAPI::IncludeDirective.new(include_params)
57
+ include_params = include_params.to_hash
58
+ includes(include_params.with_indifferent_access)
59
+ end
60
+
61
+ def parse_params(params)
62
+ parse_fields_params(params[:fields])
63
+ parse_include_params(params[:include])
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ class JsonapiRenderer
5
+ def initialize(renderer: nil)
6
+ @renderer = renderer || JSONAPI::Serializable::Renderer.new
7
+ end
8
+
9
+ def render(object, options = {})
10
+ object = transform_active_model_errors(object)
11
+
12
+ unless renderable?(object, options)
13
+ return handle_unrenderable_objects(object, options)
14
+ end
15
+
16
+ if error?(object)
17
+ render_errors(object, options).to_json
18
+ else
19
+ render_success(object, options).to_json
20
+ end
21
+ end
22
+
23
+ protected
24
+
25
+ attr_reader :renderer
26
+
27
+ def active_model_error?(error)
28
+ return false unless defined?(::ActiveModel::Errors)
29
+
30
+ error.is_a?(::ActiveModel::Errors)
31
+ end
32
+
33
+ def attribute_name(attribute)
34
+ File.basename(attribute_path(attribute))
35
+ end
36
+
37
+ def attribute_path(attribute)
38
+ attribute.to_s.tr('.', '/').gsub(/\[(\d+)\]/, '/\1')
39
+ end
40
+
41
+ def error?(object)
42
+ if object.respond_to?(:to_ary)
43
+ object.to_ary.any? { |item| item.is_a?(StandardError) }
44
+ else
45
+ object.is_a?(StandardError)
46
+ end
47
+ end
48
+
49
+ def handle_unrenderable_objects(object, options)
50
+ objects_without_serializer = unrenderable_objects(object, options)
51
+ classes_without_serializer = objects_without_serializer.map(&:class)
52
+ classes_without_serializer.uniq!
53
+ errors = classes_without_serializer.map do |item|
54
+ Errors[500].new("Could not find serializer for: #{item.name}.")
55
+ end
56
+
57
+ render_errors(errors, options)
58
+ end
59
+
60
+ def render_errors(error, options)
61
+ renderer.render_errors(Array(error), options)
62
+ end
63
+
64
+ def render_success(object, options)
65
+ return { data: {} } if object.nil?
66
+
67
+ renderer.render(object, options)
68
+ end
69
+
70
+ def renderable?(object, options)
71
+ return true if object.nil?
72
+
73
+ serializers = serializer_classes(options)
74
+
75
+ if object.respond_to?(:to_ary)
76
+ object.to_ary.all? { |item| serializers[item.class.name].present? }
77
+ else
78
+ serializers[object.class.name].present?
79
+ end
80
+ end
81
+
82
+ def serializer_classes(options)
83
+ return @serializer_classes if defined?(@serializer_classes)
84
+
85
+ @serializer_classes = HashWithIndifferentAccess.new do |hash, key|
86
+ serializer_inferrer = Inferrers::SerializerInferrer.new(key)
87
+ serializer_class = serializer_inferrer.serializer_class
88
+ hash[key] = serializer_class
89
+ end
90
+ @serializer_classes.merge!(options[:class])
91
+ end
92
+
93
+ def transform_active_model_errors(errors)
94
+ return errors unless active_model_error?(errors)
95
+
96
+ result = errors.messages.map do |attribute, attribute_errors|
97
+ attribute_errors.map do |attribute_error|
98
+ error_message = "`#{attribute_name(attribute)}' #{attribute_error}"
99
+ Errors[422].new(error_message).tap do |error|
100
+ error.pointer = "/data/attributes/#{attribute_path(attribute)}"
101
+ end
102
+ end
103
+ end
104
+ result.flatten! || result
105
+ end
106
+
107
+ def unrenderable_objects(object, options)
108
+ if object.respond_to?(:to_ary)
109
+ object.to_ary.reject { |item| renderable?(item, options) }
110
+ else
111
+ renderable?(object, options) ? [] : [object]
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ module Middleware
5
+ class Base
6
+ attr_reader :app
7
+
8
+ def initialize(app = nil)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ dup.send('_call', env)
14
+ end
15
+
16
+ private
17
+
18
+ def _call(_env)
19
+ raise NotImplementedError
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ module Middleware
5
+ class JsonapiRescuer < Rescuer
6
+ private
7
+
8
+ def error_object(status, message)
9
+ {
10
+ status: status.to_s,
11
+ title: Rack::Utils::HTTP_STATUS_CODES[status],
12
+ detail: message
13
+ }
14
+ end
15
+
16
+ def error_response(status, headers, message)
17
+ body = {
18
+ errors: [
19
+ error_object(status, message)
20
+ ]
21
+ }.to_json
22
+
23
+ response_body = Rack::BodyProxy.new([body]) do
24
+ message.close if message.respond_to?(:close)
25
+ end
26
+
27
+ [status, headers, response_body]
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ module Middleware
5
+ class Rescuer < Base
6
+ private
7
+
8
+ def _call(env)
9
+ app.call(env)
10
+ rescue Errors::Base => e
11
+ rescue_shark_error(e)
12
+ rescue StandardError => e
13
+ rescue_standard_error(e)
14
+ end
15
+
16
+ def error_response(status, headers, message)
17
+ response_body = Rack::BodyProxy.new([message]) do
18
+ message.close if message.respond_to?(:close)
19
+ end
20
+
21
+ [status, headers, response_body]
22
+ end
23
+
24
+ def rescue_shark_error(error)
25
+ status = error.status || 500
26
+ error_response(status, {}, error.message)
27
+ end
28
+
29
+ def rescue_standard_error(error)
30
+ SharkOnLambda.logger.error(error.message)
31
+ SharkOnLambda.logger.error(error.backtrace.join("\n"))
32
+ Honeybadger.notify(error) if defined?(Honeybadger)
33
+
34
+ error_response(500, {}, error.message)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ # The `multiValueQueryStringParameters` object from the API Gateway event
5
+ # keeps _all_ values in an array, regardless of the actual size of the array
6
+ # and regardless of the "intent" of the query string parameter.
7
+ #
8
+ # In order to normalise this behaviour, we treat the query strings
9
+ #
10
+ # `key=value1&key=value2`
11
+ #
12
+ # and
13
+ #
14
+ # `key[]=value1&key[]=value2`
15
+ #
16
+ # the same. Both are to be serialised to the query string
17
+ #
18
+ # `key[]=value1&key[]=value2`
19
+ #
20
+ # However, the query strings
21
+ #
22
+ # `key=value`
23
+ #
24
+ # and
25
+ #
26
+ # `key[]=value`
27
+ #
28
+ # are _not_ to be treated the same.
29
+ class Query
30
+ def initialize(data = {})
31
+ @params = HashWithIndifferentAccess.new.merge(data)
32
+ end
33
+
34
+ def add(key, values)
35
+ if key.to_s.end_with?('[]')
36
+ actual_key = key[0..-3]
37
+ add_list(actual_key, values)
38
+ else
39
+ values.each { |value| add_item(key, value) }
40
+ end
41
+ end
42
+
43
+ def to_h
44
+ @params
45
+ end
46
+
47
+ def to_s
48
+ Rack::Utils.build_nested_query(to_h)
49
+ end
50
+
51
+ private
52
+
53
+ def add_item(key, value)
54
+ if @params[key].nil?
55
+ @params[key] = value
56
+ else
57
+ @params[key] = Array(@params[key])
58
+ @params[key] << value
59
+ end
60
+ end
61
+
62
+ def add_list(key, value)
63
+ @params[key] ||= []
64
+ @params[key].concat(value)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ module RackAdapters
5
+ class ApiGateway
6
+ attr_reader :context, :event
7
+
8
+ def initialize(context:, event:)
9
+ @context = context
10
+ @event = event.deep_stringify_keys
11
+ end
12
+
13
+ def build_response(status, headers, body)
14
+ body_content = ''
15
+ body.each { |line| body_content += line.to_s }
16
+ response = {
17
+ 'statusCode' => status,
18
+ 'headers' => headers,
19
+ 'body' => body_content
20
+ }
21
+ response['isBase64Encoded'] = false if elb?
22
+ response
23
+ end
24
+
25
+ def env
26
+ default_env.merge(env_request_metadata)
27
+ .merge(env_headers)
28
+ .merge(env_params)
29
+ .merge(env_body)
30
+ end
31
+
32
+ private
33
+
34
+ def default_env
35
+ {
36
+ 'SCRIPT_NAME' => '',
37
+ 'rack.version' => Rack::VERSION,
38
+ 'rack.errors' => $stderr,
39
+ 'rack.multithread' => true,
40
+ 'rack.multiprocess' => true,
41
+ 'rack.run_once' => false
42
+ }
43
+ end
44
+
45
+ def elb?
46
+ return false if event['requestContext'].nil?
47
+
48
+ event['requestContext'].key?('elb')
49
+ end
50
+
51
+ def env_body
52
+ result = event['body'] || ''
53
+ result = Base64.decode64(result) if event['isBase64Encoded']
54
+
55
+ {
56
+ 'rack.input' => StringIO.new(result).set_encoding(Encoding::BINARY)
57
+ }
58
+ end
59
+
60
+ def env_headers
61
+ result = {}
62
+ http_headers.each_pair do |header, value|
63
+ key = key_for_header(header)
64
+ result[key] = value.to_s
65
+ end
66
+ result
67
+ end
68
+
69
+ def env_params
70
+ {
71
+ 'QUERY_STRING' => query_string,
72
+ 'shark.path_parameters' => event['pathParameters']
73
+ }
74
+ end
75
+
76
+ def env_request_metadata
77
+ {
78
+ 'REQUEST_METHOD' => event['httpMethod'],
79
+ 'PATH_INFO' => path_info,
80
+ 'SERVER_NAME' => server_name,
81
+ 'SERVER_PORT' => server_port.to_s,
82
+ 'rack.url_scheme' => url_scheme
83
+ }
84
+ end
85
+
86
+ def http_headers
87
+ event['headers'] || {}
88
+ end
89
+
90
+ def key_for_header(header)
91
+ key = header.upcase.tr('-', '_')
92
+ case key
93
+ when 'CONTENT_LENGTH', 'CONTENT_TYPE' then key
94
+ else "HTTP_#{key}"
95
+ end
96
+ end
97
+
98
+ def path_info
99
+ event['path'] || ''
100
+ end
101
+
102
+ def query_string
103
+ return @query_string if defined?(@query_string)
104
+
105
+ query = Query.new
106
+ event['multiValueQueryStringParameters']&.each_pair do |key, value|
107
+ query.add(key, value)
108
+ end
109
+ @query_string = query.to_s
110
+ end
111
+
112
+ def server_name
113
+ http_headers['Host'] || 'localhost'
114
+ end
115
+
116
+ def server_port
117
+ http_headers['X-Forwarded-Port'] || 443
118
+ end
119
+
120
+ def url_scheme
121
+ http_headers['CloudFront-Forwarded-Proto'] ||
122
+ http_headers['X-Forwarded-Proto'] ||
123
+ 'https'
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ class Request < ActionDispatch::Request
5
+ def path_parameters
6
+ super.merge(env['shark.path_parameters'] || {})
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ class Response < ActionDispatch::Response
5
+ end
6
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ module RSpec
5
+ class EnvBuilder
6
+ attr_reader :action, :controller, :headers, :method, :params
7
+
8
+ def initialize(options = {})
9
+ @method = options.fetch(:method).to_s.upcase
10
+ @controller = options.fetch(:controller)
11
+ @action = options.fetch(:action)
12
+
13
+ @headers = (options[:headers] || {}).deep_stringify_keys
14
+ @params = options[:params]
15
+
16
+ initialize_env
17
+ add_headers
18
+ add_request_body
19
+ end
20
+
21
+ def build
22
+ env.deep_stringify_keys
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :env
28
+
29
+ def add_header(name, value)
30
+ name = name.upcase.tr('-', '_')
31
+ key = case name
32
+ when 'CONTENT_LENGTH', 'CONTENT_TYPE' then name
33
+ else "HTTP_#{name}"
34
+ end
35
+ @env[key] = value.to_s
36
+ end
37
+
38
+ def add_headers
39
+ headers.each_pair { |name, value| add_header(name, value) }
40
+ end
41
+
42
+ def add_request_body
43
+ return if %w[GET HEAD OPTIONS].include?(env['REQUEST_METHOD'])
44
+ return unless params.is_a?(Hash)
45
+
46
+ body = params.to_json
47
+
48
+ env['rack.input'] = StringIO.new(body).set_encoding(Encoding::BINARY)
49
+ env['CONTENT_TYPE'] = 'application/json'
50
+ env['CONTENT_LENGTH'] = body.bytesize.to_s
51
+ end
52
+
53
+ def initialize_env
54
+ @env = Rack::MockRequest.env_for(
55
+ 'https://localhost:9292',
56
+ method: method,
57
+ params: params,
58
+ 'shark.controller' => controller,
59
+ 'shark.action' => action
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ module RSpec
5
+ module Helpers
6
+ def delete(controller_method, options = {})
7
+ make_request('DELETE', controller_method, options)
8
+ end
9
+
10
+ def get(controller_method, options = {})
11
+ make_request('GET', controller_method, options)
12
+ end
13
+
14
+ def patch(controller_method, options = {})
15
+ make_request('PATCH', controller_method, options)
16
+ end
17
+
18
+ def post(controller_method, options = {})
19
+ make_request('POST', controller_method, options)
20
+ end
21
+
22
+ def put(controller_method, options = {})
23
+ make_request('PUT', controller_method, options)
24
+ end
25
+
26
+ def response
27
+ if @response.nil?
28
+ raise 'You must make a request before you can request a response.'
29
+ end
30
+
31
+ @response
32
+ end
33
+
34
+ private
35
+
36
+ def build_env(method, action, options = {})
37
+ env_builder = EnvBuilder.new(
38
+ method: method,
39
+ controller: controller_name,
40
+ action: action,
41
+ headers: options[:headers],
42
+ params: options[:params]
43
+ )
44
+ env_builder.build
45
+ end
46
+
47
+ def controller?
48
+ controller_name.present?
49
+ end
50
+
51
+ def controller_name
52
+ self.class.ancestors.find do |klass|
53
+ klass.name.end_with?('Controller')
54
+ end&.description
55
+ end
56
+
57
+ def dispatch_request(env, skip_middleware: false)
58
+ return SharkOnLambda.application.call(env) unless skip_middleware
59
+
60
+ controller_class = env['shark.controller'].constantize
61
+ action = env['shark.action']
62
+
63
+ request = Request.new(env)
64
+ response = Response.new
65
+ controller_class.dispatch(action, request, response)
66
+ response.prepare!
67
+ end
68
+
69
+ def make_request(method, action, options = {})
70
+ raise ArgumentError, 'Cannot find controller name.' unless controller?
71
+
72
+ options = options.with_indifferent_access
73
+ env = build_env(method, action, options)
74
+
75
+ status, headers, body = dispatch_request(
76
+ env,
77
+ skip_middleware: options[:skip_middleware]
78
+ )
79
+ errors = env['rack.errors']
80
+ @response = Rack::MockResponse.new(status, headers, body, errors)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ module RSpec
5
+ module JsonapiHelpers
6
+ include Helpers
7
+
8
+ def jsonapi_attributes
9
+ jsonapi_data[:attributes] || {}
10
+ end
11
+
12
+ def jsonapi_data
13
+ parsed_body[:data] || {}
14
+ end
15
+
16
+ def jsonapi_errors
17
+ parsed_body[:errors] || []
18
+ end
19
+
20
+ private
21
+
22
+ def parsed_body
23
+ @parsed_body ||= JSON.parse(response.body).with_indifferent_access
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SharkOnLambda
4
- VERSION = '0.6.10'
4
+ VERSION = '1.0.0.rc1'
5
5
  end