When I build out Rails applications these days, I think of ActiveRecord classes purely as something that validates data and controllers as a way to interface HTTP into these data objects.
More often than not, I have to create controllers that do slightly more complicated things than a CRUD interface into the database, so where does the business logic live? I keep it in the ./app/models
directory and use namespaces to help organize it where it makes sense.
Let’s have a look at how I deal with license validation for Terminalwire.
Data validation models
Here’s what the License
model looks like at Terminalwire. You’ll notice it’s mostly data validation and dealing with making data easier to work with from Ruby.
class License < ApplicationRecord
belongs_to :user, required: true
belongs_to :account, required: true
has_one :distribution
has_many :verifications,
class_name: "License::Verification"
# Only allow https or wss for production deployments.
URL_SCHEMES = %w(https wss)
URL_FORMAT = URI::DEFAULT_PARSER.make_regexp(URL_SCHEMES)
validates :url,
presence: true,
format: {
with: URL_FORMAT,
message: "must be a valid #{URL_SCHEMES.to_sentence(words_connector: " or ", two_words_connector: " or ")} URL"
}
normalizes :url, with: ->(value) { value.chomp }
STATUSES = %w[
pending
payment_required
active
expired
inactive
]
validates :status, presence: true, inclusion: { in: STATUSES }
def status
ActiveSupport::StringInquirer.new(self[:status]) if self[:status]
end
before_validation :assign_initial_status, on: :create
def assign_initial_status
self.status = "payment_required" if product&.paid?
end
def name
URI(url).then do |url|
[
url.host,
url.path,
].join
end
end
validates :key, presence: true, uniqueness: true
before_validation :assign_random_key, on: :create
TERMS = %w[
subscription
perpetual
].freeze
validates :term, presence: true, inclusion: { in: TERMS }
attribute :term, :string, default: "perpetual"
TIERS = %w[
core
pro
enterprise
startup
business
].freeze
validates :tier, presence: true, inclusion: { in: TIERS }
attribute :tier, :string, default: "core"
def self.random_key
SecureRandom.uuid
end
def product
ProductPageModel.key(tier)
end
protected
def assign_random_key
self.key ||= self.class.random_key
end
end
These records are created by developers who acquire a license for Terminalwire. Missing is the code that validates the license.
Not a Service Object
When I end up with complicated logic that fits within one model or coordinates multiple models, I create a plain ol’ Ruby object and namespace it.
In this case, I created a License::Verifier
object that accepts a request object in the save_verifications
method and creates a bunch of License::Verification
objects in the database. If a license is valid, it’s found in the databaes and logged as a successful license verification. If it’s not found, it’s still saved in the database as an unsuccessful verification and doesn’t have an association license.
class License::Verifier
include ActiveModel::Model
include ActiveModel::Attributes
VERSION = "1.0".freeze
attribute :url, :string
validates :url, presence: true
attribute :status, :string
attribute :created_at, :string, default: -> { Time.now.utc.iso8601 }
def data
{
version: VERSION,
url:,
shell:,
status:,
created_at:
}
end
def save_verifications(request)
validate
ip_address = request.headers.fetch("Fly-Client-IP", request.remote_ip)
user_agent = request.user_agent
if licenses.any?
licenses.each { _1.verifications.create! url:, ip_address:, user_agent: }
else
License::Verification.create! url:, ip_address:, user_agent:
end
end
# Displays message to the Terminalwire client. Ideally this would use Terminalwire's
# resources, but when I implemented this the protocol would send an unexpected ack to
# the server. This is a workaround.
#
# In the future when the protocol is fixed, add a `commands` key to the protocol that
# can be used to send messages to the client and dispatched to the appropriate resource.
def shell
if valid?
{
device: "stdout",
output: nil
}
else
{
device: "stderr",
output: "\n⚠️ Can't find a valid Terminalwire server license for #{url}.\n\n"
}
end
end
def pack
MessagePack.pack **data
end
# We don't need to verify localhost URLs.
LOCALHOSTS = %w[
localhost
127.0.0.1
0.0.0.0
::1
]
def host
URI(url).host if url
end
def localhost?
host.in? LOCALHOSTS
end
def licenses
License.where(url:)
end
def to_param = self.class.escape url
def persisted? = url.present?
def self.find(url)
new url: unescape(url)
end
def self.unescape(url)
Base64.urlsafe_decode64(url) if url.present?
end
def self.escape(url)
Base64.urlsafe_encode64 url if url.present?
end
end
Since we’re bikeshedding a bit, I’ll admit that it’s not ideal having HTTP request logic outside of a controller, but reality is messy and sometimes it makes sense to do it this way. I know if it stops making sense, it’s easy for me to move it back out into a controller.
Speaking of controllers, let’s have a look at how this all comes together.
The controller
Here’s what the controller endpoint looks like that the Terminalwire-client calls to validate a license. There’s also an HTML UI that validates a license as well.
module Licenses
class VerificationsController < ApplicationController
layout false
# Sets duration in a header that the client shells use
# to cache license check responses.
VERIFICATION_CACHE_TTL = 1.day
before_action do
@verifier = License::Verifier.find(params[:id])
end
def index
@verifiers = License::Verifier.all
end
# ...
def show
if @verifier.valid?
expires_in VERIFICATION_CACHE_TTL, public: true
else
response.status = :not_found
end
@verifier.save_verifications request
respond_to do |format|
format.html { render phlex }
format.msgpack { render plain: @verifier.pack }
end
end
end
end
Putting it all together
When a request comes in that’s msgpack
format, I “find” a license verifier via License::Verifier.find(params[:id])
. That tries to find a License
that a developer created, then runs business logic that verifies the license. All License::Verification
objects are written to the database.