Belt
A Rails-inspired framework for building serverless Ruby applications on AWS Lambda.
Belt bundles everything you need to go from zero to production:
- BeltController — callbacks, strong parameters, error handling, CORS
- Belt::LambdaHandler — Lambda entry point with observability, CORS preflight, error wrapping
- Belt::ActionRouter — request routing to controllers from route manifests
- ActiveItem — DynamoDB ORM (queries, validations, associations, transactions)
- Lambda Loadout — structured logging, CloudWatch metrics (EMF), error alerting
- S3arch — full-text search via SQLite FTS5, stored on S3, queried from Lambda
Installation
Add to your Gemfile:
gem "belt"
Then:
bundle install
Quick Start
1. Project structure
my-app/
├── infrastructure/
│ ├── routes.tf.rb # Belt provider route definitions
│ └── schema.tf.rb # DynamoDB table schemas
├── lambda/
│ ├── controllers/
│ │ └── posts_controller.rb
│ ├── models/
│ │ └── post.rb
│ ├── lib/
│ │ └── routes.rb
│ └── api.rb # Lambda entry point
├── Gemfile
└── Gemfile.lock
2. Define a model
require "activeitem"
class Post < ActiveItem::Base
self.primary_key = :id
attr_accessor :id, :user_id, :title, :body, :created_at
validates :title, presence: true
before_create { self.id ||= SecureRandom.uuid }
end
3. Write a controller
require "belt"
class PostsController < BeltController::Base
before_action :authenticate!
def index
posts = Post.where(user_id: current_user_id, index: "UserIndex")
success_response(posts.map(&:attributes))
end
def show
post = Post.find(params["id"])
success_response(post.attributes)
end
def create
attrs = params.require(:post).permit(:title, :body).to_h
post = Post.create!(attrs.merge(user_id: current_user_id))
success_response(post.attributes, 201)
end
end
4. Lambda entry point
Use Belt::LambdaHandler to get automatic observability, CORS preflight handling, and error wrapping:
require "belt"
include Belt::LambdaHandler
ROUTER = Belt::ActionRouter.new(routes: Routes::API, namespace: "api")
def execute(path:, body:, event:)
ROUTER.route(event: event, body: body)
end
That's it. lambda_handler is automatically your Lambda function handler. It:
- Initializes structured logging and CloudWatch metrics
- Handles OPTIONS preflight requests
- Parses JSON request bodies
- Catches unhandled errors and returns proper CORS-enabled error responses
- Calls your
executemethod for routing
5. Configure the Belt Terraform provider
The Belt Terraform provider (formerly Dispatcher) handles Lambda packaging, API Gateway routing, and IAM permissions.
Add the provider to your Terraform config:
terraform {
required_providers {
belt = {
source = "stowzilla/belt"
}
}
}
Define routes in infrastructure/routes.tf.rb:
TerraDispatch.routes.draw do
namespace :api do
resources :posts, only: [:index, :show, :create]
end
end
Define tables in infrastructure/schema.tf.rb:
TerraDispatch.schema.define do
model :post do
partition_key :id, :string
global_secondary_index :UserIndex, partition_key: :user_id
end
end
Then deploy:
terraform init
terraform apply
The provider will:
- Package your Ruby code into Lambda functions
- Create API Gateway routes matching your DSL
- Generate IAM policies for DynamoDB table access
- Set up CloudWatch log groups
BeltController Features
Callbacks
class AdminController < BeltController::Base
before_action :authenticate!
before_action :require_admin!, except: [:health]
skip_before_action :authenticate!, only: [:health]
end
Strong Parameters
params.require(:user).permit(:name, :email, address: [:street, :city])
Error Handling
class ApiController < BeltController::Base
rescue_from MyCustomError, with: :handle_custom
private
def handle_custom(exception, _context = {})
error_response(exception., 422)
end
end
Response Helpers
success_response({ id: "123", name: "Example" }) # 200 JSON with CORS
success_response({ id: "123" }, 201) # 201 Created
error_response("Not found", 404) # 404 JSON error
html_response("<h1>Hello</h1>") # 200 HTML with CORS
Holsters (Belt's Engines)
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.
Creating a Holster
In your gem, subclass Belt::Holster:
# lib/s3arch/holster.rb
module S3arch
class Holster < Belt::Holster
end
end
That's it. Belt discovers all Holster subclasses at boot. By convention, it expects:
your-gem/
├── infrastructure/
│ ├── routes.tf.rb # Holster's route definitions
│ └── schema.tf.rb # Holster's DynamoDB tables
└── lambda/
├── controllers/ # Holster's controllers
└── models/ # Holster's models
No configuration needed if you follow the convention. Belt resolves paths relative to your gem's root (two directories up from the holster file).
Customizing Paths
If your gem uses a different layout, override any path:
module MyGem
class Holster < Belt::Holster
self.gem_root = File.("..", __dir__)
self.controllers_path = File.join(gem_root, "app", "controllers")
end
end
How Belt Uses Holsters
- Controllers:
Belt::ActionRoutersearches holster controller paths automatically - Routes:
Belt.all_routes_pathscollects all holsterroutes.tf.rbfiles for the Terraform provider - Schema:
Belt.all_schema_pathscollects all holsterschema.tf.rbfiles for the Terraform provider - Models:
Belt.all_models_pathscollects all holster model directories
Controller Discovery
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.
Belt::Observability
Belt provides global Belt::Observability::Logger and Belt::Observability::Metrics facades that are set automatically by Belt::LambdaHandler. Access them from anywhere:
Belt::Observability::Logger.info("Something happened", user_id: "123")
Belt::Observability::Metrics.track_event("OrderCreated", model: "Order")
Environment Variables
| Variable | Purpose |
|---|---|
ENVIRONMENT |
Controls verbose error responses (dev*, local, test) |
BELT_METRICS_NAMESPACE |
CloudWatch metrics namespace (default: Belt) |
ACTION |
Service name for logging (falls back to function name) |
ERROR_NOTIFICATION_TOPIC_ARN |
SNS topic for error alerts |
CORS_ALLOWED_ORIGINS |
Comma-separated origins (overrides domain vars) |
CUSTOMER_APP_DOMAIN |
Primary app domain for CORS |
OPS_APP_DOMAIN |
Internal tools domain for CORS |
License
MIT