Linters implementados por bibliotecas Ruby
Publicados: 2022-03-11Quando você ouve a palavra “ lint ” ou “ lint ”, provavelmente já tem certas expectativas sobre como essa ferramenta funciona ou o que ela deve fazer.
Você pode estar pensando no Rubocop, que um desenvolvedor da Toptal mantém, ou no JSLint, ESLint ou algo menos conhecido ou menos popular.
Este artigo irá apresentá-lo a um tipo diferente de linter. Eles não verificam a sintaxe do código nem verificam a Abstract-Syntax-Tree, mas verificam o código. Eles verificam se uma implementação adere a uma determinada interface, não apenas lexicalmente (em termos de tipagem de pato e interfaces clássicas), mas às vezes também semanticamente.
Para nos familiarizarmos com eles, vamos analisar alguns exemplos práticos. Se você não é um profissional ávido de Rails, você pode querer ler isto primeiro.
Vamos começar com um Lint básico.
ActiveModel::Lint::Tests
O comportamento deste Lint é explicado em detalhes na documentação oficial do Rails:
“Você pode testar se um objeto é compatível com a API Active Model incluindo ActiveModel::Lint::Tests em seu TestCase . Ele incluirá testes que informam se seu objeto é totalmente compatível ou, se não, quais aspectos da API não estão implementados. Observe que um objeto não é necessário para implementar todas as APIs para funcionar com o Action Pack. Este módulo apenas pretende fornecer orientação caso você queira todos os recursos prontos para uso.”
Então, se você está implementando uma classe e gostaria de usá-la com funcionalidades existentes do Rails como redirect_to, form_for , você precisa implementar alguns métodos. Essa funcionalidade não está limitada a objetos ActiveRecord . Também pode funcionar com seus objetos, mas eles precisam aprender a grasnar corretamente.
Implementação
A implementação é relativamente simples. É um módulo criado para ser incluído em casos de teste. Os métodos que começam com test_ serão implementados pelo seu framework. Espera-se que a variável de instância @model seja configurada pelo usuário antes do teste:
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 endUso
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 endActiveModel::Serializer::Lint::Tests
Os serializadores de modelo ativos não são novos, mas podemos continuar aprendendo com eles. Você inclui ActiveModel::Serializer::Lint::Tests para verificar se um objeto é compatível com a API Active Model Serializers. Se não for, os testes indicarão quais peças estão faltando.
No entanto, nos documentos, você encontrará um aviso importante de que ele não verifica a semântica:
“Esses testes não tentam determinar a correção semântica dos valores retornados. Por exemplo, você poderia implementar serializable_hash para sempre retornar {} e os testes seriam aprovados. Cabe a você garantir que os valores sejam semanticamente significativos.”
Em outras palavras, estamos apenas verificando a forma da interface. Agora vamos ver como ele é implementado.
Implementação
Isso é muito semelhante ao que vimos há pouco com a implementação de ActiveModel::Lint::Tests , mas um pouco mais rigoroso em alguns casos porque verifica a aridade ou classes de valores retornados:
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 ...Uso
Aqui está um exemplo de como ActiveModelSerializers usa o lint incluindo-o em seu caso de teste:
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 endRack::Lint
Os exemplos anteriores não se importavam com a semântica .
No entanto, Rack::Lint é uma fera completamente diferente. É no middleware Rack que você pode encapsular seu aplicativo. O middleware desempenha o papel de um linter neste caso. O linter verificará se as solicitações e respostas são construídas de acordo com a especificação do Rack. Isso é útil se você estiver implementando um servidor Rack (ou seja, Puma) que servirá o aplicativo Rack e deseja garantir que segue a especificação Rack.
Como alternativa, ele é usado quando você implementa um aplicativo muito simples e deseja garantir que não cometa erros simples relacionados ao protocolo HTTP.
Implementação
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 ...Uso em seu aplicativo
Digamos que construímos um endpoint muito simples. Às vezes, ele deveria responder com “Sem conteúdo”, mas cometemos um erro deliberado e enviaremos algum conteúdo em 50% dos casos:

# 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 Nesses casos, Rack::Lint interceptará a resposta, a verificará e gerará uma exceção:
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'Uso em Puma
Neste exemplo, vemos como o Puma envolve uma aplicação lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } primeiro em um ServerLint (que herda de Rack::Lint ) e depois em ErrorChecker .
O lint gera exceções caso a especificação não seja seguida. O verificador captura as exceções e retorna o código de erro 500. O código de teste verifica se a exceção não ocorreu:
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É assim que o Puma é certificado para ser compatível com Rack.
RailsEventStore - Repositório Lint
Rails Event Store é uma biblioteca para publicação, consumo, armazenamento e recuperação de eventos. Ele tem como objetivo ajudá-lo na implementação da Arquitetura Orientada a Eventos para sua aplicação Rails. É uma biblioteca modular construída com pequenos componentes, como repositório, mapeador, despachante, agendador, assinaturas e serializador. Cada componente pode ter uma implementação intercambiável.
Por exemplo, o repositório padrão usa ActiveRecord e assume um determinado layout de tabela para armazenar eventos. No entanto, sua implementação pode usar ROM ou trabalhar na memória sem armazenar eventos, o que é útil para testes.
Mas como você pode saber se o componente que você implementou se comporta da maneira que a biblioteca espera? Usando o linter fornecido, é claro. E é imenso. Abrange cerca de 80 casos. Alguns deles são relativamente simples:
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 endE alguns são um pouco mais complexos e se relacionam com caminhos infelizes:
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) endCom quase 1.400 linhas de código Ruby, acredito que seja o maior linter escrito em Ruby. Mas se você souber de um maior, me avise. A parte interessante é que é 100% sobre a semântica.
Ele também testa fortemente a interface, mas eu diria que é uma reflexão tardia, devido ao escopo deste artigo.
Implementação
O linter do repositório é implementado usando a funcionalidade de exemplos compartilhados RSpec:
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 # ...Uso
Este linter, assim como os demais, espera que você forneça alguns métodos, principalmente o repository , que retorna a implementação a ser verificada. Os exemplos de teste são incluídos usando o método integrado RSpec include_examples :
RSpec.describe EventRepository do include_examples :event_repository let(:repository) { EventRepository.new(serializer: YAML) } endEmpacotando
Como você pode ver, “ linter” tem um significado um pouco mais amplo do que o que costumamos ter em mente. Sempre que você implementar uma biblioteca que espera alguns colaboradores intercambiáveis, eu o encorajo a considerar fornecer um linter.
Mesmo que a única classe que passa nesses testes no início seja uma classe também fornecida por sua biblioteca, é um sinal de que você, como engenheiro de software, leva a extensibilidade a sério. Também o desafiará a pensar na interface de cada componente do seu código, não acidentalmente, mas conscientemente.
Recursos
- ActiveModel: Faça qualquer objeto Ruby parecer ActiveRecord
- RailsCast sobre o modelo ativo
- Implementação de fiapos de rack completo
- Uso de fiapos de rack no Puma
- Saiba mais sobre a loja de eventos Rails
