SimpleCrud
Table of contents
Description
Simple Crud is a gem for Rails that simplifies writing standard CRUD actions, while also adding tools so it's not needed to write tests for them. Its main objective is replacing generally copy-pasted code like generic paginated, authenticated index methods for simple lines such as
simple_crud_for :index
It includes support for index, create, destroy, update and show, and options to specify whether it should apply pagination, authorization and if a particular serializer should be used. Keep in mind, though, that the idea is not to replace writing methods in controllers altogether, but only to replace most standard cases.
Installation
Add the following line to your application's Gemfile. The gem is named wor-simple_crud, but the require path is simple_crud, so tell Bundler where to find it:
gem 'wor-simple_crud', require: 'simple_crud'
And then execute:
$ bundle
Or install it yourself as:
$ gem install wor-simple_crud
simple_crud only depends on Rails itself. Authorization (Pundit by default) and pagination (wor-paginate by default) are opt-in: add whichever libraries you actually use to your own Gemfile too.
gem 'pundit'
gem 'wor-paginate'
See Paginate and Authorize below for the other supported libraries, or to skip either feature (authorize: false / paginate: false) and depend on neither.
Usage
Setup
Application controller
Before SimpleCrud can be used, some boilerplate is needed. Add the following to your ApplicationController (or every controller in case you don't want it included in all controllers)
include Pundit::Authorization
include Wor::Paginate
extend SimpleCrudController
before_action :set_params
def set_params
SimpleCrudController.params = params
end
(Skip include Pundit::Authorization / include Wor::Paginate if you're using a different adapter, or authorize: false / paginate: false everywhere.)
Each controller
In case you need either update or create, create a method with the valid input params using standard rails:
def
params.permit(:first_name, :email, :last_name, :institution, :role)
end
For the actual crud methods, just use the lines you need
simple_crud_for :update
simple_crud_for :show
simple_crud_for :index
simple_crud_for :create
simple_crud_for :destroy
simple_crud_for :new
simple_crud_for :edit
Each method supports different options, as in:
simple_crud_for :index, paginate: false, authorize: false, serializer: CustomSerializer
- Paginate: whether it should paginate or not.
truepaginates via the configured pagination adapter (wor-paginate by default),falsedoesn't paginate - Authorize: whether it should check authorization via the configured authorization adapter (Pundit by default)
- Authenticate: whether it should use Devise to check for a current_user
- Serializer: specify a particular serializer you should use
- Html: renders the action's ERB template instead of JSON (valid for
:index,:show,:new,:edit,:create,:updateand:destroy). Only meaningful in controllers that render templates - Scope: only valid for
:index. AProc/lambdatakingcurrent_user(plus the controller'sparamsif it takes a second argument) that returns the relation to list, overriding the defaultpolicy_scope. The user is resolved viaSimpleCrud::Config.user_method(:current_userby default; set it to e.g.:current_admin) - Finder: only valid for
:show,:update,:destroyand:edit. AProc/lambda(invoked with the controller's params) or aSymbolnaming a class method on the model, used to look up the record instead ofklass.find(params[:id]). - Build: only valid for
:newand:create. AProc/lambdathat builds the record (invoked with the controller asself, socurrent_user,paramsand any instance variables are available), for building nested or owner-scoped records likecurrent_user.projects.build.:createthen assigns the permitted params to the built record before saving - Raise_on_invalid: only valid for
:createand:update. Keeps the strictcreate!/update!semantics (raising on invalid input) instead of returning422with the validation errors - Redirect: only valid for HTML-mode
:create,:updateand:destroy. AProc/lambda(called with the record) or a literal path used as the success redirect target. Defaults to the record (:create/:update) or the model's collection path (:destroy)
You'll need a few things so they work correctly:
Options
Paginate
Pagination defaults to wor-paginate and needs no extra options. Check its docs if you want to customize the output.
Three other pagination libraries have adapters built in too:
# Kaminari (https://github.com/kaminari/kaminari)
require 'simple_crud/pagination/kaminari_adapter'
SimpleCrud.configure { |config| config.pagination_adapter = SimpleCrud::Pagination::KaminariAdapter.new }
# will_paginate (https://github.com/mislav/will_paginate)
require 'simple_crud/pagination/will_paginate_adapter'
SimpleCrud.configure { |config| config.pagination_adapter = SimpleCrud::Pagination::WillPaginateAdapter.new }
# Pagy (https://github.com/ddnexus/pagy) -- requires `include Pagy::Method` in your ApplicationController
require 'simple_crud/pagination/pagy_adapter'
SimpleCrud.configure { |config| config.pagination_adapter = SimpleCrud::Pagination::PagyAdapter.new }
They render a plain JSON array rather than wor-paginate's {page:, count:, total_pages:, ...} envelope. None of the four pagination gems is installed automatically, so add whichever one you pick to your own Gemfile.
Want something else, or nothing at all? Write your own SimpleCrud::Pagination::Adapter:
class MyPaginationAdapter
include SimpleCrud::Pagination::Adapter
# Called from inside the generated index action when paginate: true.
# Must render the response itself.
def paginate(controller, klass, )
records = klass.some_pagination_method(controller.params[:page])
controller.render({ json: records }.merge())
end
end
SimpleCrud.configure { |config| config.pagination_adapter = MyPaginationAdapter.new }
Authorize
Authorization checks go through Pundit by default. Name the policy after the model plus Policy, e.g. AuthorPolicy, written as a regular Pundit policy:
Note: authorization runs whenever
authorize: true, independently ofauthenticate. With no signed-in user the policy receivesnilas the user, so write policies that tolerate it (e.g.user.nil? ? false : ...). Devise providesauthenticate_user!andcurrent_userfor you; non-Devise apps must define acurrent_user(or pointSimpleCrud::Config.user_methodat their own method) for policies andscope:lambdas to receive the user.
class AuthorPolicy
attr_reader :user, :author
def initialize(user, )
@user = user
@author =
end
def show?
user.present?
end
class Scope
def initialize(user, scope)
@user = user
@scope = scope
end
# Used by :index to scope the listed records to the current user.
def resolve
@scope.where(user: @user)
end
end
end
When authorize: true, the :index action paginates the Pundit policy_scope of the model (falling back to the full relation when no Scope is defined) instead of klass.all, so "only my records" scoping works out of the box. Override the scope per action with the scope: option, a callable that receives the user resolved via SimpleCrud::Config.user_method (nil when there is none, and params too when it takes a second argument):
SimpleCrud.configure { |c| c.user_method = :current_admin } # non-Devise naming conventions
simple_crud_for :index, scope: ->(user) { Model.visible_to(user) }
simple_crud_for :index, scope: ->(user, params) { Model.where(status: params[:status]).visible_to(user) }
Prefer CanCanCan or Action Policy instead? Both have adapters ready to go:
# CanCanCan (https://github.com/CanCanCommunity/cancancan)
require 'simple_crud/authorization/can_can_can_adapter'
SimpleCrud.configure do |config|
config. = SimpleCrud::Authorization::CanCanCanAdapter.new
end
# Action Policy (https://github.com/palkan/action_policy)
require 'simple_crud/authorization/action_policy_adapter'
SimpleCrud.configure do |config|
config. = SimpleCrud::Authorization::ActionPolicyAdapter.new
end
As with pagination, simple_crud ships the adapter code but not the library itself. Add cancancan or action_policy to your own Gemfile, whichever you pick.
Using something else, or skipping authorization entirely? Write your own SimpleCrud::Authorization::Adapter:
class MyAuthorizationAdapter
include SimpleCrud::Authorization::Adapter
# Called from inside a generated CRUD action. Raise (or otherwise halt
# the request) when the current user may not act on +record+.
def (controller, record)
controller.(record)
end
# Called once, when simple_crud_for is invoked, to fail fast if the
# given model has no authorization rules defined at all.
def policy_defined?(model_class)
Kernel.const_defined?("#{model_class}Policy")
end
# Called when rendering :index with authorize: true. Must return the
# relation the current user is allowed to list. The default returns
# the full relation.
def policy_scope(controller, model_class)
model_class.all
end
end
SimpleCrud.configure do |config|
config. = MyAuthorizationAdapter.new
end
Authenticate
SimpleCrud will assume a current_user method. Future versions will support a custom model. Defining a current_user method in ApplicationController should work if you're using a different model, as of now.
Serializer
The name of the serializer, by default, is the name of the model followed by Serializer, as is the standard for ActiveModelSerializers. It's possible to just pass a custom serializer class though. As for the serializer itself, it's a standard serializer, with the gotcha that you need to include :id for the SimpleCrud examples to work.
class AuthorSerializer < ActiveModel::Serializer
attributes :email, :first_name, :last_name, :institution, :role, :id
end
HTML
For server-rendered apps, html: true renders the action's ERB template instead of JSON, so be sure the controller can render templates (e.g. ActionController::Base). Behavior per action:
:indexrendersindex.html.erbwith the paginated records exposed as@records(pagination still applies, or usepaginate: false).:showrendersshow.html.erbwith the record exposed as@record(customfinder:still applies).:newbuilds a new record, authorizes it, and rendersnew.html.erbwith it exposed as@record.:editlooks the record up (honoringfinder:), authorizes it, and rendersedit.html.erbwith it exposed as@record.:createsaves and redirects to the created record on success, or re-rendersnew.html.erb(with@recordand its errors) on failure.:updatesaves and redirects to the record on success, or re-rendersedit.html.erbon failure.:destroydestroys and redirects to the collection (redirect_to Model) on success, or re-rendersshow.html.erbif a callback aborts the destroy.
simple_crud_for :index, html: true
simple_crud_for :show, html: true
simple_crud_for :new, html: true
simple_crud_for :edit, html: true
simple_crud_for :create, html: true
simple_crud_for :update, html: true
simple_crud_for :destroy, html: true
Or pass a block that renders explicitly, overriding the auto-render. The block receives the records for :index, the record for :show/:new, or the record plus a saved flag for :create/:update/:destroy. Passing a block also implies html: true for the shared examples (so a server-rendered block is asserted as HTML); if your block renders JSON instead, pass html: false explicitly:
simple_crud_for :index do |records|
render :index, locals: { models: records }
end
simple_crud_for :create do |record, saved|
saved ? redirect_to(record) : render(:new, locals: { model: record })
end
Build
simple_crud_for :new and simple_crud_for :create build the record with klass.new, which can't express owner-scoped or nested records (current_user.projects.build, @project.tasks.build). Pass a build: lambda; it runs with the controller as self, so current_user, params and any instance variables set by a before_action are available:
simple_crud_for :new, build: -> { current_user.projects.build }
simple_crud_for :create, build: -> { @project.tasks.build }
:create assigns the permitted params to the built record before saving, so the owner/parent association survives. :update keeps finding the record via the finder:.
Edit
simple_crud_for :edit is the find-instead-of-build twin of :new, the last hand-written CRUD action in server-rendered apps. It looks the record up (honoring finder: exactly like :show), authorizes it, and renders edit.html.erb with the record exposed as @record; in JSON mode it returns the record:
simple_crud_for :edit, finder: ->(params) { Project.find_by!(slug: params[:slug]) }
Finder
By default records are looked up by primary key via klass.find(params[:id]). The finder: option on :show, :update and :destroy replaces that with any lookup you want: pretty URLs (resources :posts, param: :slug), tokens, composite keys, or scoping by a parent resource. Pass a Proc/lambda that maps the controller's params to a record, or a Symbol naming a class method on the model that takes the params:
simple_crud_for :show, finder: ->(params) { Model.find_by!(token: params[:token]) }
simple_crud_for :update, finder: :find_by_slug
simple_crud_for :destroy, finder: ->(params) { current_user.models.find(params[:id]) }
When omitted it defaults to klass.find(params[:id]), and not_found is still returned whenever the finder finds no record.
Shared examples
While optional, using the included shared examples saves you from writing the standard test cases for the methods. You can even use them if you didn't use simple_crud_for, as a set of basic tests. To include them, just add require 'simple_crud/rspec' to your rails_helper.rb file and add the lines you need to your *_spec.rb files:
require 'rails_helper'
describe V1::Backoffice::AuthorsController do
include_examples 'simple crud for update'
include_examples 'simple crud for show'
include_examples 'simple crud for create'
include_examples 'simple crud for index'
include_examples 'simple crud for destroy'
end
The create and update examples cover the 422 response with validation errors (skipped when raise_on_invalid: true, or whenever the action uses a render block, since the block decides how the response is rendered), and all base examples adapt to html: true controllers (asserting the rendered template/redirect instead of JSON). Controllers using the extra options can include their dedicated examples too:
include_examples 'simple crud for new' # the :new action
include_examples 'simple crud for edit' # the :edit action
include_examples 'simple crud for index with block' # render block
include_examples 'simple crud for index with scope' # scope: ->(user) { ... }
include_examples 'simple crud for show with block' # render block on :show
include_examples 'simple crud for new with block' # render block on :new
include_examples 'simple crud for create with block' # render block on :create
include_examples 'simple crud for destroy with block' # render block on :destroy
include_examples 'simple crud for show with finder' # finder on :show
include_examples 'simple crud for update with finder' # finder on :update
include_examples 'simple crud for destroy with finder' # finder on :destroy
include_examples 'simple crud for new with build' # build: -> { ... } on :new
include_examples 'simple crud for create with build' # build: -> { ... } on :create
It's not needed to specify paginate: true and such, since the shared examples will use the configuration that was originally passed to simple_crud_for
Adopting the shared examples
The shared examples assume the gem's own stack by default (Devise-JWT authentication, FactoryBot, Pundit, ActiveModel Serializers). Wiring is opt-in: require the file and call SimpleCrud::RSpec.install! in spec/spec_helper.rb (or rails_helper.rb), then configure anything your app differs on:
SimpleCrud::RSpec.configure do |config|
# How the current user (and a secondary "other user") is built.
config.current_user = -> { User.create!(email: 'user@example.com', password: 'secret') }
config.other_user = -> { User.create!(email: 'other@example.com', password: 'secret') }
# How to sign the current user in for a request (no Devise-JWT here).
config.authenticate = -> { request.session[:user_id] = current_user.id }
# How records and create/update params are built (no FactoryBot here).
config.create_record = ->(klass, attributes) { klass.create!(attributes) }
config.create_records = ->(klass, count, attributes) { count.times.map { klass.create!(attributes) } }
config.params_for = ->(klass) { klass.new.attributes.slice('title') }
# The owner association used when building records for the examples
# (model_attributes defaults to `{ owner_association => current_user }`;
# override model_attributes directly for multi-key or non-user-owned models).
config.owner_association = :owner
config.model_attributes = -> { { owner: current_user } }
config.required_attribute = :title
config.required_error = "Title can't be blank"
# Redirect-based apps: unauthenticated requests get a redirect, not a 401.
# And if the rendered template name doesn't match the action, drop the
# render_template assertion.
config.unauthenticated_status = :found
config.assert_html_template = false
# Server-rendered apps usually use nested strong params
# (params.require(:task).permit(:name)); wrap request bodies under the
# model's params key instead of posting flat params.
config.params_key = :task
# Nested resources (/projects/:project_slug/tasks): extra params
# (e.g. the parent slug) added to every request.
config.route_params = -> { { project_slug: project.slug } }
# Re-render the form with a 422 on validation failure (instead of 200).
config.invalid_status = :unprocessable_entity
# How the 'simple crud for index with scope' example builds the records
# that match the controller's scope and the ones that must be excluded.
# Defaults derive from model_attributes (mine) plus an owner_association
# => other_user variant (theirs); override both when your scope keys on
# something other than the owner association.
config.scoped_attributes = -> { { project: project } }
config.other_scoped_attributes = -> { { project: other_project } }
# Pundit policy and serializer class lookup.
config.policy_class = ->(klass) { "#{klass}Policy".constantize }
config.serializer_class = ->(model) { "#{model.class}Serializer".constantize }
# Optional extra assertions on the persisted record in the create/update
# success paths (catches regressions that save into the wrong parent).
config.created_record_check = ->(record) { expect(record.project).to eq(project) }
end
Each setting has a sensible default, so you only override what differs. Callable settings (current_user, authenticate, create_record, create_records, params_for, model_attributes, scoped_attributes, other_scoped_attributes, policy_class, serializer_class) run in the example-group context, so they can call request, create, current_user, etc.; created_record_check receives the persisted record. The examples are controller-agnostic (they issue requests by action name, not hardcoded paths), so they work for namespaced and nested controllers alike. If you keep assert_html_template on (the default), add gem 'rails-controller-testing' for the render_template matcher.
Per-controller overrides via metadata
Any setting can also be overridden per controller (or per example) with simple_crud: metadata instead of globally, useful when the app matches the defaults except for one resource. Metadata wins over global config, and every read happens at example runtime, so there are no around hooks or state to restore:
RSpec.describe TasksController, type: :controller, simple_crud: {
model_attributes: -> { { project: project } },
route_params: -> { { project_slug: project.slug } }
} do
let(:project) { create(:project, owner: current_user) }
include_examples 'simple crud for show'
end
Callable settings resolve in the example context, so their lambdas can close over the spec's lets; that's how records land in the same project the route params point at.
Contributing
- Fork it
- Run
bundle install && bundle exec appraisal generateonce, to install dependencies and generate the per-Rails-version gemfiles (gemfiles/rails_*.gemfile) used for testing - Run
bundle exec overcommit --installonce, to enable the pre-push hook (runs RuboCop and the full spec suite automatically on everygit push) - Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Run RuboCop lint (
bundle exec rubocop lib spec --format simple) - Run rspec tests (
BUNDLE_GEMFILE=gemfiles/rails_8.1.gemfile bundle exec rspec) - Push your branch (
git push origin my-new-feature). The pre-push hook re-verifies both automatically - Create a new Pull Request to
mainbranch
Releases
📢 See what's changed in a recent version
About
The current maintainer of this gem is:
This project was developed by:
Originally at Wolox
License
simple-crud is available under the MIT license.
Copyright (c) 2017 Wolox
Copyright (c) 2026 Ignacio Coluccio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.