Linters Implemented by Ruby Libraries
When you hear the word “linter,” you probably think about particular widely used tools. But there’s a different kind of linters.
In this article, Toptal Back-end Architect Robert Pankowecki introduces you to linters implemented by Ruby libraries and details their capabilities.
When you hear the word “linter,” you probably think about particular widely used tools. But there’s a different kind of linters.
In this article, Toptal Back-end Architect Robert Pankowecki introduces you to linters implemented by Ruby libraries and details their capabilities.
Robert Pankowecki
Robert is a software architect who specializes in large, monolithic Rails applications. He has authored five books about Rails, React, and Domain-driven Design.
Expertise
When you hear the word “linter” or “lint” you likely already have certain expectations about how such a tool works or what it should do.
You might be thinking of Rubocop, which one Toptal developer maintains, or of JSLint, ESLint, or something less well known or less popular.
This article will introduce you to a different kind of linters. They don’t check code syntax nor do they verify the Abstract-Syntax-Tree, but they do verify code. They check if an implementation adheres to a certain interface, not only lexically (in terms of duck typing and classic interfaces) but sometimes also semantically.
To become familiarized with them, let’s analyze some practical examples. If you’re not an avid Rails professional, you may want to read this first.
Let’s get started with a basic Lint.
ActiveModel::Lint::Tests
The behavior of this Lint is explained in detail in official Rails documentation:
“You can test whether an object is compliant with the Active Model API by including ActiveModel::Lint::Tests
in your TestCase
. It will include tests that tell you whether your object is fully compliant or, if not, which aspects of the API are not implemented. Note an object is not required to implement all APIs in order to work with Action Pack. This module only intends to provide guidance in case you want all features out of the box.”
So, if you’re implementing a class and you would like to use it with existing Rails functionality such as redirect_to, form_for
, you need to implement a couple methods. This functionality is not limited to ActiveRecord
objects. It can work with your objects, too, but they need to learn to quack properly.
Implementation
The implementation is relatively straightforward. It’s a module that is created to be included in test cases. The methods that begin with test_
will be implemented by your framework. It is expected that the @model
instance variable will be set up by the user ahead of the test:
module ActiveModel
module Lint
module Tests
def test_to_key
assert_respond_to model, :to_key
def model.persisted?() false end
assert model.to_key.nil?, "to_key should return nil when `persisted?` returns false"
end
def test_to_param
assert_respond_to model, :to_param
def model.to_key() [1] end
def model.persisted?() false end
assert model.to_param.nil?, "to_param should return nil when `persisted?` returns false"
end
...
private
def model
assert_respond_to @model, :to_model
@model.to_model
end
Usage
class Person
def persisted?
false
end
def to_key
nil
end
def to_param
nil
end
# ...
end
# test/models/person_test.rb
require "test_helper"
class PersonTest < ActiveSupport::TestCase
include ActiveModel::Lint::Tests
setup do
@model = Person.new
end
end
ActiveModel::Serializer::Lint::Tests
Active model serializers are not new, but we can continue to learn from them. You include ActiveModel::Serializer::Lint::Tests
to verify whether an object is compliant with the Active Model Serializers API. If it is not, the tests will denote which parts are missing.
However, in the docs, you’ll find an important warning that it does not check semantics:
“These tests do not attempt to determine the semantic correctness of the returned values. For instance, you could implement serializable_hash
to always return {}
, and the tests would pass. It is up to you to ensure that the values are semantically meaningful.”
In other words, we are only checking the shape of the interface. Now let’s see how it’s implemented.
Implementation
This is very similar to what we saw a moment ago with the implementation of ActiveModel::Lint::Tests
, but a bit more strict in some cases because it checks arity or classes of returned values:
module ActiveModel
class Serializer
module Lint
module Tests
# Passes if the object responds to <tt>read_attribute_for_serialization</tt>
# and if it requires one argument (the attribute to be read).
# Fails otherwise.
#
# <tt>read_attribute_for_serialization</tt> gets the attribute value for serialization
# Typically, it is implemented by including ActiveModel::Serialization.
def test_read_attribute_for_serialization
assert_respond_to resource, :read_attribute_for_serialization, 'The resource should respond to read_attribute_for_serialization'
actual_arity = resource.method(:read_attribute_for_serialization).arity
# using absolute value since arity is:
# 1 for def read_attribute_for_serialization(name); end
# -1 for alias :read_attribute_for_serialization :send
assert_equal 1, actual_arity.abs, "expected #{actual_arity.inspect}.abs to be 1 or -1"
end
# Passes if the object's class responds to <tt>model_name</tt> and if it
# is in an instance of +ActiveModel::Name+.
# Fails otherwise.
#
# <tt>model_name</tt> returns an ActiveModel::Name instance.
# It is used by the serializer to identify the object's type.
# It is not required unless caching is enabled.
def test_model_name
resource_class = resource.class
assert_respond_to resource_class, :model_name
assert_instance_of resource_class.model_name, ActiveModel::Name
end
...
Usage
Here’s an example of how ActiveModelSerializers
uses the lint by including it in its test case:
module ActiveModelSerializers
class ModelTest < ActiveSupport::TestCase
include ActiveModel::Serializer::Lint::Tests
setup do
@resource = ActiveModelSerializers::Model.new
end
def test_initialization_with_string_keys
klass = Class.new(ActiveModelSerializers::Model) do
attributes :key
end
value = 'value'
model_instance = klass.new('key' => value)
assert_equal model_instance.read_attribute_for_serialization(:key), value
end
Rack::Lint
The previous examples didn’t care about semantics.
However, Rack::Lint
is a completely different beast. It is Rack middleware that you can wrap your application in. The middleware plays the role of a linter in this case. The linter will check whether requests and responses are constructed according to the Rack spec. This is useful if you’re implementing a Rack server (i.e., Puma) that will serve the Rack application and you want to ensure that you follow the Rack specification.
Alternatively, it is used when you implement a very bare application and you want to ensure that you don’t make simple mistakes related to the HTTP protocol.
Implementation
module Rack
class Lint
def initialize(app)
@app = app
@content_length = nil
end
def call(env = nil)
dup._call(env)
end
def _call(env)
raise LintError, "No env given" unless env
check_env env
env[RACK_INPUT] = InputWrapper.new(env[RACK_INPUT])
env[RACK_ERRORS] = ErrorWrapper.new(env[RACK_ERRORS])
ary = @app.call(env)
raise LintError, "response is not an Array, but #{ary.class}" unless ary.kind_of? Array
raise LintError, "response array has #{ary.size} elements instead of 3" unless ary.size == 3
status, headers, @body = ary
check_status status
check_headers headers
hijack_proc = check_hijack_response headers, env
if hijack_proc && headers.is_a?(Hash)
headers[RACK_HIJACK] = hijack_proc
end
check_content_type status, headers
check_content_length status, headers
@head_request = env[REQUEST_METHOD] == HEAD
[status, headers, self]
end
## === The Content-Type
def check_content_type(status, headers)
headers.each { |key, value|
## There must not be a <tt>Content-Type</tt>, when the +Status+ is 1xx, 204 or 304.
if key.downcase == "content-type"
if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i
raise LintError, "Content-Type header found in #{status} response, not allowed"
end
return
end
}
end
## === The Content-Length
def check_content_length(status, headers)
headers.each { |key, value|
if key.downcase == 'content-length'
## There must not be a <tt>Content-Length</tt> header when the +Status+ is 1xx, 204 or 304.
if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i
raise LintError, "Content-Length header found in #{status} response, not allowed"
end
@content_length = value
end
}
end
...
Usage in Your App
Let’s say we build a very simple endpoint. Sometimes it should respond with “No Content,” but we made a deliberate mistake and we will send some content in 50% of the cases:
# foo.rb
# run with rackup foo.rb
Foo = Rack::Builder.new do
use Rack::Lint
use Rack::ContentLength
app = proc do |env|
if rand > 0.5
no_content = Rack::Utils::HTTP_STATUS_CODES.invert['No Content']
[no_content, { 'Content-Type' => 'text/plain' }, ['bummer no content with content']]
else
ok = Rack::Utils::HTTP_STATUS_CODES.invert['OK']
[ok, { 'Content-Type' => 'text/plain' }, ['good']]
end
end
run app
end.to_app
In such cases, Rack::Lint
will intercept the response, verify it, and raise an exception:
Rack::Lint::LintError: Content-Type header found in 204 response, not allowed
/Users/dev/.rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/rack-2.2.3/lib/rack/lint.rb:21:in `assert'
/Users/dev/.rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/rack-2.2.3/lib/rack/lint.rb:710:in `block in check_content_type'
Usage in Puma
In this example we see how Puma wraps a very simple application lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] }
first in a ServerLint
(which inherits from Rack::Lint
) then in ErrorChecker
.
The lint raises exceptions in case the specification is not followed. The checker catches the exceptions and returns error code 500. The test code verifies that the exception did not occur:
class TestRackServer < Minitest::Test
class ErrorChecker
def initialize(app)
@app = app
@exception = nil
end
attr_reader :exception, :env
def call(env)
begin
@app.call(env)
rescue Exception => e
@exception = e
[ 500, {}, ["Error detected"] ]
end
end
end
class ServerLint < Rack::Lint
def call(env)
check_env env
@app.call(env)
end
end
def setup
@simple = lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] }
@server = Puma::Server.new @simple
port = (@server.add_tcp_listener "127.0.0.1", 0).addr[1]
@tcp = "http://127.0.0.1:#{port}"
@stopped = false
end
def test_lint
@checker = ErrorChecker.new ServerLint.new(@simple)
@server.app = @checker
@server.run
hit(["#{@tcp}/test"])
stop
refute @checker.exception, "Checker raised exception"
end
That’s how Puma is verified to be certified Rack compatible.
RailsEventStore - Repository Lint
Rails Event Store is a library for publishing, consuming, storing, and retrieving events. It aims to help you in implementing Event-Driven Architecture for your Rails application. It’s a modular library built with small components such as a repository, mapper, dispatcher, scheduler, subscriptions, and serializer. Each component can have an interchangeable implementation.
For example, the default repository uses ActiveRecord and assumes a certain table layout for storing events. However, your implementation can use ROM or work in-memory without storing events, which is useful for testing.
But how can you know if the component you implemented behaves in a way the library expects? By using the provided linter, of course. And it’s immense. It covers about 80 cases. Some of them are relatively simple:
specify 'adds an initial event to a new stream' do
repository.append_to_stream([event = SRecord.new], stream, version_none)
expect(read_events_forward(repository).first).to eq(event)
expect(read_events_forward(repository, stream).first).to eq(event)
expect(read_events_forward(repository, stream_other)).to be_empty
end
And some are a little bit more complex and relate to unhappy paths:
it 'does not allow linking same event twice in a stream' do
repository.append_to_stream(
[SRecord.new(event_id: "a1b49edb")],
stream,
version_none
).link_to_stream(["a1b49edb"], stream_flow, version_none)
expect do
repository.link_to_stream(["a1b49edb"], stream_flow, version_0)
end.to raise_error(EventDuplicatedInStream)
end
At almost 1,400 lines of Ruby code, I believe it is the biggest linter written in Ruby. But if you’re aware of a bigger one, let me know. The interesting part is that it is 100% about the semantics.
It heavily tests the interface too, but I would say that is an afterthought given the scope of this article.
Implementation
The repository linter is implemented using the RSpec Shared Examples functionality:
module RubyEventStore
::RSpec.shared_examples :event_repository do
let(:helper) { EventRepositoryHelper.new }
let(:specification) { Specification.new(SpecificationReader.new(repository, Mappers::NullMapper.new)) }
let(:global_stream) { Stream.new(GLOBAL_STREAM) }
let(:stream) { Stream.new(SecureRandom.uuid) }
let(:stream_flow) { Stream.new('flow') }
# ...
it 'just created is empty' do
expect(read_events_forward(repository)).to be_empty
end
specify 'append_to_stream returns self' do
repository
.append_to_stream([event = SRecord.new], stream, version_none)
.append_to_stream([event = SRecord.new], stream, version_0)
end
# ...
Usage
This linter, similar to the others, expects you to provide some methods, most importantly the repository
, which returns the implementation to be verified. The test examples are included using the built-in RSpec include_examples
method:
RSpec.describe EventRepository do
include_examples :event_repository
let(:repository) { EventRepository.new(serializer: YAML) }
end
Wrapping Up
As you can see, “linter” has a slightly broader meaning than what we usually have in mind. Any time you implement a library that expects some interchangeable collaborators, I encourage you to consider providing a linter.
Even if the only class passing such tests in the beginning will be a class also provided by your library, it’s a sign that you as a software engineer take extensibility seriously. It will also challenge you to think about the interface for each component in your code, not accidentally but consciously.
Resources
Further Reading on the Toptal Blog:
Understanding the basics
Why are linters called linters?
Lint was a command that examined C source programs, detecting a number of bugs and obscurities. That’s how the term originated.
What does it mean to lint code?
It means to verify its correctness in accordance with additional linting rules.
What are linting rules?
Linting rules are checks verifying specific properties of the analyzed code. These can be superficial, focusing on style guides, or deep, performing sophisticated static analyses of the code and looking for non-trivial errors.