De mágico a versátil: Ruby y el backstage de nuestro versionamiento
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:
- Me molestaba que la función se llamara
request_body
y no simplementebody
. 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ónrequest_body
dentro del bloque pasado a la funciónrequest
. Debería simplemente llamarsebody
, 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 larequest
o de laresponse
). -
request_body
también se podía usar fuera del contexto derequest
:
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 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:
Versioning::RequestTransformer
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étodobody
no debe transformar la request inmediatamente, esto se debe hacer a voluntad al momento de transformar las versiones, por eso se define el métodotransform
, ybody
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 de un equipo que versiona así, estamos buscando software engineers
Puedes ver las vacantes y postular acá.