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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fde6517c1c9ccbd2cac44b59f692934fe4f2faf614d64ebafad7ee30f6e562a1
4
- data.tar.gz: f368621167b1266f21cb8205a3eb31dd92fefb6856876696130ff023f5c953ad
3
+ metadata.gz: 9445afac3a832595238e015620913022c6cdfa6fb8f45e4d37b59f7215a34f25
4
+ data.tar.gz: 88b09d9faf70b253c2ac50429a3e4c54bd2cc3ebd4c9e59f2e7887e833d8d543
5
5
  SHA512:
6
- metadata.gz: e2128c5df77f839ffb2997d4f634b149a28140187f57fec829b4109e687e958bf1513276e7963a679577c069b4404208178adb62142ebd6f791ccafc56cebc0e
7
- data.tar.gz: cf0b2faf98684b8ad6d53eb6505a3543c62a09c143f2c8a07de8eb2601a076b007134609cdc0f9fa3f4536aa84df35702cc3c595018e2e348c14e55b519c0bc6
6
+ metadata.gz: 186ffe088baefe51b1535a0be897053d61d5ae717d06720afd6f1862decd7b0074f85b47de5ec27d6e8e3e7104d35ed390b64e8e20a4a270efd1ed14c00e73ea
7
+ data.tar.gz: c43ac53938a4642699bda126b97b703b72bd5f98470333e6cb889ac9fef21528bc5da791a9bb20bd50f7908442e2bb93c967cdb75fb2ee725e7f5bd3c8de9a58
checksums.yaml.gz.sig ADDED
Binary file
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 0.0.5
4
+
5
+ - Eliminated regex from `Belt::ActionRouter` — uses pure segment-by-segment string comparison (resolves CodeQL alerts)
6
+
7
+ ## 0.0.4
8
+
9
+ - Added `Belt::Holster` — Belt's equivalent of Rails Engines. Gems subclass `Belt::Holster` to provide controllers, models, routes, and schema via convention.
10
+ - Added `Belt.all_controller_paths`, `Belt.all_models_paths`, `Belt.all_routes_paths`, `Belt.all_schema_paths` aggregation methods
11
+ - `Belt::ActionRouter` now searches holster controller paths automatically
12
+
13
+ ## 0.0.3
14
+
15
+ - Added `Belt::LambdaHandler` — module for Lambda entry points with observability, CORS preflight, JSON parsing, and error wrapping
16
+ - Added `Belt::ActionRouter` — request routing to controllers based on route manifests
17
+ - Added `Belt::Observability` — global Logger and Metrics facades for access from anywhere in the codebase
18
+
19
+ ## 0.0.2
20
+
21
+ - Renamed base class to `BeltController::Base` (mirrors `ActionController::Base`)
22
+ - Added `BeltController::Base` with callbacks, strong params, CORS, error handling
23
+ - Added `ActionController::Parameters` (strong params without Rails)
24
+ - Added response helpers and CORS origin resolution
25
+ - Bundled dependencies: activeitem, lambda_loadout, s3arch
data/README.md CHANGED
@@ -1,19 +1,281 @@
1
1
  # Belt
2
2
 
3
- A utility toolkit for Ruby applications.
3
+ A Rails-inspired framework for building serverless Ruby applications on AWS Lambda.
4
+
5
+ Belt bundles everything you need to go from zero to production:
6
+
7
+ - **BeltController** — callbacks, strong parameters, error handling, CORS
8
+ - **Belt::LambdaHandler** — Lambda entry point with observability, CORS preflight, error wrapping
9
+ - **Belt::ActionRouter** — request routing to controllers from route manifests
10
+ - **ActiveItem** — DynamoDB ORM (queries, validations, associations, transactions)
11
+ - **Lambda Loadout** — structured logging, CloudWatch metrics (EMF), error alerting
12
+ - **S3arch** — full-text search via SQLite FTS5, stored on S3, queried from Lambda
4
13
 
