Technology8 minute read

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.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.

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.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.
Robert Pankowecki

Robert Pankowecki

Back-end Architect

Robert is a software architect who specializes in large, monolithic Rails applications. He has authored five books about Rails, React, and Domain-driven Design.

Share

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

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.

Hire a Toptal expert on this topic.
Hire Now

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

Join the Toptal® community.