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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9445afac3a832595238e015620913022c6cdfa6fb8f45e4d37b59f7215a34f25
|
|
4
|
+
data.tar.gz: 88b09d9faf70b253c2ac50429a3e4c54bd2cc3ebd4c9e59f2e7887e833d8d543
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
139
|
+
Define tables in `infrastructure/schema.tf.rb`:
|
|
12
140
|
|
|
13
141
|
```ruby
|
|
14
|
-
|
|
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
|
data/certs/stowzilla.pem
ADDED
|
@@ -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
|