5
14
  ## Installation
6
15
 
16
+ Add to your Gemfile:
17
+
18
+ ```ruby
19
+ gem "belt"
20
+ ```
21
+
22
+ Then:
23
+
24
+ ```bash
25
+ bundle install
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ### 1. Project structure
31
+
32
+ ```
33
+ my-app/
34
+ ├── infrastructure/
35
+ │ ├── routes.tf.rb # Belt provider route definitions
36
+ │ └── schema.tf.rb # DynamoDB table schemas
37
+ ├── lambda/
38
+ │ ├── controllers/
39
+ │ │ └── posts_controller.rb
40
+ │ ├── models/
41
+ │ │ └── post.rb
42
+ │ ├── lib/
43
+ │ │ └── routes.rb
44
+ │ └── api.rb # Lambda entry point
45
+ ├── Gemfile
46
+ └── Gemfile.lock
47
+ ```
48
+
49
+ ### 2. Define a model
50
+
51
+ ```ruby
52
+ require "activeitem"
53
+
54
+ class Post < ActiveItem::Base
55
+ self.primary_key = :id
56
+
57
+ attr_accessor :id, :user_id, :title, :body, :created_at
58
+
59
+ validates :title, presence: true
60
+ before_create { self.id ||= SecureRandom.uuid }
61
+ end
62
+ ```
63
+
64
+ ### 3. Write a controller
65
+
66
+ ```ruby
67
+ require "belt"
68
+
69
+ class PostsController < BeltController::Base
70
+ before_action :authenticate!
71
+
72
+ def index
73
+ posts = Post.where(user_id: current_user_id, index: "UserIndex")
74
+ success_response(posts.map(&:attributes))
75
+ end
76
+
77
+ def show
78
+ post = Post.find(params["id"])
79
+ success_response(post.attributes)
80
+ end
81
+
82
+ def create
83
+ attrs = params.require(:post).permit(:title, :body).to_h
84
+ post = Post.create!(attrs.merge(user_id: current_user_id))
85
+ success_response(post.attributes, 201)
86
+ end
87
+ end
88
+ ```
89
+
90
+ ### 4. Lambda entry point
91
+
92
+ Use `Belt::LambdaHandler` to get automatic observability, CORS preflight handling, and error wrapping:
93
+
94
+ ```ruby
95
+ require "belt"
96
+
97
+ include Belt::LambdaHandler
98
+
99
+ ROUTER = Belt::ActionRouter.new(routes: Routes::API, namespace: "api")
100
+
101
+ def execute(path:, body:, event:)
102
+ ROUTER.route(event: event, body: body)
103
+ end
104
+ ```
105
+
106
+ That's it. `lambda_handler` is automatically your Lambda function handler. It:
107
+ - Initializes structured logging and CloudWatch metrics
108
+ - Handles OPTIONS preflight requests
109
+ - Parses JSON request bodies
110
+ - Catches unhandled errors and returns proper CORS-enabled error responses
111
+ - Calls your `execute` method for routing
112
+
113
+ ### 5. Configure the Belt Terraform provider
114
+
115
+ The Belt Terraform provider (formerly Dispatcher) handles Lambda packaging, API Gateway routing, and IAM permissions.
116
+
117
+ Add the provider to your Terraform config:
118
+
119
+ ```hcl
120
+ terraform {
121
+ required_providers {
122
+ belt = {
123
+ source = "stowzilla/belt"
124
+ }
125
+ }
126
+ }
127
+ ```
128
+
129
+ Define routes in `infrastructure/routes.tf.rb`:
130
+
7
131
  ```ruby
8
- gem 'belt'
132
+ TerraDispatch.routes.draw do
133
+ namespace :api do
134
+ resources :posts, only: [:index, :show, :create]
135
+ end
136
+ end
9
137
  ```
10
138
 
11
- ## Usage
139
+ Define tables in `infrastructure/schema.tf.rb`:
12
140
 
13
141
  ```ruby
