Skip to content

Repository files navigation

RoleModel Rails

Executable Best Practices for Rails apps, based on RoleModel's best approaches

Attempts to solve the pain of:

  • Setup of a new Rails app is harder than it needs to be
    • We've tried application templates, but only useful onetime
  • Our BestPractice learns don't often get ported into other projects as it isn't straightforward to do so
  • There is an emerging pattern of libraries using generators (e.g. webpacker) to migrate a setup between library versions

Precondition

The rolemodel-rails gem expects to be added to an existing Rails project. Typically those are started with:

rails new <app-name> --javascript=webpack --database=postgresql --skip-test --skip-solid

The Devise generator requires your database to exist before running.

rails db:create

Installation

Add this line to your application's Gemfile:

gem'rolemodel-rails'

Important

We used to recommend putting rolemodel-rails in the development gem group. This is no longer supported, because some Rolemodel namespaced utility modules and classes are expected to be available at runtime.

And then execute:

$ bundle install

Usage

Run the core generators (recommended on a new app — sets up our standard baseline so the app is ready to push straight to Heroku)

bin/rails g rolemodel:core_setup

Or run a single generator

bin/rails g rolemodel:webpack

Or run a category subset

bin/rails g rolemodel:testing:all

You can see complete list of available generators (including those under the RoleModel namespace) by running

bin/rails g

Generators

Utilities

Rolemodel::Utility::TaskTools

A mixin of helper methods for writing friendlier, more informative Rake tasks. It provides consistent, migration-style console output, progress indication for long-running loops, an opt-in dry-run mode, and sanitized positional task arguments.

Requiring & Including

The module is available at runtime (rolemodel-rails must not be restricted to the development gem group). Require it and include it inside the namespace block of your .rake file:

require'rolemodel/utility/task_tools'namespace:reportsdoincludeRolemodel::Utility::TaskToolstasktotal: :environmentdo@total=GeneratedReport.countendnamespace:cleardodesc'Delete all non-current report records'taskexpired: :totaldosay_with_time"Detecting & Deleting Expired Reports among #{@total} Total"dodeleted_reports=0GeneratedReport.find_each.with_indexdo |report,index|
indicate_progress(index,@total)nextifreport.current?report.destroyunlessdry_run?deleted_reports += 1endsay"#{deleted_reports}/#{@total} Records Deleted"endendendend

Note

include inside a namespace block adds the helper methods to the anonymous object that evaluates your task bodies, making them (and any plain methods you define alongside them) available to every task in that namespace.

Helper Methods

say(message)

Prints a message using the same style as Rails migrations. Pass subitem: true to indent the line beneath a preceding say.

say'Doing some important stuff!'say'Like this one specific thing!',subitem: true#=> -- Doing some important stuff!#=> -> Like this one specific thing!
say_with_time(message, &block)

Wraps say and prints the block's real execution time as an indented subitem. Use it to announce and time a unit of work.

say_with_time"Deleting all #{@total} Records"doGeneratedReport.destroy_allunlessdry_run?end#=> -- Deleting all 42 Records#=> -> 0.0198s
indicate_progress(index, total = nil, report_interval: 9)

Renders an animated spinner (and, when total is given, a completion percentage) for a long-running loop. Call it once per iteration with the current index; it only redraws every report_interval iterations so the animation stays eye-trackable. Pass a reduced report_interval when iterations are very slow.

GeneratedReport.find_each.with_indexdo |report,index|
indicate_progress(index,@total)# ...end
dry_run?

Returns true when the DRY_RUN environment variable is present, enabling a dry-run pattern for your tasks. Guard any code that writes to the database or file system with unless dry_run? so the task still produces its console feedback without performing the side effects.

report.destroyunlessdry_run?
DRY_RUN=true rake reports:clear:expired
sanitize_arguments(args, defaults = {})

Improves the usability of positional Rake::Task arguments, which cannot declare default values and are awkward to skip. Pass the task's args and a hash of defaults; it returns a hash with whitespace stripped, blank/skipped values (passed as _) replaced by your defaults.

desc'Seed a dev user with the given name and email'task:dev,%i[nameemail]=>:environmentdo |_,args|
name,email=sanitize_arguments(args,name: 'RoleModel',email: 'it-support@rolemodelsoftware.com').values_at(:name,:email)say_with_time"Seeding Dev User (name: #{name}, email: #{email}, role: Admin)"doUser.find_or_create(name:,email:,role: 'admin')unlessdry_run?endend
# Skip the name argument with `_` to fall back to its default
DRY_RUN=true rake users:dev[_,bob@example.com]
#=> -- Seeding Dev User (name: RoleModel, email: bob@example.com, role: Admin)#=> -> 0.0000s

