Thoughts on software development and occasionally other things.

A cleaner way to start Rails projects

There is a bit of background information required to set this article up: I like clean computer systems. In this context, it means only having the required applications or libraries installed. It keeps things simple. To accomplish this in the Ruby world, I have a ~/.bundle/config that contains BUNDLE_PATH: "vendor/bundle". This ensures all gems are placed in the same folder of the application using them, instead of the default of $GEM_HOME. I only have a handful of gems installed globally, majority being installed per application. One such gem that I previously always had installed globally, including many alternate versions, was Rails. No more!

Recently I have been spending a lot of time experimenting with containerization through Docker. Most of it has been to get an old Rails application (Rails 3.x and Ruby 2.x) running on the latest Apple silicon/ARM architecture powered hardware. During that time I learned a little something to prevent installing Rails globally, and instead only within the context of the application.

Create the folder to store your application’s source code, along with the Gemfile.

mkdir MyApp
cd MyApp
touch Gemfile
touch Gemfile.lock

Edit the Gemfile to include the source and Rails.

source 'https://rubygems.org'
gem 'rails', '~>6'

Install the gems.

bundle install

Finally, initialize the Rails project. This will use the local version of Rails that was specified in the Gemfile and now installed to MyApp/vendor/bundle. The --force will overwrite the current Gemfile and Gemfile.lock without a prompt. The name of the application will be the same as the folder.

bundle exec rails new . --force

That’s it! A gem list will show your global gems, of which Rails is nowhere to be seen. A bundle list will show all of the gems installed for MyApp, including Rails.

Why though? It’s a bit cleaner, I like it.

Requiring Ruby standard library packages

After working with Rails so much, it’s easy to forget all the magic it adds to Ruby. A colleague had a question about why the CSV library worked while in a development environment, but failed to in a production environment. After some quick debugging, we discovered that it was simply because the CSV library wasn’t loaded in the production environment. It was likely that a library loaded only during development was requiring the CSV library, thus making it available to use.

It’s probably a good practice to require all the standard library packages needed by code in a project, and not rely on them coming from somewhere else (such as a gem, that might only be loaded in certain contexts). This is certainly something that I have overlooked lately in my own code, as things like CSV, ‘just worked’.

Systemd and Puma

This website is served by a combination of Nginx and Puma on a Ubuntu host (all on a virtual private server in the cloud somewhere). It took some time to get all of these components talking to each other in the way that I wanted. System administration has always been a hobby of mine ever since taking up programming, however I am in no way an expert. This is as much for others as it is for my self. Without a doubt it will be useful in my future as I build and maintain servers, and in the mean time it may also help someone else struggling with a similar setup.

This assumes Puma is serving a Ruby on Rails application. I am also using Capistrano for deployment, which determines the folder structures used in the configuration files.

Here is the Puma configuration script living at /var/www/my-app/shared/puma.rb.

environment "production"

bind  "unix:///var/www/my-app/shared/tmp/sockets/puma.sock"
pidfile "/var/www/my-app/shared/tmp/pids/puma.pid"
state_path "/var/www/my-app/shared/tmp/pids/puma.state"
directory "/var/www/my-app/current"
rackup "/var/www/my-app/current/config.ru"

stdout_redirect "/var/www/my-app/shared/log/puma_access.log", "/var/www/my-app/shared/log/puma_error.log", true

workers 0
threads 0,16

activate_control_app "unix:///var/www/my-app/shared/tmp/sockets/pumactl.sock"

prune_bundler

Definitely be sure to tweak all the settings as needed. Also note that the typical daemonize true line is not present in this configuration. This is key to make managing Puma with Systemd simpler.

Next is the Systemd configuration file for the Puma application server in /etc/systemd/system/puma.service.

[Unit]
Description=Puma application server
After=network.target

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/www/my-app/current
Environment=RAILS_ENV=production

ExecStart=/home/my-user/.rbenv/shims/bundle exec puma -C /var/www/my-app/shared/puma.rb --pidfile /var/www/my-app/shared/tmp/pids/puma.pid -e production
ExecStop=/home/my-user/.rbenv/shims/bundle exec pumactl -S /var/www/my-app/shared/tmp/pids/puma.state stop
ExecReload=/home/my-user/.rbenv/shims/bundle exec pumactl -S /var/www/my-app/shared/tmp/pids/puma.state phased-restart
Restart=always
KillMode=process

[Install]
WantedBy=multi-user.target

