belt 0.0.1 → 0.0.5

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.
@@ -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,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ class Holster
5
+ class << self
6
+ def inherited(subclass)
7
+ super
8
+ Belt.holsters << subclass
9
+ end
10
+
11
+ # Convention: gem root is two levels up from the holster file
12
+ def gem_root
13
+ @gem_root ||= File.expand_path('../..', caller_locations(1, 1).first.path)
14
+ end
15
+
16
+ attr_writer :gem_root, :controllers_path, :models_path, :routes_path, :schema_path
17
+
18
+ # Defaults follow Belt project structure conventions
19
+ def controllers_path
20
+ @controllers_path || File.join(gem_root, 'lambda', 'controllers')
21
+ end
22
+
23
+ def models_path
24
+ @models_path || File.join(gem_root, 'lambda', 'models')
25
+ end
26
+
27
+ def routes_path
28
+ @routes_path || File.join(gem_root, 'infrastructure', 'routes.tf.rb')
29
+ end
30
+
31
+ def schema_path
32
+ @schema_path || File.join(gem_root, 'infrastructure', 'schema.tf.rb')
33
+ end
34
+
35
+ def holster_name
36
+ name&.split('::')&.first&.downcase || 'unknown'
37
+ end
38
+ end
39
+ end
40
+
41
+ @holsters = []
42
+
43
+ class << self
44
+ attr_reader :holsters
45
+ end
46
+ 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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Belt
2
- VERSION = "0.1.0"
4
+ VERSION = '0.0.5'
3
5
  end
data/lib/belt.rb CHANGED
@@ -1,3 +1,42 @@
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
+ require_relative 'belt/holster'
9
+
1
10
  module Belt
2
- VERSION = "0.0.1"
11
+ class AuthenticationError < StandardError; end
12
+ class RecordNotFound < StandardError; end
13
+ class ActionNotFound < StandardError; end
14
+
15
+ @controller_paths = []
16
+
17
+ class << self
18
+ attr_reader :controller_paths
19
+
20
+ # Collects all controller paths: app-defined + holster-provided
21
+ def all_controller_paths
22
+ controller_paths + holsters.select { |h| File.directory?(h.controllers_path) }.map(&:controllers_path)
23
+ end
24
+
25
+ # Collects all model paths from holsters
26
+ def all_models_paths
27
+ holsters.select { |h| File.directory?(h.models_path) }.map(&:models_path)
28
+ end
29
+
30
+ # Collects all routes files from holsters
31
+ def all_routes_paths
32
+ holsters.select { |h| File.exist?(h.routes_path) }.map(&:routes_path)
33
+ end
34
+
35
+ # Collects all schema files from holsters
36
+ def all_schema_paths
37
+ holsters.select { |h| File.exist?(h.schema_path) }.map(&:schema_path)
38
+ end
39
+ end
3
40
  end
41
+
42
+ require_relative 'belt_controller/base'