shark-on-lambda 0.0.0 → 0.6.7

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +5 -0
  3. data/.gitlab-ci.yml +13 -0
  4. data/.rubocop.yml +13 -0
  5. data/.travis.yml +9 -2
  6. data/README.md +184 -18
  7. data/Rakefile +2 -0
  8. data/bin/console +4 -9
  9. data/changelog.md +17 -0
  10. data/gems.locked +92 -0
  11. data/{Gemfile → gems.rb} +2 -1
  12. data/lib/shark-on-lambda.rb +1 -5
  13. data/lib/shark_on_lambda.rb +104 -1
  14. data/lib/shark_on_lambda/api_gateway/base_controller.rb +76 -0
  15. data/lib/shark_on_lambda/api_gateway/base_handler.rb +82 -0
  16. data/lib/shark_on_lambda/api_gateway/concerns/http_response_validation.rb +61 -0
  17. data/lib/shark_on_lambda/api_gateway/errors.rb +49 -0
  18. data/lib/shark_on_lambda/api_gateway/headers.rb +37 -0
  19. data/lib/shark_on_lambda/api_gateway/jsonapi_controller.rb +77 -0
  20. data/lib/shark_on_lambda/api_gateway/jsonapi_parameters.rb +68 -0
  21. data/lib/shark_on_lambda/api_gateway/jsonapi_renderer.rb +105 -0
  22. data/lib/shark_on_lambda/api_gateway/parameters.rb +18 -0
  23. data/lib/shark_on_lambda/api_gateway/query.rb +69 -0
  24. data/lib/shark_on_lambda/api_gateway/request.rb +148 -0
  25. data/lib/shark_on_lambda/api_gateway/response.rb +82 -0
  26. data/lib/shark_on_lambda/api_gateway/serializers/base_error_serializer.rb +20 -0
  27. data/lib/shark_on_lambda/concerns/filter_actions.rb +82 -0
  28. data/lib/shark_on_lambda/concerns/resettable_singleton.rb +18 -0
  29. data/lib/shark_on_lambda/concerns/yaml_config_loader.rb +28 -0
  30. data/lib/shark_on_lambda/configuration.rb +71 -0
  31. data/lib/shark_on_lambda/inferrers/name_inferrer.rb +66 -0
  32. data/lib/shark_on_lambda/inferrers/serializer_inferrer.rb +45 -0
  33. data/lib/shark_on_lambda/secrets.rb +43 -0
  34. data/lib/shark_on_lambda/tasks.rb +3 -0
  35. data/lib/shark_on_lambda/tasks/build.rake +146 -0
  36. data/lib/{shark-on-lambda → shark_on_lambda}/version.rb +1 -1
  37. data/shark-on-lambda.gemspec +21 -6
  38. metadata +158 -20
  39. data/Gemfile.lock +0 -35
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ module ApiGateway
5
+ class JsonapiParameters
6
+ def initialize(params = {})
7
+ @class = default_serializer_classes
8
+ @fields = HashWithIndifferentAccess.new
9
+ @include = []
10
+
11
+ parse_params(params) if params.present?
12
+ end
13
+
14
+ def classes(serializer_classes = {})
15
+ @class = default_serializer_classes.merge(serializer_classes)
16
+ end
17
+
18
+ def fields(serialized_fields = {})
19
+ @fields = serialized_fields.with_indifferent_access
20
+ end
21
+
22
+ def includes(*includes_list)
23
+ @include = includes_list
24
+ end
25
+
26
+ def to_h
27
+ {
28
+ class: @class,
29
+ fields: @fields,
30
+ include: @include
31
+ }
32
+ end
33
+ alias to_hash to_h
34
+
35
+ protected
36
+
37
+ def default_serializer_classes
38
+ HashWithIndifferentAccess.new do |hash, key|
39
+ serializer_service = Inferrers::SerializerInferrer.new(key)
40
+ serializer_class = serializer_service.serializer_class
41
+ hash[key] = serializer_class
42
+ end
43
+ end
44
+
45
+ def parse_fields_params(fields_params)
46
+ return if fields_params.blank?
47
+
48
+ serialized_fields = fields_params.transform_values do |fields_param|
49
+ fields = fields_param.split(',')
50
+ fields.map!(&:strip)
51
+ fields.map!(&:to_sym)
52
+ end
53
+ fields(serialized_fields)
54
+ end
55
+
56
+ def parse_include_params(include_params)
57
+ include_params = ::JSONAPI::IncludeDirective.new(include_params)
58
+ include_params = include_params.to_hash
59
+ includes(include_params.with_indifferent_access)
60
+ end
61
+
62
+ def parse_params(params)
63
+ parse_fields_params(params[:fields])
64
+ parse_include_params(params[:include])
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ module ApiGateway
5
+ class JsonapiRenderer
6
+ def initialize(renderer: nil)
7
+ @renderer = renderer || ::JSONAPI::Serializable::Renderer.new
8
+ end
9
+
10
+ def render(object, options = {})
11
+ object = transform_active_model_errors(object)
12
+
13
+ unless renderable?(object, options)
14
+ return handle_unrenderable_objects(object, options)
15
+ end
16
+
17
+ if error?(object)
18
+ render_errors(object, options)
19
+ else
20
+ render_success(object, options)
21
+ end
22
+ end
23
+
24
+ protected
25
+
26
+ attr_reader :renderer
27
+
28
+ def active_model_error?(error)
29
+ return false unless defined?(::ActiveModel::Errors)
30
+
31
+ error.is_a?(::ActiveModel::Errors)
32
+ end
33
+
34
+ def error?(object)
35
+ if object.respond_to?(:to_ary)
36
+ object.to_ary.any? { |item| item.is_a?(StandardError) }
37
+ else
38
+ object.is_a?(StandardError)
39
+ end
40
+ end
41
+
42
+ def handle_unrenderable_objects(object, options)
43
+ objects_without_serializer = unrenderable_objects(object, options)
44
+ classes_without_serializer = objects_without_serializer.map(&:class)
45
+ classes_without_serializer.uniq!
46
+ errors = classes_without_serializer.map do |item|
47
+ Errors[500].new("Could not find serializer for: #{item.name}.")
48
+ end
49
+
50
+ render_errors(errors, options)
51
+ end
52
+
53
+ def render_errors(error, options)
54
+ renderer.render_errors(Array(error), options)
55
+ end
56
+
57
+ def render_success(object, options)
58
+ renderer.render(object, options)
59
+ end
60
+
61
+ def renderable?(object, options)
62
+ serializers = serializer_classes(options)
63
+
64
+ if object.respond_to?(:to_ary)
65
+ object.to_ary.all? { |item| serializers[item.class.name].present? }
66
+ else
67
+ serializers[object.class.name].present?
68
+ end
69
+ end
70
+
71
+ def serializer_classes(options)
72
+ return @serializer_classes if defined?(@serializer_classes)
73
+
74
+ @serializer_classes = HashWithIndifferentAccess.new do |hash, key|
75
+ serializer_inferrer = Inferrers::SerializerInferrer.new(key)
76
+ serializer_class = serializer_inferrer.serializer_class
77
+ hash[key] = serializer_class
78
+ end
79
+ @serializer_classes.merge!(options[:class])
80
+ end
81
+
82
+ def transform_active_model_errors(errors)
83
+ return errors unless active_model_error?(errors)
84
+
85
+ result = errors.messages.map do |attribute, attribute_errors|
86
+ attribute_errors.map do |attribute_error|
87
+ error_message = "`#{attribute}' #{attribute_error}"
88
+ Errors[422].new(error_message).tap do |error|
89
+ error.pointer = "/data/attributes/#{attribute}"
90
+ end
91
+ end
92
+ end
93
+ result.flatten! || result
94
+ end
95
+
96
+ def unrenderable_objects(object, options)
97
+ if object.respond_to?(:to_ary)
98
+ object.to_ary.reject { |item| renderable?(item, options) }
99
+ else
100
+ renderable?(object, options) ? [] : [object]
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ module ApiGateway
5
+ class Parameters
6
+ extend ::Forwardable
7
+
8
+ def initialize(request)
9
+ @params = ::HashWithIndifferentAccess.new
10
+ @params = @params.merge(request.request_parameters)
11
+ @params = @params.merge(request.query_parameters)
12
+ @params = @params.merge(request.path_parameters)
13
+ end
14
+
15
+ def_instance_delegators :@params, :[], :as_json
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ module ApiGateway
5
+ # The `multiValueQueryStringParameters` object from the API Gateway event
6
+ # keeps _all_ values in an array, regardless of the actual size of the array
7
+ # and regardless of the "intent" of the query string parameter.
8
+ #
9
+ # In order to normalise this behaviour, we treat the query strings
10
+ #
11
+ # `key=value1&key=value2`
12
+ #
13
+ # and
14
+ #
15
+ # `key[]=value1&key[]=value2`
16
+ #
17
+ # the same. Both are to be serialised to the query string
18
+ #
19
+ # `key[]=value1&key[]=value2`
20
+ #
21
+ # However, the query strings
22
+ #
23
+ # `key=value`
24
+ #
25
+ # and
26
+ #
27
+ # `key[]=value`
28
+ #
29
+ # are _not_ to be treated the same.
30
+ class Query
31
+ def initialize
32
+ @params = HashWithIndifferentAccess.new
33
+ end
34
+
35
+ def add(key, values)
36
+ if key.end_with?('[]')
37
+ actual_key = key[0..-3]
38
+ add_list(actual_key, values)
39
+ else
40
+ values.each { |value| add_item(key, value) }
41
+ end
42
+ end
43
+
44
+ def to_h
45
+ @params
46
+ end
47
+
48
+ def to_s
49
+ Rack::Utils.build_nested_query(to_h)
50
+ end
51
+
52
+ private
53
+
54
+ def add_item(key, value)
55
+ if @params[key].nil?
56
+ @params[key] = value
57
+ else
58
+ @params[key] = Array(@params[key])
59
+ @params[key] << value
60
+ end
61
+ end
62
+
63
+ def add_list(key, value)
64
+ @params[key] ||= []
65
+ @params[key].concat(value)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ module ApiGateway
5
+ class Request
6
+ attr_reader :event, :context
7
+
8
+ LOCALHOST = Regexp.union([/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
9
+ /^::1$/,
10
+ /^0:0:0:0:0:0:0:1(%.*)?$/]).freeze
11
+
12
+ def initialize(event:, context:)
13
+ @event = event
14
+ @context = context
15
+ @headers = Headers.new
16
+
17
+ event['headers']&.each { |key, value| @headers[key] = value }
18
+ end
19
+
20
+ def authorization
21
+ @headers['authorization'] || @headers['x-authorization']
22
+ end
23
+
24
+ def body
25
+ StringIO.new(raw_post)
26
+ end
27
+
28
+ def content_length
29
+ raw_post.present? ? raw_post.bytesize : 0
30
+ end
31
+
32
+ def form_data?
33
+ raw_post.present?
34
+ end
35
+
36
+ # This method does not necessarily return the full path the client sent
37
+ # for the request: AWS API Gateway does not provide such an attribute.
38
+ # We therefore put together the query string using the
39
+ # event['multiValueQueryStringParameters'] property and return a path
40
+ # that is for almost all purposes equivalent to the client's request path.
41
+ # But keep in mind it is NOT the same!
42
+ def fullpath
43
+ return @fullpath if defined?(@fullpath)
44
+
45
+ uri = URI.parse(event['requestContext']['path'])
46
+ uri.query = query_string if query_string.present?
47
+ @fullpath = uri.to_s
48
+ end
49
+ alias original_fullpath fullpath
50
+
51
+ def headers
52
+ @headers.to_h
53
+ end
54
+
55
+ def ip
56
+ event['requestContext']['identity']['sourceIp']
57
+ end
58
+ alias remote_ip ip
59
+
60
+ def key?(key)
61
+ @headers.key?(key)
62
+ end
63
+
64
+ def local?
65
+ (LOCALHOST =~ remote_ip).present?
66
+ end
67
+
68
+ def media_type
69
+ @headers['content-type']
70
+ end
71
+
72
+ def method
73
+ event['httpMethod']
74
+ end
75
+ alias request_method method
76
+
77
+ def method_symbol
78
+ return nil if method.blank?
79
+
80
+ method.downcase.to_sym
81
+ end
82
+ alias request_method_symbol method_symbol
83
+
84
+ def original_url
85
+ hostname = event['requestContext']['domainName']
86
+ URI.join("https://#{hostname}", original_fullpath).to_s
87
+ end
88
+
89
+ def path_parameters
90
+ return @path_parameters if defined?(@path_parameters)
91
+
92
+ @path_parameters = HashWithIndifferentAccess.new
93
+ @path_parameters = @path_parameters.merge(event['pathParameters'] || {})
94
+ end
95
+
96
+ def query_parameters
97
+ return @query_parameters if defined?(@query_parameters)
98
+
99
+ # We have to jump through the Rack::Utils hoops here, because the event
100
+ # object from the AWS Gateway deserialises the query string in a "wrong"
101
+ # way, so we need to put it back together and deserialise it with
102
+ # Rack::Utils.parse_nested_query.
103
+ data = Rack::Utils.parse_nested_query(query_string)
104
+ @query_parameters = HashWithIndifferentAccess.new
105
+ @query_parameters = @query_parameters.merge(data)
106
+ end
107
+ alias GET query_parameters
108
+
109
+ def raw_post
110
+ return @raw_post if defined?(@raw_post)
111
+
112
+ @raw_post = event['body']
113
+ @raw_post = Base64.decode64(@raw_post) if event['isBase64Encoded']
114
+ @raw_post
115
+ end
116
+
117
+ def request_parameters
118
+ return @request_parameters if defined?(@request_parameters)
119
+ return {} if raw_post.blank?
120
+
121
+ data = JSON.parse(raw_post)
122
+ @request_parameters = HashWithIndifferentAccess.new.merge(data)
123
+ rescue JSON::ParserError => e
124
+ raise Errors[400], e.message
125
+ rescue StandardError
126
+ raise Errors[400], 'The request body must be empty or a JSON object.'
127
+ end
128
+ alias POST request_parameters
129
+
130
+ def xml_http_request?
131
+ (headers['x-requested-with'] =~ /XMLHttpRequest/i).present?
132
+ end
133
+ alias xhr? xml_http_request?
134
+
135
+ protected
136
+
137
+ def query_string
138
+ return @query_string if defined?(@query_string)
139
+
140
+ query = Query.new
141
+ event['multiValueQueryStringParameters']&.each_pair do |key, value|
142
+ query.add(key, value)
143
+ end
144
+ @query_string = query.to_s
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharkOnLambda
4
+ module ApiGateway
5
+ class Response
6
+ attr_accessor :body, :charset, :content_type
7
+ attr_reader :headers
8
+ attr_writer :status
9
+
10
+ STATUS_WITH_NO_ENTITY_BODY = ((100..199).to_a + [204, 304]).freeze
11
+
12
+ def self.default_charset
13
+ 'utf-8'
14
+ end
15
+
16
+ def self.default_content_type
17
+ 'application/vnd.api+json'
18
+ end
19
+
20
+ def initialize(headers: nil)
21
+ @body = nil
22
+ @charset = self.class.default_charset
23
+ @content_type = self.class.default_content_type
24
+ @headers = headers || Headers.new
25
+ @headers['content-type'] = self.class.default_content_type
26
+ @status = 200
27
+ end
28
+
29
+ def delete_header(key)
30
+ @headers.delete(key)
31
+ end
32
+
33
+ def get_header(key)
34
+ @headers[key]
35
+ end
36
+
37
+ # rubocop:disable Naming/PredicateName
38
+ def has_header?(key)
39
+ @headers.key?(key)
40
+ end
41
+ # rubocop:enable Naming/PredicateName
42
+
43
+ def message
44
+ ::Rack::Utils::HTTP_STATUS_CODES[response_code]
45
+ end
46
+ alias status_message message
47
+
48
+ def response_code
49
+ @status
50
+ end
51
+
52
+ def set_header(key, value)
53
+ @headers[key] = value
54
+ end
55
+
56
+ def to_h
57
+ {
58
+ statusCode: response_status_code,
59
+ headers: headers.to_h,
60
+ body: response_body
61
+ }
62
+ end
63
+
64
+ protected
65
+
66
+ def response_status_code
67
+ return response_code.to_i if response_body.present?
68
+ return 204 if (200..299).cover?(response_code.to_i)
69
+ return 304 if (300..399).cover?(response_code.to_i)
70
+
71
+ response_code.to_i
72
+ end
73
+
74
+ def response_body
75
+ return if STATUS_WITH_NO_ENTITY_BODY.include?(response_code)
76
+ return if body.blank?
77
+
78
+ body.is_a?(String) ? body : body.to_json
79
+ end
80
+ end
81
+ end
82
+ end