backend-ruby/sinatra-project-starter/SKILL.md
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.
npx skillsauth add achreftlili/deep-dev-skills sinatra-project-starterInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
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.rb
app/
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 App
app/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
end
config/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__))
end
app/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
end
app/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
end
app/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
end
app/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
end
app/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
end
app/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
end
app/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
end
db/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
end
require "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 rubocop
sequel 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.testing
Set up Vitest 2.x with TypeScript for unit and component testing using test/describe/it, vi.fn/vi.mock/vi.spyOn, component testing with Testing Library, coverage (v8/istanbul), workspace config, and snapshot testing.
testing
Set up pytest 8.x with Python for unit and integration testing using fixtures (scope, autouse, parametrize), async tests (pytest-asyncio), mocking (unittest.mock, pytest-mock), coverage (pytest-cov), conftest.py patterns, and markers.
testing
Set up Playwright 1.49+ with TypeScript for E2E testing using page object model, fixtures, test.describe/test blocks, assertions, selectors, network mocking, CI configuration, and trace viewer.
testing
Set up Jest 30+ with TypeScript for unit tests, integration tests, mocking (jest.fn, jest.mock, jest.spyOn), coverage configuration, custom matchers, snapshot testing, and setup/teardown patterns.