If daemonize true is included in the Puma configuration, then the Systemd configuration would need to include something like PIDFile=/var/www/my-app/shared/tmp/pids/puma.pid (a value set at the top of the Puma configuration file).

With these two files in place, the typical Systemd commands all behave as expected status, start, stop, restart, etc. Assuming everything else (Nginx, database, etc.) is setup properly, Puma should boot back up and be serving the application if the host machine ever needs to be restarted.

Auto-resizing text fields with Stimulus

Having a text field grow with its contents is a much nicer experience than having to scroll all the way through it. Writing and reviewing articles for my website wasn’t the easiest because of that. I mostly wrote in a text editor and copied/pasted to avoid having to scroll as much. That work-flow put some barriers up in terms of getting an article written. I’ve been experimenting with Stimulus lately, and figured it would be a good learning experience to solve the problem using it.

It really isn’t all that complex. There are different, and likely better, ways to solve this problem.

// expandable_field_controller.js
import { Controller } from 'stimulus'

export default class extends Controller {

  static targets = ['input']

  initialize() {
    this.setup()
    this.resize()
  }

  setup() {
    this.inputTarget.style.height = `${this.inputTarget.scrollHeight}px`
  }

  resize() {
    this.inputTarget.style.height = 'auto'
    this.inputTarget.style.height = `${this.inputTarget.scrollHeight}px`
  }

}

It’s worth noting that I have a min-height set on <textarea> controls that this is used with. Setting the height to auto on the resize() call prevented a weird bug when reducing the height (?). I chose to use the input event to trigger the behaviour.

<div data-controller="expandable-field">
  <%=
    form.text_area :content,
      data: {
        target: "expandable-field.input",
        action: "input->expandable-field#resize"
      }
  %>
</div>

Dynamic routes with Ruby on Rails

Often the need to build dynamic pages with static routes into Ruby on Rails applications comes up. One such example is an, ‘About Us’, page. The content has to be modifiable through a web interface, but the route is typically static, such as, /about. There are many approaches to handling this without getting into building something complex like a full content management system.

Here is a minimal model to represent a page.

Page(id: uuid, title: string, permalink: string, content: string)

A typical model would have a resourceful route defined for it (resources :pages) letting a page be accessible at /pages/:id. Overriding Page#to_param and setting it to the permalink attribute is a step in the right direction by producing a URL like /pages/about. It’s not ideal to define a named route that points to an action which loads a particular page:

##
# This approach works, but has drawbacks...
#

# config/routes.rb
get 'about', to: 'pages#about'

# app/controllers/pages_controller.rb
...
def about
  @page = Page.find_by(permalink: 'about')
end
...

This works, but it’s too rigid. If the permalink changes, or another page is added, the routes and controller would need to be updated. Routes cannot be dynamically generated and updated (i.e. defining named routes for all pages at present and in the future). However, Rails provides routing constraints, which can be used to achieve something similar.

get ':permalink',
  to: 'pages#show',
  constraints: lambda { |request| Page.exists?(permalink: request[:permalink]) }

This effectively defines a top-level route for all pages. In most cases it’s best to place this route near the bottom, so that is has the lowest priority. Here’s the controller action to back this route up:

# app/controllers/pages_controller.rb
...
def show
  @page = Page.find_by(permalink: params[:permalink])
end
...

An advantage of this approach over routing all unmatched requests to a controller action is that the Rails router can still take care of serving the 404 page!

Permalinks with Ruby on Rails

There are many ways to get nice looking permalinks using Ruby on Rails. By default, Rails uses the resource name and its primary key (/articles/1001 for example). I wanted something a little different for my personal website. There are more sophisticated ways to achieve this, but I went with a straight-forward approach using normal routes.

constraints year: /\d{4}/, month: /\w{3}/, day: /\d{1,2}/, permalink: /[\w\-]+/ do
  get 'articles/:year/:month/:day/:permalink', to: 'articles#show', as: :article
end

The other piece that makes this work is an instance method on the Article model.

def url
  [
    "",
    "articles",
    article.created_at.year,
    article.created_at.strftime("%b").downcase,
    article.created_at.day,
    article.permalink
  ] * "/"
end

Now calling @article.url will return a string that can be used to link directly to an article! The controller can use any logic it wants to find the article in the database. If the permalink column is unique in the database, then it could query it directly.

It is safe to expect more

It has been a long time since this domain was home to anything more than an empty page. Things are changing. I am making a commitment to my self to keep this, my personal space on the web, much more than an empty page.