Rolemodel::ResourceFor::ControllerExtension

Provides the private resource_for utility method to all controller classes throughout your app. Use it in conjunction with Routing concerns.

The Problem It Solves

Apps accumulate resources that hang off many different parents. Good examples include comments, reports, duplications.

The naive approach involves many namespaced controllers that all do the same thing but with a different parent resource, or adding several non-restful actions to each parent controller.

The following example illustrates a better pattern.

Routing

Declare the child resource inside a route concern and pass the parent's class name as a route default using parent_resource:

concern:commentabledoresources:comments,commentable_type: parent_resource.name.classifyendconcern:reportabledoresources:generated_reports,only: %i[showcreate],reportable_type: parent_resource.name.classifyendshallowdoresources:accountsdoresources:estimates,concerns: %i[commentablereportable]doresources:widgets,concerns: %i[commentablereportable]endendend
Controller

One controller serves every parent:

classCommentsController < ApplicationControllerbefore_action:set_commentable,only: %i[indexnewcreate]before_action:set_comment,except: %i[indexnewcreate]defcreate# ...endprivatedefset_commentable@commentable=resource_for(:commentable_type)# pass in the symbol specified in your routing concern.endend

Under shallow: true, only the collection actions (index, new, create) carry the parent id — hence the only:/except: split above. Member actions find the child directly by params[:id] and reach the parent through its own association.

resource_for returns the record itself, so it composes with authorization and presentation:

defset_resource@resource=authorizeresource_for(:resource_type)end

Guarding User-Supplied Types

Route defaults are merged into params last, so a route-supplied type cannot be overridden by a query string or request body. If a type ever arrives from user input instead, allowlist it before calling resource_forsafe_constantize will happily resolve any constant in the app:

REPORT_CONTEXTS=%w[AccessoryEstimatePartProxyTank].freezebefore_action:verify_context_type,:set_context,only: %i[create]privatedefverify_context_typereturnifREPORT_CONTEXTS.include?(params[:context_type])redirect_back_or_toroot_url,alert: 'Invalid Request'end

Doing this even for route-supplied types is cheap insurance: it documents which parents the controller actually supports and fails loudly when a new route wires up a parent the controller cannot handle.

Notes

  • Raises ActiveRecord::RecordNotFound when the id does not resolve, which Rails renders as a 404 — the same behavior as any other find.
  • safe_constantize returns nil for an unknown constant, producing a NoMethodError; allowlisting avoids that.
  • The class name is demodulized when deriving the id param, so Reporting::Tank looks for params[:tank_id].
  • Included via ActiveSupport.on_load(:action_controller_base), so ActionController::API controllers do not get it.

Development

Install the versions of Node and Ruby specified in .node-version and .ruby-version on your machine. https://asdf-vm.com/ is a great tool for managing language versions. Then run corepack enable to activate the Yarn 4+ version pinned by each project's packageManager field.

Adding new Generators

Run bin/new_generator passing the name you want to use and a description. Consult the list of existing Generators in case your new generator belongs in one of the existing groups (folders).

e.g.

bin/new_generator testing/fantasitic_specs 'A Fantastic Testing Framework'

We use the embeded Rails apps (example_rails_current & example_rails_legacy) to test generators against. They reference the rolemodel-rails gem by local path, so you can navigate into one of them and run your generator for immediate feedback while developing.

Testing

Generator specs should be added to the spec directory.

Setup & Teardown of the test-dummy app is handled for you. All you need to do is run the provided helper method:

e.g.

RSpec.describeRolemodel::MyGenerator,type: :generatordobefore{run_generators}end

You may also provide command line arguments to the helper method as an array:

e.g.

RSpec.describeRolemodel::Testing::JasminePlaywrightGenerator,type: :generatordobefore{run_generators(['--github-package-token=123'])}end

If the generator you're testing depends on being run after another generator, you may pass an array to the optional generators keyword argument in the order in which they should run.

e.g.

RSpec.describeRolemodel::MyGenerator,type: :generatordobeforedorun_generators(generators: [::Rolemodel::PrereqGenerator,described_class])endend

If you're testing the standard output produced by your generator, assign the return value of run_generators to a variable.

e.g.

RSpec.describeRolemodel::MyGenerator,type: :generatordobeforedo@stdout=run_generators(generators: [::Rolemodel::PrereqGenerator,described_class])endit'prints something useful'doexpect(@stdout[Rolemodel::MyGenerator]).toinclude('something useful')endend

Additional information about testing generators and the available assertions & matchers can be found at the following resources.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/RoleModel/rolemodel_rails.

License

The gem is available as open source under the terms of the MIT License.

About

Gem for generating new rails projects

Resources

Stars

7 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages