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.
- 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/holster.rb +46 -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 +40 -1
- data/lib/belt_controller/base.rb +282 -0
- data.tar.gz.sig +0 -0
- metadata +58 -5
- metadata.gz.sig +4 -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
|
data/lib/belt/holster.rb
ADDED
|
@@ -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
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
|
-
|
|
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'
|