Scaffold a production-ready Sinatra 4.x API with Ruby 3.3+, modular application style, Rack middleware, Sequel for database access, Puma web server, and structured project layout.
77
72%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Advisory
Suggest reviewing before use
Optimize this skill with Tessl
npx tessl skill review --optimize ./backend-ruby/sinatra-project-starter/SKILL.mdScaffold a production-ready Sinatra 4.x API with Ruby 3.3+, modular application style, Rack middleware, Sequel for database access, Puma web server, and structured project layout.
mkdir <project-name> && cd <project-name>
bundle init
# Add to Gemfile, then bundle install
bundle add sinatra sinatra-contrib
bundle add puma
bundle add sequel
bundle add pg # PostgreSQL adapter
bundle add rack-contrib
bundle add bcrypt # Password hashing
bundle add jwt # Token auth
bundle add dotenv --group "development, test"
bundle add rspec rack-test factory_bot faker --group "development, test"
bundle install
# Create directory structure
mkdir -p app/{routes,models,middleware,services,serializers}
mkdir -p config db/migrations spec/{routes,models,services,support}
touch config.ru app/app.rb config/database.rbapp/
app.rb # Main Sinatra::Base application class
routes/
base.rb # Shared route helpers
health.rb # Health check routes
users.rb # User CRUD routes
models/
user.rb # Sequel model
middleware/
auth.rb # Rack authentication middleware
json_parser.rb # Parse JSON request bodies
error_handler.rb # Catch exceptions, return JSON
services/
create_user.rb # Service objects
serializers/
user_serializer.rb # JSON serialization
config/
database.rb # Sequel database connection
app_config.rb # Environment-based config
db/
migrations/
001_create_users.rb # Sequel migrations
spec/
routes/
users_spec.rb
models/
user_spec.rb
spec_helper.rb
support/
factory_bot.rb
config.ru # Rack entry point
Gemfile
Rakefile
.env
.env.example # Template for required env vars (commit this)Sinatra::Base subclass) — never classic/top-level style for productionuse or map in config.ru.to_jsondotenv in development, env vars in productionconfig/puma.rb or CLI flagsconfig.rurequire "dotenv/load" if ENV["RACK_ENV"] != "production"
require_relative "config/database"
require_relative "app/app"
run Appapp/app.rbrequire "sinatra/base"
require "sinatra/json"
require "sinatra/namespace"
Dir[File.join(__dir__, "middleware", "*.rb")].each { |f| require f }
Dir[File.join(__dir__, "models", "*.rb")].each { |f| require f }
Dir[File.join(__dir__, "routes", "*.rb")].each { |f| require f }
Dir[File.join(__dir__, "services", "*.rb")].each { |f| require f }
Dir[File.join(__dir__, "serializers", "*.rb")].each { |f| require f }
class App < Sinatra::Base
register Sinatra::Namespace
use Middleware::ErrorHandler
use Middleware::JsonParser
configure do
set :show_exceptions, false
set :raise_errors, false
set :dump_errors, false
end
configure :development do
set :show_exceptions, :after_handler
end
# Mount route modules
namespace "/api" do
register Routes::Health
register Routes::Users
end
# Catch-all for undefined routes
not_found do
json error: "Not found"
end
endconfig/database.rbrequire "sequel"
database_url = ENV.fetch("DATABASE_URL", "postgres://localhost:5432/myapp_#{ENV.fetch('RACK_ENV', 'development')}")
DB = Sequel.connect(database_url, max_connections: 5)
# Enable model plugin globally
Sequel::Model.plugin :json_serializer
Sequel::Model.plugin :timestamps, update_on_create: true
Sequel::Model.plugin :validation_helpers
# Run migrations in development
if ENV.fetch("RACK_ENV", "development") == "development"
Sequel.extension :migration
Sequel::Migrator.run(DB, File.expand_path("../../db/migrations", __FILE__))
endapp/routes/users.rbmodule Routes
module Users
def self.registered(app)
app.namespace "/users" do
# GET /api/users
get "" do
limit = [params.fetch("limit", 20).to_i, 100].min
offset = params.fetch("offset", 0).to_i
users = User.order(Sequel.desc(:created_at))
.limit(limit)
.offset(offset)
.all
json data: users.map { |u| UserSerializer.new(u).as_json },
meta: { limit: limit, offset: offset, total: User.count }
end
# GET /api/users/:id
get "/:id" do
user = User[params[:id]]
halt 404, json(error: "User not found") unless user
json data: UserSerializer.new(user).as_json
end
# POST /api/users
post "" do
# parsed_body is provided by the JsonBodyParser middleware (see app.rb)
result = CreateUser.call(parsed_body)
if result.success?
status 201
json data: UserSerializer.new(result.user).as_json
else
status 422
json error: result.errors
end
end
# PUT /api/users/:id
put "/:id" do
user = User[params[:id]]
halt 404, json(error: "User not found") unless user
user.update(parsed_body.slice("email", "name"))
json data: UserSerializer.new(user.refresh).as_json
end
# DELETE /api/users/:id
delete "/:id" do
user = User[params[:id]]
halt 404, json(error: "User not found") unless user
user.destroy
status 204
end
end
end
end
endapp/models/user.rbclass User < Sequel::Model
plugin :secure_password # requires bcrypt
one_to_many :posts
def validate
super
validates_presence [:email, :name]
validates_unique :email
validates_format /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i, :email,
message: "is not a valid email"
end
endapp/middleware/json_parser.rbmodule Middleware
class JsonParser
def initialize(app)
@app = app
end
def call(env)
if env["CONTENT_TYPE"]&.include?("application/json")
body = env["rack.input"].read
env["rack.input"].rewind
unless body.empty?
parsed = JSON.parse(body)
env["parsed_body"] = parsed
end
end
@app.call(env)
rescue JSON::ParserError
[400, { "Content-Type" => "application/json" }, ['{"error":"Invalid JSON"}']]
end
end
endapp/middleware/error_handler.rbmodule Middleware
class ErrorHandler
def initialize(app)
@app = app
end
def call(env)
@app.call(env)
rescue Sequel::ValidationFailed => e
[422, { "Content-Type" => "application/json" },
[{ error: e.errors.full_messages }.to_json]]
rescue Sequel::DatabaseError => e
warn "Database error: #{e.message}"
[500, { "Content-Type" => "application/json" },
['{"error":"Internal server error"}']]
rescue StandardError => e
warn "Unhandled error: #{e.class} — #{e.message}"
[500, { "Content-Type" => "application/json" },
['{"error":"Internal server error"}']]
end
end
endapp/middleware/auth.rbmodule Middleware
class Auth
SKIP_PATHS = %w[/api/health /api/users].freeze
SKIP_METHODS = %w[POST].freeze
def initialize(app)
@app = app
end
def call(env)
# Skip auth for certain routes
if skip_auth?(env)
return @app.call(env)
end
auth_header = env["HTTP_AUTHORIZATION"]
unless auth_header&.start_with?("Bearer ")
return [401, { "Content-Type" => "application/json" },
['{"error":"Missing Authorization header"}']]
end
token = auth_header.sub("Bearer ", "")
payload = decode_token(token)
unless payload
return [401, { "Content-Type" => "application/json" },
['{"error":"Invalid token"}']]
end
env["current_user_id"] = payload["user_id"]
@app.call(env)
end
private
def skip_auth?(env)
# Customize skip logic per route
env["PATH_INFO"] == "/api/health"
end
def decode_token(token)
secret = ENV.fetch("JWT_SECRET")
JWT.decode(token, secret, true, algorithm: "HS256").first
rescue JWT::DecodeError
nil
end
end
endapp/services/create_user.rbclass CreateUser
attr_reader :user, :errors
def self.call(params)
new(params).call
end
def initialize(params)
@params = params
@errors = []
end
def call
@user = User.new(
email: @params["email"],
name: @params["name"],
password: @params["password"]
)
@user.save
self
rescue Sequel::ValidationFailed => e
@errors = e.errors.full_messages
self
end
def success?
@errors.empty? && @user&.id
end
endapp/serializers/user_serializer.rbclass UserSerializer
def initialize(user)
@user = user
end
def as_json
{
id: @user.id,
email: @user.email,
name: @user.name,
created_at: @user.created_at&.iso8601,
updated_at: @user.updated_at&.iso8601
}
end
enddb/migrations/001_create_users.rbSequel.migration do
change do
create_table(:users) do
primary_key :id
String :email, null: false, unique: true
String :name, null: false
String :password_digest, null: false
DateTime :created_at
DateTime :updated_at
end
end
endrequire "dotenv/load" if ENV["RACK_ENV"] != "production"
require "sequel"
require_relative "config/database"
namespace :db do
desc "Run migrations"
task :migrate do
Sequel.extension :migration
Sequel::Migrator.run(DB, "db/migrations")
puts "Migrations complete."
end
desc "Rollback last migration"
task :rollback do
Sequel.extension :migration
Sequel::Migrator.run(DB, "db/migrations", target: 0)
puts "Rollback complete."
end
desc "Create database"
task :create do
db_name = URI.parse(ENV.fetch("DATABASE_URL")).path.sub("/", "")
system("createdb #{db_name}")
end
end# Add to Sinatra::Base or a helpers block
helpers do
def parsed_body
env["parsed_body"] || {}
end
def current_user_id
env["current_user_id"]
end
def current_user
@current_user ||= User[current_user_id] if current_user_id
end
end.env.example to .env and fill in DATABASE_URL and JWT_SECRETbundle install to install all gem dependenciesbundle exec rake db:createbundle exec rake db:migratebundle exec rerun -- rackup config.ru -p 4567curl http://localhost:4567/api/health# Development server (with auto-reload)
bundle exec rerun -- rackup config.ru -p 4567
# Production server
bundle exec puma -C config/puma.rb
# Run with specific environment
RACK_ENV=production bundle exec puma
# Database
bundle exec rake db:create
bundle exec rake db:migrate
bundle exec rake db:rollback
# Tests
bundle exec rspec
bundle exec rspec spec/routes/users_spec.rb
# Console
bundle exec irb -r ./config/database -r ./app/app
# Lint
bundle exec rubocopsequel CLI or Rake tasks for migrations.sinatra-activerecord gem with rake db:migrate. Works identically to Rails migrations.bcrypt for password hashing in the User model (plugin :secure_password).rack-cors gem. Add use Rack::Cors in config.ru with appropriate origin rules.Sidekiq (same as Rails) or lighter alternatives like Sucker Punch (in-process) or Que (PostgreSQL-based).rack-test provides get, post, put, delete methods for integration testing. Pair with RSpec and Factory Bot.Rack::Multipart (built-in). Access via params[:file][:tempfile] and params[:file][:filename].ruby:3.3-slim base. COPY Gemfile* ./ && bundle install --without development test in build stage. Run with CMD ["bundle", "exec", "puma"].stream block for SSE or large file downloads.181fcbc
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.