14
- require 'belt'
142
+ TerraDispatch.schema.define do
143
+ model :post do
144
+ partition_key :id, :string
145
+ global_secondary_index :UserIndex, partition_key: :user_id
146
+ end
147
+ end
148
+ ```
149
+
150
+ Then deploy:
151
+
152
+ ```bash
153
+ terraform init
154
+ terraform apply
15
155
  ```
16
156
 
157
+ The provider will:
158
+ - Package your Ruby code into Lambda functions
159
+ - Create API Gateway routes matching your DSL
160
+ - Generate IAM policies for DynamoDB table access
161
+ - Set up CloudWatch log groups
162
+
163
+ ## BeltController Features
164
+
165
+ ### Callbacks
166
+
167
+ ```ruby
168
+ class AdminController < BeltController::Base
169
+ before_action :authenticate!
170
+ before_action :require_admin!, except: [:health]
171
+ skip_before_action :authenticate!, only: [:health]
172
+ end
173
+ ```
174
+
175
+ ### Strong Parameters
176
+
177
+ ```ruby
178
+ params.require(:user).permit(:name, :email, address: [:street, :city])
179
+ ```
180
+
181
+ ### Error Handling
182
+
183
+ ```ruby
184
+ class ApiController < BeltController::Base
185
+ rescue_from MyCustomError, with: :handle_custom
186
+
187
+ private
188
+
189
+ def handle_custom(exception, _context = {})
190
+ error_response(exception.message, 422)
191
+ end
192
+ end
193
+ ```
194
+
195
+ ### Response Helpers
196
+
197
+ ```ruby
198
+ success_response({ id: "123", name: "Example" }) # 200 JSON with CORS
199
+ success_response({ id: "123" }, 201) # 201 Created
200
+ error_response("Not found", 404) # 404 JSON error
201
+ html_response("<h1>Hello</h1>") # 200 HTML with CORS
202
+ ```
203
+
204
+ ## Holsters (Belt's Engines)
205
+
206
+ Holsters are Belt's equivalent of Rails Engines. A holster lets a gem provide its own controllers, models, routes, and schema — all discovered automatically via convention.
207
+
208
+ ### Creating a Holster
209
+
210
+ In your gem, subclass `Belt::Holster`:
211
+
212
+ ```ruby
213
+ # lib/s3arch/holster.rb
214
+ module S3arch
215
+ class Holster < Belt::Holster
216
+ end
217
+ end
218
+ ```
219
+
220
+ That's it. Belt discovers all `Holster` subclasses at boot. By convention, it expects:
221
+
222
+ ```
223
+ your-gem/
224
+ ├── infrastructure/
225
+ │ ├── routes.tf.rb # Holster's route definitions
226
+ │ └── schema.tf.rb # Holster's DynamoDB tables
227
+ └── lambda/
228
+ ├── controllers/ # Holster's controllers
229
+ └── models/ # Holster's models
230
+ ```
231
+
232
+ No configuration needed if you follow the convention. Belt resolves paths relative to your gem's root (two directories up from the holster file).
233
+
234
+ ### Customizing Paths
235
+
236
+ If your gem uses a different layout, override any path:
237
+
238
+ ```ruby
239
+ module MyGem
240
+ class Holster < Belt::Holster
241
+ self.gem_root = File.expand_path("..", __dir__)
242
+ self.controllers_path = File.join(gem_root, "app", "controllers")
243
+ end
244
+ end
245
+ ```
246
+
247
+ ### How Belt Uses Holsters
248
+
249
+ - **Controllers**: `Belt::ActionRouter` searches holster controller paths automatically
250
+ - **Routes**: `Belt.all_routes_paths` collects all holster `routes.tf.rb` files for the Terraform provider
251
+ - **Schema**: `Belt.all_schema_paths` collects all holster `schema.tf.rb` files for the Terraform provider
252
+ - **Models**: `Belt.all_models_paths` collects all holster model directories
253
+
254
+ ## Controller Discovery
255
+
256
+ Belt discovers controllers from the app's namespace module first, then searches `Belt.all_controller_paths` — which includes both app-defined paths and holster-provided paths. No registration needed.
257
+
258
+ ## Belt::Observability
259
+
260
+ Belt provides global `Belt::Observability::Logger` and `Belt::Observability::Metrics` facades that are set automatically by `Belt::LambdaHandler`. Access them from anywhere:
261
+
262
+ ```ruby
263
+ Belt::Observability::Logger.info("Something happened", user_id: "123")
264
+ Belt::Observability::Metrics.track_event("OrderCreated", model: "Order")
265
+ ```
266
+
267
+ ## Environment Variables
268
+
269
+ | Variable | Purpose |
270
+ |----------|---------|
271
+ | `ENVIRONMENT` | Controls verbose error responses (`dev*`, `local`, `test`) |
272
+ | `BELT_METRICS_NAMESPACE` | CloudWatch metrics namespace (default: `Belt`) |
273
+ | `ACTION` | Service name for logging (falls back to function name) |
274
+ | `ERROR_NOTIFICATION_TOPIC_ARN` | SNS topic for error alerts |
275
+ | `CORS_ALLOWED_ORIGINS` | Comma-separated origins (overrides domain vars) |
276
+ | `CUSTOMER_APP_DOMAIN` | Primary app domain for CORS |
277
+ | `OPS_APP_DOMAIN` | Internal tools domain for CORS |
278
+
17
279
  ## License
18
280
 
19
281
  MIT
@@ -0,0 +1,26 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIEdDCCAtygAwIBAgIBATANBgkqhkiG9w0BAQsFADBAMQ4wDAYDVQQDDAVhZ2Vu
3
+ dDEZMBcGCgmSJomT8ixkARkWCXN0b3d6aWxsYTETMBEGCgmSJomT8ixkARkWA2Nv
4
+ bTAeFw0yNjA2MDgxOTExNTlaFw0yNzA2MDgxOTExNTlaMEAxDjAMBgNVBAMMBWFn
5
+ ZW50MRkwFwYKCZImiZPyLGQBGRYJc3Rvd3ppbGxhMRMwEQYKCZImiZPyLGQBGRYD
6
+ Y29tMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAupBquKI/4WvXOgND
7
+ pXyqH2GllZs1wG4TWWdn/DoMg45UoCwD+AWEuGrIdInBCpPN8vEJNJWPoM/RrU+b
8
+ xRBZT4uUk00bnZRW2SYh5GJSqBoBR+rWc2DGkXyGfdRU2sQvkB0+is6ChgQ61WMM
9
+ 33LE9+loBlVsZ6EVtrc18Uh2OW0mJpe0hN2nmBrxZqqOZigxC4DKRMFHvpRkxSb6
10
+ mD4kit1AcwX9NEWJsXxrPaetL/SB/VbXaEZX93XAvp6USaXvCWt4slkDS2mIvqtn
11
+ 9DtGC43LFC7SDGbnsG9PVenQgVCi8UWFPUAab0PqZSlmi3Qlbhw8qTGPp5Cbv4vz
12
+ qjC2UGPOQigA/7lbbGRhCohMrjOVHMAQwkcgiIqtolUoYlnvPMIy+m3pdvgDv/PH
13
+ bsZGvXQ7i0458xsmp1vaKthZocVAR+GboHbuIiYPUnO45ccXUQ00x6365tTe7mZi
14
+ NvmUYdAGbQmVvFqyxF7IYA6sF74L2Lstu0knSfss557bAe1HAgMBAAGjeTB3MAkG
15
+ A1UdEwQCMAAwCwYDVR0PBAQDAgSwMB0GA1UdDgQWBBSnxTL/lNBCeLqpeVIX6AUY
16
+ kel4zjAeBgNVHREEFzAVgRNhZ2VudEBzdG93emlsbGEuY29tMB4GA1UdEgQXMBWB
17
+ E2FnZW50QHN0b3d6aWxsYS5jb20wDQYJKoZIhvcNAQELBQADggGBACm9Fjit/UCv
18
+ FxlKqeiCTIG94cIx+QrWAOJSx9knKydwUec1u04D/DbfZjTn3C2Bj227QgxeUn+6
19
+ if3e2v7zAk1896hLmGYzML0+nxQPb0vmtdLR7HETUlSKTVabcv1fbwLyjsuGrBvk
20
+ y51vOEzUEZ508a9yepLYqrQu1kOju4d57c9oA5l3H0mMKWz7av9tFj0B+STvuaWk
21
+ HRYDWc5HgOEVTyV+w0uFt2Kw4OCb8C42uSvC5RfYYtw78MSP+5Ru+LXJ7XOtmuN0
22
+ E6GVmofQ17ig9O3rgfFbMendSInrRmvPIGswvM1yivq9NOllFbdck2OJKPx6FCJF
23
+ 7SJIkXQfc9P4B5iASIV1d1FsE0YX+g3jHXPJK/4mGL5bAyBKzpMfQB/mg6vQBzkh
24
+ aOKPwcreFj7TznBl89R5tNS9wZQfPVR98zgPyocddWhK18eQNMSBUnv4eeJ8PPbk
25
+ DovL+G8ajHDZ9fjH/+GVYHEMuiVdLarXrKJpHC1VfGTTUAp4NSEpUQ==
26
+ -----END CERTIFICATE-----
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'helpers/cors_origin'
5
+
6
+ module Belt
7
+ # Routes incoming requests to controllers based on a route manifest.
8
+ #
9
+ # Usage:
10
+ # ROUTER = Belt::ActionRouter.new(routes: MY_ROUTES, namespace: "api")
11
+ # response = ROUTER.route(event: event, body: body)
12
+ #
13
+ class ActionRouter
14
+ class RouteNotFound < StandardError; end
15
+
16
+ def initialize(routes:, namespace:)
17
+ @namespace = namespace.to_s
18
+ @namespace_module_name = "#{@namespace.split('_').map(&:capitalize).join}Controllers"
19
+ @routes = build_route_table(routes)
20
+ end
21
+
22
+ def route(event:, body:)
23
+ method = event['httpMethod']
24
+ full_path = event['path']
25
+ match_path = strip_namespace_prefix(full_path)
26
+
27
+ route_info = find_route(method, match_path)
28
+
29
+ unless route_info
30
+ Belt::Observability::Logger.instance&.warn('Route not found', method: method, path: full_path)
31
+ return error_response('Not found', 404, event)
32
+ end
33
+
34
+ path_params = extract_path_params(route_info[:pattern], match_path)
35
+ event['pathParameters'] = (event['pathParameters'] || {}).merge(path_params)
36
+
37
+ dispatch_to_controller(route_info, event, body)
38
+ end
39
+
40
+ def find_route(method, path)
41
+ path_segments = path.split('/')
42
+ @routes.find { |r| r[:verb] == method && segments_match?(r[:segments], path_segments) }
43
+ end
44
+
45
+ def extract_path_params(pattern, actual_path)
46
+ pattern_segments = pattern.split('/')
47
+ path_segments = actual_path.split('/')
48
+ params = {}
49
+
50
+ pattern_segments.each_with_index do |seg, i|
51
+ params[seg[1..-2]] = path_segments[i] if seg.start_with?('{') && seg.end_with?('}')
52
+ end
53
+
54
+ params
55
+ end
56
+
57
+ private
58
+
59
+ def strip_namespace_prefix(path)
60
+ return '/' if path.nil?
61
+
62
+ prefix = "/#{@namespace}"
63
+ if path.start_with?(prefix)
64
+ stripped = path.sub(prefix, '')
65
+ stripped.empty? ? '/' : stripped
66
+ else
67
+ path
68
+ end
69
+ end
70
+
71
+ def build_route_table(routes)
72
+ routes.map { |r| build_route_entry(r) }
73
+ .sort_by { |r| route_specificity(r[:pattern]) }
74
+ end
75
+
76
+ def route_specificity(pattern)
77
+ segments = pattern.split('/').reject(&:empty?)
78
+ param_count = segments.count { |s| s.start_with?('{') }
79
+ [param_count, -segments.length, pattern]
80
+ end
81
+
82
+ def build_route_entry(route)
83
+ pattern = route[:path] || route['path']
84
+ {
85
+ verb: route[:verb] || route['verb'],
86
+ pattern: pattern,
87
+ segments: pattern.split('/'),
88
+ controller: route[:controller] || route['controller'],
89
+ action: route[:action] || route['action']
90
+ }
91
+ end
92
+
93
+ def segments_match?(pattern_segments, path_segments)
94
+ return false unless pattern_segments.length == path_segments.length
95
+
96
+ pattern_segments.each_with_index do |seg, i|
97
+ next if seg.start_with?('{') && seg.end_with?('}')
98
+ return false unless seg == path_segments[i]
99
+ end
100
+
101
+ true
102
+ end
103
+
104
+ def dispatch_to_controller(route_info, event, body)
105
+ controller_class = resolve_controller(route_info[:controller])
106
+ controller = controller_class.new(event: event, body: body)
107
+
108
+ controller_name = controller_class.name.split('::').last.gsub('Controller', '')
109
+ Belt::Observability::Logger.instance&.info("Processing by #{controller_name}##{route_info[:action]}")
110
+
111
+ controller.dispatch(route_info[:action].to_sym)
112
+ end
113
+
114
+ def resolve_controller(controller_name)
115
+ # Try namespace module first (app's own controllers)
116
+ begin
117
+ namespace_module = Object.const_get(@namespace_module_name)
118
+ return resolve_from_module(namespace_module, controller_name)
119
+ rescue NameError
120
+ # Fall through to controller_paths lookup
121
+ end
122
+
123
+ # Scan controller_paths (gem-provided controllers via convention)
124
+ resolve_from_paths(controller_name)
125
+ end
126
+
127
+ def resolve_from_module(namespace_module, controller_name)
128
+ if controller_name.include?('/')
129
+ parts = controller_name.split('/')
130
+ parent = namespace_module.const_get(parts[0].split('_').map(&:capitalize).join)
131
+ parent.const_get("#{parts[1].split('_').map(&:capitalize).join}Controller")
132
+ else
133
+ namespace_module.const_get("#{controller_name.split('_').map(&:capitalize).join}Controller")
134
+ end
135
+ end
136
+
137
+ def resolve_from_paths(controller_name)
138
+ if controller_name.include?('/')
139
+ end
140
+ file_name = "#{controller_name}_controller.rb"
141
+
142
+ Belt.all_controller_paths.each do |path|
143
+ full_path = File.join(path, file_name)
144
+ next unless File.exist?(full_path)
145
+
146
+ require full_path
147
+ # After requiring, try to find the constant
148
+ class_name = "#{controller_name.split(%r{[_/]}).map(&:capitalize).join}Controller"
149
+ return Object.const_get(class_name) if Object.const_defined?(class_name)
150
+ end
151
+
152
+ raise Belt::ActionNotFound, "Controller not found: #{controller_name}"
153
+ end
154
+
155
+ def error_response(message, status_code, event = nil)
156
+ origin = Belt::Helpers::CorsOrigin.resolve_origin(Belt::Helpers::CorsOrigin.origin_from_event(event))
157
+ headers = {
158
+ 'Access-Control-Allow-Headers' => 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
159
+ 'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
160
+ 'Content-Type' => 'application/json'
161
+ }
162
+ headers['Access-Control-Allow-Origin'] = origin if origin
163
+ { statusCode: status_code, headers: headers, body: JSON.generate(error: message) }
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Helpers
5
+ module CorsOrigin
6
+ def self.resolve_origin(request_origin)
7
+ allowed = allowed_origins
8
+ return nil if allowed.empty?
9
+ return request_origin if request_origin && allowed.include?(request_origin)
10
+
11
+ allowed.first
12
+ end
13
+
14
+ def self.origin_from_event(event)
15
+ return nil unless event.is_a?(Hash)
16
+
17
+ headers = event['headers']
18
+ return nil unless headers.is_a?(Hash)
19
+
20
+ headers['Origin'] || headers['origin']
21
+ end
22
+
23
+ def self.allowed_origins
24
+ @allowed_origins ||= build_allowed_origins
25
+ end
26
+
27
+ def self.reset!
28
+ @allowed_origins = nil
29
+ end
30
+
31
+ private_class_method def self.build_allowed_origins
32
+ explicit = ENV.fetch('CORS_ALLOWED_ORIGINS', nil)
33
+ return explicit.split(',').map(&:strip).reject(&:empty?) if explicit && !explicit.empty?
34
+
35
+ origins = []
36
+ domains = %w[CUSTOMER_APP_DOMAIN OPS_APP_DOMAIN BLOG_APP_DOMAIN]
37
+ domains.each do |var|
38
+ domain = ENV.fetch(var, nil)
39
+ next unless domain && !domain.empty?
40
+
41
+ origins << "https://#{domain}"
42
+ origins << "https://www.#{domain}" if domain.count('.') == 1
43
+ end
44
+
45
+ env = ENV.fetch('ENVIRONMENT', nil)
46
+ unless %w[prod production staging].include?(env)
47
+ origins << 'http://localhost:3000'
48
+ origins << 'http://localhost:3001'
49
+ end
50
+
51
+ origins
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Helpers
5
+ module ErrorLogging
6
+ module_function
7
+
8
+ def log_error(logger, message, error, context = {})
9
+ filtered_backtrace = filter_backtrace(error.backtrace || [])
10
+
11
+ if logger
12
+ logger.error(message,
13
+ error_class: error.class.name,
14
+ error_message: error.message,
15
+ backtrace: filtered_backtrace,
16
+ backtrace_full: error.backtrace&.first(20),
17
+ **context)
18
+ else
19
+ require 'json'
20
+ puts JSON.generate({
21
+ level: 'ERROR',
22
+ message: message,
23
+ error_class: error.class.name,
24
+ error_message: error.message,
25
+ backtrace: filtered_backtrace,
26
+ timestamp: Time.now.utc.iso8601,
27
+ **context
28
+ })
29
+ end
30
+ end
31
+
32
+ def filter_backtrace(backtrace)
33
+ return [] if backtrace.nil? || backtrace.empty?
34
+
35
+ app_patterns = [%r{/var/task/}, %r{lambda/}, %r{controllers/}, %r{models/}, %r{lib/}, %r{helpers/}]
36
+ exclude_patterns = [%r{/var/runtime/}, %r{/opt/ruby/}, %r{/gems/}, /rubygems/, /<internal:/]
37
+
38
+ app_lines = []
39
+ lib_lines = []
40
+
41
+ backtrace.each do |line|
42
+ next if exclude_patterns.any? { |pattern| line.match?(pattern) }
43
+
44
+ if app_patterns.any? { |pattern| line.match?(pattern) }
45
+ app_lines << line
46
+ else
47
+ lib_lines << line
48
+ end
49
+ end
50
+
51
+ (app_lines.first(10) + lib_lines.first(3)).compact
52
+ end
53
+ end
54
+ end
55
+ end