Linters implementados por las bibliotecas de Ruby

Publicado: 2022-03-11

Cuando escuche la palabra " linter " o " lint ", es probable que ya tenga ciertas expectativas sobre cómo funciona una herramienta de este tipo o qué debería hacer.

Podría estar pensando en Rubocop, que mantiene un desarrollador de Toptal, o en JSLint, ESLint, o algo menos conocido o menos popular.

Este artículo le presentará un tipo diferente de linters. No verifican la sintaxis del código ni verifican el Abstract-Syntax-Tree, pero sí verifican el código. Comprueban si una implementación se adhiere a una determinada interfaz, no solo léxicamente (en términos de tipificación pato e interfaces clásicas) sino también semánticamente.

Para familiarizarse con ellos, analicemos algunos ejemplos prácticos. Si no es un ávido profesional de Rails, tal vez quiera leer esto primero.

Comencemos con un Lint básico.

ActiveModel::Lint::Pruebas

El comportamiento de este Lint se explica en detalle en la documentación oficial de Rails:

“Puede probar si un objeto cumple con Active Model API al incluir ActiveModel::Lint::Tests en su TestCase . Incluirá pruebas que le dirán si su objeto es totalmente compatible o, si no, qué aspectos de la API no están implementados. Tenga en cuenta que no es necesario que un objeto implemente todas las API para poder trabajar con Action Pack. Este módulo solo pretende brindar orientación en caso de que desee todas las funciones listas para usar”.

Entonces, si está implementando una clase y le gustaría usarla con la funcionalidad de Rails existente, como redirect_to, form_for , necesita implementar un par de métodos. Esta funcionalidad no se limita a los objetos ActiveRecord . También puede funcionar con sus objetos, pero necesitan aprender a graznar correctamente.

Implementación

La implementación es relativamente sencilla. Es un módulo que se crea para ser incluido en casos de prueba. Su marco implementará los métodos que comienzan con test_ . Se espera que el usuario configure la variable de instancia @model antes de la prueba:

 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

Uso

 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::Pruebas

Los serializadores de modelos activos no son nuevos, pero podemos seguir aprendiendo de ellos. Incluya ActiveModel::Serializer::Lint::Tests para verificar si un objeto cumple con la API de Active Model Serializers. Si no es así, las pruebas indicarán qué partes faltan.

Sin embargo, en los documentos, encontrará una advertencia importante de que no verifica la semántica:

“Estas pruebas no intentan determinar la corrección semántica de los valores devueltos. Por ejemplo, podría implementar serializable_hash para devolver siempre {} y las pruebas pasarían. Depende de usted asegurarse de que los valores sean semánticamente significativos”.

En otras palabras, solo estamos comprobando la forma de la interfaz. Ahora veamos cómo se implementa.

Implementación

Esto es muy similar a lo que vimos hace un momento con la implementación de ActiveModel::Lint::Tests , pero un poco más estricto en algunos casos porque verifica la aridad o clases de valores devueltos:

 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

Aquí hay un ejemplo de cómo ActiveModelSerializers usa la pelusa al incluirla en su caso de prueba:

 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

Rejilla::Pelusa

Los ejemplos anteriores no se preocuparon por la semántica .

Sin embargo, Rack::Lint es una bestia completamente diferente. Es el middleware de Rack en el que puede envolver su aplicación. El middleware juega el papel de un linter en este caso. El linter verificará si las solicitudes y las respuestas se construyen de acuerdo con las especificaciones de Rack. Esto es útil si está implementando un servidor Rack (es decir, Puma) que funcionará con la aplicación Rack y quiere asegurarse de seguir la especificación Rack.

Alternativamente, se usa cuando implementa una aplicación muy simple y desea asegurarse de no cometer errores simples relacionados con el protocolo HTTP.

Implementación

 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 en su aplicación

Digamos que construimos un punto final muy simple. A veces debería responder con "Sin contenido", pero cometimos un error deliberado y enviaremos algún contenido en el 50% de los 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

En tales casos, Rack::Lint interceptará la respuesta, la verificará y generará una excepción:

 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 en Puma

En este ejemplo vemos cómo Puma envuelve una aplicación muy simple lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } primero en un ServerLint (que hereda de Rack::Lint ) luego en ErrorChecker .

La pelusa genera excepciones en caso de que no se siga la especificación. El verificador detecta las excepciones y devuelve el código de error 500. El código de prueba verifica que no se produjo la excepción:

 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

Así es como se verifica que Puma es compatible con Rack certificado.

RailsEventStore - Repositorio Lint

Rails Event Store es una biblioteca para publicar, consumir, almacenar y recuperar eventos. Su objetivo es ayudarlo a implementar la Arquitectura impulsada por eventos para su aplicación Rails. Es una biblioteca modular construida con pequeños componentes, como un repositorio, un mapeador, un despachador, un programador, suscripciones y un serializador. Cada componente puede tener una implementación intercambiable.

Por ejemplo, el repositorio predeterminado usa ActiveRecord y asume un cierto diseño de tabla para almacenar eventos. Sin embargo, su implementación puede usar ROM o trabajar en memoria sin almacenar eventos, lo cual es útil para realizar pruebas.

Pero, ¿cómo puede saber si el componente que implementó se comporta de la manera que espera la biblioteca? Usando el linter provisto, por supuesto. Y es inmenso. Abarca alrededor de 80 casos. Algunos de ellos son 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 end

Y algunos son un poco más complejos y se relacionan con caminos infelices:

 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

Con casi 1400 líneas de código Ruby, creo que es el linter más grande escrito en Ruby. Pero si conoces uno más grande, házmelo saber. La parte interesante es que se trata 100% de la semántica.

También prueba en gran medida la interfaz, pero diría que es una ocurrencia tardía dado el alcance de este artículo.

Implementación

El linter del repositorio se implementa utilizando la funcionalidad de ejemplos compartidos de 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, al igual que los demás, espera que proporcione algunos métodos, sobre todo el repository , que devuelve la implementación para verificarla. Los ejemplos de prueba se incluyen utilizando el método incluido RSpec include_examples :

 RSpec.describe EventRepository do include_examples :event_repository let(:repository) { EventRepository.new(serializer: YAML) } end

Terminando

Como puedes ver, “ linter” tiene un significado un poco más amplio de lo que solemos tener en mente. Cada vez que implemente una biblioteca que espera algunos colaboradores intercambiables, le recomiendo que considere proporcionar un linter.

Incluso si la única clase que pasa estas pruebas al principio es una clase que también proporciona su biblioteca, es una señal de que usted, como ingeniero de software, se toma en serio la extensibilidad. También lo desafiará a pensar en la interfaz de cada componente en su código, no accidentalmente sino conscientemente.

Recursos

  • ActiveModel: haga que cualquier objeto de Ruby se sienta como ActiveRecord
  • RailsCast Acerca del modelo activo
  • Implementación completa de Rack Lint
  • Uso de pelusas en rejillas en Puma
  • Obtenga más información sobre la tienda de eventos de Rails