Si tuviera que describir Ruby con una sola palabra, sería versatilidad. Es tan versátil que hasta parece como si se pudiera inventar sintaxis nueva (todos tenemos ese amigo/a que se queja de que “Ruby es demasiado mágico”).

En mi anterior blogpost hablé sobre nuestro módulo de versionamiento, de cómo se vería para nuestros usuarios y de cómo lo usarían nuestros devs. Hay una cosa que intencionalmente no incluí y es de lo que vengo a hablar: mágicos detalles de implementación.

Buena parte de escribir el módulo de versionamiento fue escribir un DSL (Domain Specific Language) diseñado para simplificar la expresión de cambios. Un DSL es un lenguaje o API, creada para llevar a cabo tareas concretas en un dominio específico, de manera concisa y expresiva.

Para escribir el DSL con el que devs creen nuevas versiones de la API me aproveché mucho de la versatilidad de Ruby y no decepcionó. Veamos algunas de las magias de Ruby. 🧙

Cómo se ve el DSL

Para que un dev pueda crear una nueva versión de la API, solo tiene que escribir un pequeño módulo encapsulando el cambio en la forma de entrada y salida de una request, desde cómo está la API antes de la nueva versión hasta cómo se ve después de la versión. Acá hay un ejemplo:

class RandomChange < Versioning::VersionChange
  description \
    'Change the :data field name of the ' \
    'request body to :metadata'

  request MyController, :create do
    body do |attributes|
      metadata = attributes[:data]
      attributes.except(:data).merge(metadata: metadata)
    end
  end
end

El DSL es muy expresivo, escribir los cambios toma solo un par de líneas y la interfaz para los devs es simple y segura.

request y response

Los métodos request y response están inmediatamente definidos para la clase y no hay que instanciarla para usarlos (de hecho se llaman en la definición de RandomChange). Para que eso fuera posible, esos métodos deben definirse en la eigenclass de la clase Versioning::VersionChange.

class Versioning::VersionChange
  class << self
    def request(controller, action, &block)
      ...
    end
  end
end

Acá, la línea class << self abre la eigenclass de la clase que se está definiendo para poder definir métodos en ella.

Con eso, el método request puede ser usado inmediatamente en la definición de las clases que heredan de Versioning::VersionChange.

Además, notemos que el método request recibe un parámetro controller, un parámetro action y un parámetro &block. Para el DSL nos aprovechamos de que en ruby se pueden omitir los paréntesis al llamar las funciones. Diseccionemos el siguiente código:

request MyController, :create do
  body do |attributes|
    metadata = attributes[:data]
    attributes.except(:data).merge(metadata: metadata)
  end
end

Aquí, MyController corresponde al parámetro controller del método, :create corresponde al parámetro action y el bloque que viene después del primer do es simplemente el parámetro &block del método (esa es la sintaxis de Ruby para recibir un bloque en una función). No es magia, solamente ✨ Ruby ✨.

Scoped Context

La parte que más me costó de escribir el DSL fue disponibilizar la función body y params para que pudieran usarse en los métodos request y response. ¿Cómo podía distinguir entre una función body llamada desde request y una llamada desde response?

Además, para mí era esencial impedir que un dev pudiera usar la función body en cualquier parte de la clase Change que está escribiendo. Sé que podría simplemente haber documentado que la función body no se puede usar fuera de request y response, pero entonces no habría sido mágico.

Implementación v1

Mi naive approach fue escribir una versión funcional sin los nice to have que quería al principio y simplemente definir el método request_body en la eigenclass de la clase Versioning::VersionChange.

class Versioning::VersionChange
  class << self
    def request(controller, action, &block)
      ...
    end

    def request_body(&block)
      ...
    end
  end
end

Con esto, se podía usar el método request_body dentro de request:

class RandomChange < Versioning::VersionChange
  request MyController, :create do
    request_body do |attributes|
      ...
    end
  end
end

Todo bien hasta aquí. La funcionalidad básica estaba implementada ✅, y una versión funcional se podría haber creado. Pero habían dos cosas dando vueltas a mi cabeza:

  1. Me molestaba que la función se llamara request_body y no simplemente body. Desde el punto de vista de un dev usando el DSL, es ridículo tener que especificar que se está alterando el body de la request llamando una función request_body dentro del bloque pasado a la función request. Debería simplemente llamarse body, pero con esta primera versión de la implementación no habría habido forma de distinguir de dónde era el body a modificar (si de la request o de la response).

