belt 0.0.1 → 0.0.6
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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +25 -0
- data/README.md +266 -4
- data/certs/stowzilla.pem +26 -0
- data/lib/belt/action_router.rb +166 -0
- data/lib/belt/helpers/cors_origin.rb +55 -0
- data/lib/belt/helpers/error_logging.rb +55 -0
- data/lib/belt/helpers/response.rb +70 -0
- data/lib/belt/lambda_handler.rb +96 -0
- data/lib/belt/observability.rb +64 -0
- data/lib/belt/parameters.rb +177 -0
- data/lib/belt/version.rb +3 -1
- data/lib/belt.rb +54 -1
- data/lib/belt_controller/base.rb +282 -0
- data.tar.gz.sig +2 -0
- metadata +57 -5
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'error_logging'
|
|
4
|
+
require_relative 'cors_origin'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module Helpers
|
|
8
|
+
module Response
|
|
9
|
+
def cors_headers(event = nil)
|
|
10
|
+
event = @event if event.nil? && instance_variable_defined?(:@event)
|
|
11
|
+
origin = CorsOrigin.resolve_origin(CorsOrigin.origin_from_event(event))
|
|
12
|
+
headers = {
|
|
13
|
+
'Access-Control-Allow-Headers' => 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
|
|
14
|
+
'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
|
|
15
|
+
'Access-Control-Max-Age' => '300',
|
|
16
|
+
'Content-Type' => 'application/json'
|
|
17
|
+
}
|
|
18
|
+
headers['Access-Control-Allow-Origin'] = origin if origin
|
|
19
|
+
headers
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def success_response(body, status_code = 200)
|
|
23
|
+
{ statusCode: status_code, headers: cors_headers, body: JSON.generate(body) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def error_response(message, status_code = 400, error_details = nil)
|
|
27
|
+
body = { error: message }
|
|
28
|
+
if error_details
|
|
29
|
+
body[:details] = error_details.is_a?(Hash) ? error_details : { message: error_details.to_s }
|
|
30
|
+
end
|
|
31
|
+
{ statusCode: status_code, headers: cors_headers, body: JSON.generate(body) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def html_response(html, status_code = 200)
|
|
35
|
+
event = @event if instance_variable_defined?(:@event)
|
|
36
|
+
origin = CorsOrigin.resolve_origin(CorsOrigin.origin_from_event(event))
|
|
37
|
+
headers = { 'Content-Type' => 'text/html; charset=utf-8' }
|
|
38
|
+
headers['Access-Control-Allow-Origin'] = origin if origin
|
|
39
|
+
{ statusCode: status_code, headers: headers, body: html }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def handle_error_and_respond(error, message, context = {}, status_code = 500)
|
|
43
|
+
log_error(message, error, context)
|
|
44
|
+
if verbose_errors_enabled?
|
|
45
|
+
error_details = {
|
|
46
|
+
type: error.class.name, message: error.message,
|
|
47
|
+
backtrace: ErrorLogging.filter_backtrace(error.backtrace || []),
|
|
48
|
+
environment: ENV.fetch('ENVIRONMENT', nil)
|
|
49
|
+
}
|
|
50
|
+
error_details[:context] = context unless context.empty?
|
|
51
|
+
error_response(message, status_code, error_details)
|
|
52
|
+
else
|
|
53
|
+
error_response(message, status_code)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def verbose_errors_enabled?
|
|
60
|
+
env = ENV['ENVIRONMENT']&.downcase || ''
|
|
61
|
+
env.start_with?('dev') || env == 'local' || env == 'test'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def log_error(message, error, context = {})
|
|
65
|
+
logger_instance = defined?(LOGGER) && LOGGER.respond_to?(:instance) ? LOGGER.instance : @logger
|
|
66
|
+
ErrorLogging.log_error(logger_instance, message, error, context)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'lambda_loadout'
|
|
5
|
+
require_relative 'observability'
|
|
6
|
+
require_relative 'helpers/response'
|
|
7
|
+
|
|
8
|
+
module Belt
|
|
9
|
+
# Lambda handler module — include in your Lambda entry point to get automatic
|
|
10
|
+
# observability setup, CORS preflight handling, JSON body parsing, and error wrapping.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# require "belt"
|
|
14
|
+
#
|
|
15
|
+
# include Belt::LambdaHandler
|
|
16
|
+
#
|
|
17
|
+
# def execute(path:, body:, event:)
|
|
18
|
+
# ROUTER.route(event: event, body: body)
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
module LambdaHandler
|
|
22
|
+
include Belt::Helpers::Response
|
|
23
|
+
|
|
24
|
+
attr_accessor :logger, :metrics
|
|
25
|
+
|
|
26
|
+
def self.included(base)
|
|
27
|
+
base.instance_variable_set(:@belt_lambda_handler_included, true)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# API Gateway Lambda handler.
|
|
31
|
+
# Handles HTTP requests with automatic CORS, body parsing, observability, and error wrapping.
|
|
32
|
+
# Override `execute` to provide your own routing logic.
|
|
33
|
+
def lambda_handler(event:, context:)
|
|
34
|
+
init_observability(context: context)
|
|
35
|
+
|
|
36
|
+
LambdaLoadout.with_logging_and_metrics(
|
|
37
|
+
logger,
|
|
38
|
+
metrics,
|
|
39
|
+
context,
|
|
40
|
+
event: event,
|
|
41
|
+
error_notification_config: {
|
|
42
|
+
sns_topic_arn: ENV.fetch('ERROR_NOTIFICATION_TOPIC_ARN', nil)
|
|
43
|
+
}
|
|
44
|
+
) do
|
|
45
|
+
logger.info('Lambda invoked',
|
|
46
|
+
http_method: event['httpMethod'],
|
|
47
|
+
path: event['path'],
|
|
48
|
+
source_ip: event.dig('requestContext', 'identity', 'sourceIp'))
|
|
49
|
+
|
|
50
|
+
return { statusCode: 200, headers: cors_headers(event), body: '{}' } if event['httpMethod'] == 'OPTIONS'
|
|
51
|
+
|
|
52
|
+
begin
|
|
53
|
+
body = JSON.parse(event['body'] || '{}')
|
|
54
|
+
rescue JSON::ParserError
|
|
55
|
+
return error_response('Invalid JSON in request body')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
begin
|
|
59
|
+
result = execute(path: event['path'], body: body, event: event)
|
|
60
|
+
logger.info('Request completed', status_code: result[:statusCode], path: event['path'])
|
|
61
|
+
result
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
handle_error_and_respond(e, 'Unhandled error during request processing',
|
|
64
|
+
{ path: event['path'], method: event['httpMethod'] })
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Belt::Helpers::ErrorLogging.log_error(@logger, 'Unhandled Lambda error', e,
|
|
69
|
+
{ phase: 'lambda_handler', path: event&.dig('path') })
|
|
70
|
+
|
|
71
|
+
body = { error: 'Internal server error' }
|
|
72
|
+
if verbose_errors_enabled?
|
|
73
|
+
body[:message] = e.message
|
|
74
|
+
body[:type] = e.class.name
|
|
75
|
+
body[:backtrace] = Belt::Helpers::ErrorLogging.filter_backtrace(e.backtrace || [])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
{ statusCode: 500, headers: cors_headers, body: JSON.generate(body) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def init_observability(context:)
|
|
84
|
+
service_name = ENV['ACTION'] || context.function_name.split('-').last
|
|
85
|
+
|
|
86
|
+
@logger = LambdaLoadout::Logger.new(service: service_name)
|
|
87
|
+
@metrics = LambdaLoadout::Metrics.new(
|
|
88
|
+
namespace: ENV['BELT_METRICS_NAMESPACE'] || 'Belt',
|
|
89
|
+
service: service_name
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
Belt::Observability::Logger.instance = @logger
|
|
93
|
+
Belt::Observability::Metrics.instance = @metrics
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
# Global facades for Lambda Loadout logger and metrics.
|
|
5
|
+
# Set by Belt::LambdaHandler at the start of each request.
|
|
6
|
+
# Provides clean access to observability from anywhere in the codebase.
|
|
7
|
+
module Observability
|
|
8
|
+
# Logger facade — delegates to a LambdaLoadout::Logger instance
|
|
9
|
+
module Logger
|
|
10
|
+
class << self
|
|
11
|
+
attr_accessor :instance
|
|
12
|
+
|
|
13
|
+
def info(message, **context)
|
|
14
|
+
instance&.info(message, **context)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def error(message, exception = nil, **context)
|
|
18
|
+
if exception
|
|
19
|
+
instance&.error(message, exception, **context)
|
|
20
|
+
else
|
|
21
|
+
instance&.error(message, **context)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def warn(message, exception = nil, **context)
|
|
26
|
+
if exception
|
|
27
|
+
instance&.warn(message, exception, **context)
|
|
28
|
+
else
|
|
29
|
+
instance&.warn(message, **context)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def debug(message, **context)
|
|
34
|
+
instance&.debug(message, **context)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Metrics facade — delegates to a LambdaLoadout::Metrics instance
|
|
40
|
+
module Metrics
|
|
41
|
+
class << self
|
|
42
|
+
attr_accessor :instance
|
|
43
|
+
|
|
44
|
+
def add_metric(name:, unit:, value:)
|
|
45
|
+
instance&.add_metric(name: name, unit: unit, value: value)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def add_dimension(name:, value:)
|
|
49
|
+
instance&.add_dimension(name: name, value: value)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def track_event(event_name, **dimensions)
|
|
53
|
+
instance&.add_metric(name: event_name, unit: 'Count', value: 1)
|
|
54
|
+
dimensions.each { |k, v| instance&.add_dimension(name: k.to_s, value: v.to_s) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def track_value(metric_name, value, unit: 'None', **dimensions)
|
|
58
|
+
instance&.add_metric(name: metric_name, unit: unit, value: value)
|
|
59
|
+
dimensions.each { |k, v| instance&.add_dimension(name: k.to_s, value: v.to_s) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
# Rails-like Strong Parameters for Lambda controllers.
|
|
7
|
+
# Provides secure parameter filtering without requiring Rails.
|
|
8
|
+
module ActionController
|
|
9
|
+
class ParameterMissing < StandardError
|
|
10
|
+
attr_reader :param
|
|
11
|
+
|
|
12
|
+
def initialize(param)
|
|
13
|
+
@param = param.to_s
|
|
14
|
+
super("param is missing or the value is empty: #{@param}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class UnpermittedParameters < StandardError
|
|
19
|
+
attr_reader :params
|
|
20
|
+
|
|
21
|
+
def initialize(params)
|
|
22
|
+
@params = Array(params).map(&:to_s)
|
|
23
|
+
super("found unpermitted parameter(s): #{@params.join(', ')}")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Parameters
|
|
28
|
+
def initialize(params = {}, permitted = false)
|
|
29
|
+
@params = normalize_keys(params)
|
|
30
|
+
@permitted = permitted
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def [](key)
|
|
34
|
+
@params[key.to_s]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fetch(key, *, &)
|
|
38
|
+
@params.fetch(key.to_s, *, &)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def key?(key)
|
|
42
|
+
@params.key?(key.to_s)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def keys
|
|
46
|
+
@params.keys
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def values
|
|
50
|
+
@params.values
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def each(&)
|
|
54
|
+
@params.each(&)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def empty?
|
|
58
|
+
@params.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def any?
|
|
62
|
+
@params.any?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def require(key)
|
|
66
|
+
value = @params[key.to_s]
|
|
67
|
+
raise ParameterMissing, key if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
68
|
+
|
|
69
|
+
value.is_a?(Hash) ? Parameters.new(value) : value
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def permit(*filters)
|
|
73
|
+
permitted_params = {}
|
|
74
|
+
|
|
75
|
+
filters.each do |filter|
|
|
76
|
+
case filter
|
|
77
|
+
when Symbol, String
|
|
78
|
+
key = filter.to_s
|
|
79
|
+
permitted_params[key] = @params[key] if @params.key?(key)
|
|
80
|
+
when Hash
|
|
81
|
+
filter.each do |key, nested_filter|
|
|
82
|
+
key = key.to_s
|
|
83
|
+
next unless @params.key?(key)
|
|
84
|
+
|
|
85
|
+
permitted_params[key] = permit_nested(@params[key], nested_filter)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
Parameters.new(permitted_params, true)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def permitted?
|
|
94
|
+
@permitted
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def to_h
|
|
98
|
+
raise UnpermittedParameters, @params.keys unless @permitted
|
|
99
|
+
|
|
100
|
+
deep_to_h(@params)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def to_unsafe_h
|
|
104
|
+
deep_to_h(@params)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def merge(other)
|
|
108
|
+
other_hash = other.is_a?(Parameters) ? other.to_unsafe_h : other
|
|
109
|
+
Parameters.new(@params.merge(normalize_keys(other_hash)), @permitted)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def slice(*keys)
|
|
113
|
+
Parameters.new(@params.slice(*keys.map(&:to_s)), @permitted)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def except(*keys)
|
|
117
|
+
Parameters.new(@params.except(*keys.map(&:to_s)), @permitted)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def normalize_keys(hash)
|
|
123
|
+
return {} unless hash.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
hash.transform_keys { |key| key.to_s.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, '') }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def permit_nested(value, filter)
|
|
129
|
+
case value
|
|
130
|
+
when Hash
|
|
131
|
+
if filter == {}
|
|
132
|
+
value
|
|
133
|
+
elsif filter.is_a?(Array)
|
|
134
|
+
Parameters.new(value).permit(*filter).to_unsafe_h
|
|
135
|
+
else
|
|
136
|
+
value
|
|
137
|
+
end
|
|
138
|
+
when Array
|
|
139
|
+
if filter == []
|
|
140
|
+
value.select { |v| scalar?(v) }
|
|
141
|
+
elsif filter.is_a?(Array)
|
|
142
|
+
value.map do |item|
|
|
143
|
+
next item unless item.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
Parameters.new(item).permit(*filter).to_unsafe_h
|
|
146
|
+
end
|
|
147
|
+
else
|
|
148
|
+
value
|
|
149
|
+
end
|
|
150
|
+
else
|
|
151
|
+
value
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def scalar?(value)
|
|
156
|
+
case value
|
|
157
|
+
when String, Symbol, NilClass, Numeric, TrueClass, FalseClass, Date, Time, DateTime
|
|
158
|
+
true
|
|
159
|
+
else
|
|
160
|
+
false
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def deep_to_h(value)
|
|
165
|
+
case value
|
|
166
|
+
when Hash
|
|
167
|
+
value.transform_values { |v| deep_to_h(v) }
|
|
168
|
+
when Array
|
|
169
|
+
value.map { |v| deep_to_h(v) }
|
|
170
|
+
when Parameters
|
|
171
|
+
value.to_unsafe_h
|
|
172
|
+
else
|
|
173
|
+
value
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
data/lib/belt/version.rb
CHANGED
data/lib/belt.rb
CHANGED
|
@@ -1,3 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'belt/version'
|
|
4
|
+
require_relative 'belt/parameters'
|
|
5
|
+
require_relative 'belt/observability'
|
|
6
|
+
require_relative 'belt/lambda_handler'
|
|
7
|
+
require_relative 'belt/action_router'
|
|
8
|
+
|
|
1
9
|
module Belt
|
|
2
|
-
|
|
10
|
+
class AuthenticationError < StandardError; end
|
|
11
|
+
class RecordNotFound < StandardError; end
|
|
12
|
+
class ActionNotFound < StandardError; end
|
|
13
|
+
|
|
14
|
+
@controller_paths = []
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
attr_reader :controller_paths
|
|
18
|
+
|
|
19
|
+
# Auto-discover lambda/controllers dirs in all loaded gems
|
|
20
|
+
def gem_controller_paths
|
|
21
|
+
@gem_controller_paths ||= discover_gem_paths('lambda/controllers')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Auto-discover lambda/models dirs in all loaded gems
|
|
25
|
+
def gem_model_paths
|
|
26
|
+
@gem_model_paths ||= discover_gem_paths('lambda/models')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# All controller paths: app-defined + gem-discovered
|
|
30
|
+
def all_controller_paths
|
|
31
|
+
controller_paths + gem_controller_paths
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# All gem model paths that exist on disk
|
|
35
|
+
def all_model_paths
|
|
36
|
+
gem_model_paths
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Reset cached paths (useful in tests)
|
|
40
|
+
def reset_gem_paths!
|
|
41
|
+
@gem_controller_paths = nil
|
|
42
|
+
@gem_model_paths = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def discover_gem_paths(subdir)
|
|
48
|
+
Gem::Specification.each.filter_map do |spec|
|
|
49
|
+
path = File.join(spec.gem_dir, subdir)
|
|
50
|
+
path if File.directory?(path)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
3
54
|
end
|
|
55
|
+
|
|
56
|
+
require_relative 'belt_controller/base'
|