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.
Install with Tessl CLI
npx tessl i github:achreftlili/deep-dev-skills --skill rails-project-starter71
Does it follow best practices?
If you maintain this skill, you can automatically optimize it using the tessl CLI to improve its score:
npx tessl skill review --optimize ./path/to/skillValidation for skill structure
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.
# 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.