2. request_body también se podía usar fuera del contexto de request:

class RandomChange < Versioning::VersionChange
  request_body do |attributes|
    ...
  end

  request MyController, :create do
    ...
  end
end

El código de arriba no arrojaba errores explicativos para un dev. De hecho, se podría haber hecho algo así:

class RandomChange < Versioning::VersionChange
  response MySerializer do
    request_body do |attributes|
      ...
    end
  end
end

Y tampoco habían saltado errores. Un desastre. Lo que quería que pasara era que en cualquiera de los casos de arriba se levantara un error como el siguiente:

=> NoMethodError (undefined method `request_body' for RandomChange:Class)
La magia puede ser buena

La magia versatilidad de Ruby

Me di cuenta rápido de que lo que quería hacer era algo similar a una inyección de contexto. Quería inyectarle al bloque de la función request un contexto que tuviera la función body, para no tener que definirla en la eigenclass de la clase Versioning::VersionChange.

Investigando, me topé con que Ruby tiene una forma de hacer algo similar. Los objetos en Ruby tienen un método llamado instance_eval, que recibe un bloque y lo ejecuta en el contexto del objeto en que fue llamado instance_eval. Veamos un ejemplo:

class NumberClass
  def add_two(number)
    number + 2
  end
end

class OperatorClass
  def operate(&block)
    puts 'operating...'
    NumberClass.new.instance_eval(&block)
    puts 'ending...'
  end
end

operator = OperatorClass.new
operator.operate do
  number = add_two 3
  puts number
end

En el ejemplo de arriba, el bloque de operate usa el método add_two, pero ese método no existe en la clase OperatorClass. Pero como el bloque se evalúa usando instance_eval en una instancia de la clase NumberClass (que sí tiene el método add_two), entonces todo funciona bien.

💡 Aquí se me ocurrió algo: el bloque de request sería ejecutado por una clase con un contexto diseñado específicamente para las requests, y el bloque response sería ejecutado por una clase con un contexto diseñado específicamente para las responses. A trabajar.

Definí que tendría dos clases:

  1. Versioning::RequestTransformer
  2. Versioning::ResponseTransformer

Estas clases tendrían cada una el método body, por lo que cuando el bloque del método request fuera ejecutado por una instancia de la clase Versioning::RequestTransformer, sabría exactamente a qué body pertenecería el cambio. Entonces, mi clase Versioning::VersionChange quedó así:

class Versioning::VersionChange
  class << self
    def request(controller, action, &block)
      ...
      request_transformer.instance_eval(&block)
    end

    private

    def request_transformer
      @request_transformer ||= Versioning::RequestTransformer.new
    end
  end
end

Ahora está claro que todo el código dentro del bloque del método request se ejecuta usando el contexto de una instancia de Versioning::RequestTransformer. Finalmente solo me quedaba definir la clase en cuestión:

class Versioning::RequestTransformer
  def initialize
    @body_transformer = nil
  end

  def body(&block)
    @body_transformer = block
  end

  def transform(original_body)
    @body_transformer&.call(original_body) || original_body
  end
end

Notar que el método body no debe transformar la request inmediatamente, esto se debe hacer a voluntad al momento de transformar las versiones, por eso se define el método transform, y body solamente almacena el bloque.

Lo que hace la clase es recibir el bloque del método body, almacenarlo y exponer un método transform para poder usar el bloque que se le pasa al version change durante su creación.

Con esto, todos los bloques son ejecutados fuera del contexto de la clase Change misma, y son ejecutados por uno de los transformers 🤖.

Así quedó la implementación del DSL para el módulo de versionamiento de la API de Fintoc, con errores claros y una interfaz limpia. Algo que me quedó claro después de haber escrito el DSL: no hay que hacer magia, solamente entender las reglas de Ruby.


Si quieres ser parte del equipo que versiona así, estamos buscando software engineers

Puedes ver las vacantes y postular acá.