CtrlK
BlogDocsLog inGet started
Tessl Logo

sinatra-project-starter

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

Quality

72%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Optimize this skill with Tessl

npx tessl skill review --optimize ./backend-ruby/sinatra-project-starter/SKILL.md
SKILL.md
Quality
Evals
Security

Sinatra Project Starter

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.

Prerequisites

  • Ruby >= 3.3
  • Bundler >= 2.5
  • PostgreSQL (or SQLite for development)

Scaffold Command

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

Project Structure

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)

Key Conventions

  • Use modular style (Sinatra::Base subclass) — never classic/top-level style for production
  • One route file per resource, mounted via use or map in config.ru
  • JSON-only API: parse JSON bodies in middleware, respond with .to_json
  • Sequel over ActiveRecord for Sinatra — lighter, no Rails dependency, composable datasets
  • Service objects for business logic beyond simple CRUD
  • Rack middleware for cross-cutting concerns (auth, error handling, CORS)
  • Environment config via dotenv in development, env vars in production
  • Puma as the production web server — configured via config/puma.rb or CLI flags

Essential Patterns

Rack Entry Point — config.ru

require "dotenv/load" if ENV["RACK_ENV"] != "production"

require_relative "config/database"
require_relative "app/app"

run App

Main Application — app/app.rb

require "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

Database Connection — config/database.rb

require "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

Route Module — app/routes/users.rb

module 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

Sequel Model — app/models/user.rb

class 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

JSON Body Parser Middleware — app/middleware/json_parser.rb

module 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

Error Handler Middleware — app/middleware/error_handler.rb

module 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

Auth Middleware — app/middleware/auth.rb

module 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

Service Object — app/services/create_user.rb

class 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

Serializer — app/serializers/user_serializer.rb

class 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

Migration — db/migrations/001_create_users.rb

Sequel.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

Rakefile

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

Helper for Parsed Body

# 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

First Steps After Scaffold

  1. Copy .env.example to .env and fill in DATABASE_URL and JWT_SECRET
  2. Run bundle install to install all gem dependencies
  3. Create the database: bundle exec rake db:create
  4. Run migrations: bundle exec rake db:migrate
  5. Start the dev server: bundle exec rerun -- rackup config.ru -p 4567
  6. Verify the health endpoint: curl http://localhost:4567/api/health

Common Commands

# 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

Integration Notes

  • Database: Sequel is recommended for Sinatra — lighter than ActiveRecord, composable datasets, no Rails dependency. Use sequel CLI or Rake tasks for migrations.
  • ActiveRecord Alternative: If you prefer ActiveRecord, use sinatra-activerecord gem with rake db:migrate. Works identically to Rails migrations.
  • Auth: JWT-based auth via Rack middleware. Use bcrypt for password hashing in the User model (plugin :secure_password).
  • CORS: Use rack-cors gem. Add use Rack::Cors in config.ru with appropriate origin rules.
  • Background Jobs: Use Sidekiq (same as Rails) or lighter alternatives like Sucker Punch (in-process) or Que (PostgreSQL-based).
  • Testing: rack-test provides get, post, put, delete methods for integration testing. Pair with RSpec and Factory Bot.
  • File Uploads: Use Rack::Multipart (built-in). Access via params[:file][:tempfile] and params[:file][:filename].
  • Docker: Use ruby:3.3-slim base. COPY Gemfile* ./ && bundle install --without development test in build stage. Run with CMD ["bundle", "exec", "puma"].
  • Streaming: Sinatra supports streaming responses via stream block for SSE or large file downloads.
Repository
achreftlili/deep-dev-skills
Last updated
Created

Is this your skill?

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.