Scaffold a production-ready Rails 8.x API with Ruby 3.3+, ActiveRecord, Devise authentication, Pundit authorization, RSpec testing, service objects, and Sidekiq background jobs.
56
63%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No known issues
Fix and improve this skill with Tessl
tessl review fix ./backend-ruby/rails-project-starter/SKILL.mdScaffold a production-ready Rails 8.x API with Ruby 3.3+, ActiveRecord, Devise authentication, Pundit authorization, RSpec testing, service objects, and Sidekiq background jobs.
# API-only application
rails new <project-name> --api --database=postgresql --skip-test
cd <project-name>
# Add essential gems to Gemfile, then bundle
bundle add devise devise-jwt pundit
bundle add sidekiq
bundle add rspec-rails factory_bot_rails faker --group "development, test"
bundle add shoulda-matchers --group test
# Setup
rails db:create
rails generate rspec:install
rails generate devise:install
rails generate devise User
rails generate pundit:install
rails db:migraterails new <project-name> --database=postgresql --skip-test --css=tailwindapp/
controllers/
application_controller.rb # Base controller — auth, error handling
api/
v1/
users_controller.rb # Versioned API controllers
posts_controller.rb
models/
application_record.rb
user.rb # Devise model, associations, validations
post.rb
policies/
application_policy.rb # Pundit base policy
post_policy.rb
services/
application_service.rb # Base service object
users/
create_user.rb # Service objects per domain action
update_user.rb
serializers/ # Or app/views/api/v1/ with jbuilder
user_serializer.rb
jobs/
application_job.rb
send_welcome_email_job.rb # Sidekiq background job
config/
routes.rb
database.yml
initializers/
devise.rb
sidekiq.rb
cors.rb # rack-cors configuration
db/
migrate/
schema.rb
seeds.rb
spec/
models/
requests/ # Integration tests (preferred over controller specs)
services/
factories/
rails_helper.rb
spec_helper.rb
.env # Environment variables (do not commit)
.env.example # Template for required env vars (commit this)/api/v1/resourceresource_params method{ data: ..., meta: ... } or { error: ... }app/controllers/api/v1/base_controller.rbmodule Api
module V1
class BaseController < ApplicationController
include Pundit::Authorization
before_action :authenticate_user!
rescue_from ActiveRecord::RecordNotFound do |e|
render json: { error: e.message }, status: :not_found
end
rescue_from ActiveRecord::RecordInvalid do |e|
render json: { error: e.record.errors.full_messages }, status: :unprocessable_entity
end
rescue_from Pundit::NotAuthorizedError do
render json: { error: "Not authorized" }, status: :forbidden
end
private
def paginate(scope)
page = (params[:page] || 1).to_i
per_page = [(params[:per_page] || 20).to_i, 100].min
offset = (page - 1) * per_page
{
data: scope.limit(per_page).offset(offset),
meta: { page: page, per_page: per_page, total: scope.count }
}
end
end
end
endapp/controllers/api/v1/users_controller.rbmodule Api
module V1
class UsersController < BaseController
skip_before_action :authenticate_user!, only: [:create]
def index
users = policy_scope(User).order(created_at: :desc)
result = paginate(users)
render json: {
data: result[:data].map { |u| UserSerializer.new(u).as_json },
meta: result[:meta]
}
end
def show
user = User.find(params[:id])
authorize user
render json: { data: UserSerializer.new(user).as_json }
end
def create
result = Users::CreateUser.call(user_params)
if result.success?
render json: { data: UserSerializer.new(result.user).as_json },
status: :created
else
render json: { error: result.errors }, status: :unprocessable_entity
end
end
def update
user = User.find(params[:id])
authorize user
user.update!(user_params)
render json: { data: UserSerializer.new(user).as_json }
end
def destroy
user = User.find(params[:id])
authorize user
user.destroy!
head :no_content
end
private
def user_params
params.require(:user).permit(:email, :name, :password, :password_confirmation)
end
end
end
endapp/services/users/create_user.rbmodule Users
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(@params)
ActiveRecord::Base.transaction do
@user.save!
SendWelcomeEmailJob.perform_later(@user.id)
end
self
rescue ActiveRecord::RecordInvalid => e
@errors = e.record.errors.full_messages
self
end
def success?
@errors.empty?
end
end
endapp/models/user.rbclass User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
:jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
has_many :posts, dependent: :destroy
validates :name, presence: true, length: { maximum: 100 }
validates :email, presence: true, uniqueness: true
scope :active, -> { where(active: true) }
scope :recent, -> { order(created_at: :desc) }
endapp/policies/post_policy.rbclass PostPolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
def create?
user.present?
end
def update?
owner?
end
def destroy?
owner? || user.admin?
end
class Scope < Scope
def resolve
if user&.admin?
scope.all
else
scope.where(published: true)
end
end
end
private
def owner?
record.user_id == 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
endapp/jobs/send_welcome_email_job.rbclass SendWelcomeEmailJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: :polynomially_longer, attempts: 3
def perform(user_id)
user = User.find(user_id)
UserMailer.welcome(user).deliver_now
end
endconfig/routes.rbRails.application.routes.draw do
devise_for :users, controllers: {
sessions: "api/v1/sessions",
registrations: "api/v1/registrations"
}
namespace :api do
namespace :v1 do
resources :users, only: [:index, :show, :create, :update, :destroy]
resources :posts
end
end
# Sidekiq Web UI (admin only in production)
require "sidekiq/web"
mount Sidekiq::Web => "/sidekiq"
get "up" => "rails/health#show", as: :rails_health_check
endconfig/initializers/sidekiq.rbSidekiq.configure_server do |config|
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
end
Sidekiq.configure_client do |config|
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
endconfig/initializers/cors.rbRails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins ENV.fetch("ALLOWED_ORIGINS", "*")
resource "*",
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
expose: ["Authorization"]
end
enddb/migrate/YYYYMMDD_create_users.rbclass CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users, id: :uuid do |t|
t.string :email, null: false
t.string :name, null: false
t.string :encrypted_password, null: false, default: ""
t.boolean :admin, default: false
t.boolean :active, default: true
# Devise fields
t.string :reset_password_token
t.datetime :reset_password_sent_at
t.datetime :remember_created_at
t.timestamps
end
add_index :users, :email, unique: true
add_index :users, :reset_password_token, unique: true
end
endspec/requests/api/v1/users_spec.rbrequire "rails_helper"
RSpec.describe "Api::V1::Users", type: :request do
let(:user) { create(:user) }
let(:headers) { auth_headers(user) }
describe "GET /api/v1/users" do
before { create_list(:user, 3) }
it "returns paginated users" do
get "/api/v1/users", headers: headers
expect(response).to have_http_status(:ok)
body = JSON.parse(response.body)
expect(body["data"].length).to eq(4) # 3 + the auth user
expect(body["meta"]["total"]).to eq(4)
end
end
describe "POST /api/v1/users" do
let(:valid_params) do
{ user: { email: "new@example.com", name: "New User", password: "password123" } }
end
it "creates a user" do
expect {
post "/api/v1/users", params: valid_params, as: :json
}.to change(User, :count).by(1)
expect(response).to have_http_status(:created)
end
end
endspec/factories/users.rbFactoryBot.define do
factory :user do
email { Faker::Internet.unique.email }
name { Faker::Name.name }
password { "password123" }
password_confirmation { "password123" }
trait :admin do
admin { true }
end
end
end.env.example to .env and fill in DATABASE_URL, REDIS_URL, and ALLOWED_ORIGINSbundle install to install all gem dependenciesbin/rails db:create db:migratebin/rails db:seedbin/rails servercurl http://localhost:3000/up# Development server
bin/rails server
# Console
bin/rails console
# Database
bin/rails db:create
bin/rails db:migrate
bin/rails db:seed
bin/rails db:rollback
# Generate
bin/rails generate model Post title:string body:text user:references
bin/rails generate migration AddPublishedToPosts published:boolean
# Tests
bundle exec rspec
bundle exec rspec spec/requests/
bundle exec rspec spec/models/user_spec.rb:15 # specific line
# Sidekiq
bundle exec sidekiq
# Routes
bin/rails routes
# Lint
bundle exec rubocop
bundle exec rubocop -a # auto-fixdevise-jwt adds stateless JWT auth for APIs — tokens in Authorization header.authorize record in controllers and policy_scope(Model) for scoped queries.perform_later (Active Job) or perform_async (Sidekiq native). Use perform_later for framework portability.Rails.cache.fetch with expiry. Fragment caching in views with cache helper.enable_extension "pgcrypto" in migration). Use config/database.yml with DATABASE_URL env var in production.DatabaseCleaner or transactional fixtures.ruby:3.3-slim base image. Multi-stage: install gems in build stage, copy to runtime stage. Precompile assets if full-stack.kamal setup to deploy to a VPS.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.