Cómo crear una gema de Ruby on Rails desde tu código existente

Es casi instintivo para los desarrolladores buscar soluciones que otros desarrolladores han utilizado con éxito cuando se enfrentan a un problema. (¿Aún mejor si hay una biblioteca preexistente que podemos conectar directamente a nuestro código y ahorrarnos algo de tiempo, verdad?)

En esta publicación del blog, tomaré mi publicación anterior (Cómo usar FilePond con Active Storage de Rails) y convertiré el código de allí en una gema. Mientras lees esta publicación, ten en cuenta que este es mi enfoque. No hay una única forma de hacer esto, y notarás mientras lees varios códigos fuente de diferentes gemas que los autores tienen varias preferencias. Sin embargo, hay algunas mejores prácticas generales que describiré.

Por cierto, si eres nuevo en el mundo de Ruby, llamamos a las bibliotecas “gemas”. Rails, por ejemplo, es una gema en sí misma. Así que de aquí en adelante, solo me referiré a las bibliotecas de Ruby como gemas.

Lo que estamos construyendo

Si aún no has leído la publicación anterior (Cómo usar FilePond con Active Storage de Rails), te recomiendo que lo hagas ahora en una nueva pestaña. Te dará algo de contexto sobre lo que estamos construyendo aquí.

Brevemente, aquí hay un resumen:

  • Crear (usando el código de nuestra publicación anterior) una biblioteca de integración para la biblioteca JavaScript FilePond. Esto consistirá tanto en código Ruby para nuestro controlador personalizado como en código para cargar la biblioteca JavaScript.
  • Agregar pruebas para verificar nuestro controlador, la carga del código JavaScript, y pruebas del sistema para verificar la funcionalidad de nuestra integración.
  • Documentar nuestra gema para que otros puedan usarla.
  • Publicar la gema en RubyGems.org.

Cuándo crear una gema

Tener demasiadas dependencias en proyectos es una forma segura de hacer las cosas difíciles para ti a largo plazo. Afecta la mantenibilidad del código y hace que las actualizaciones (por ejemplo, para un proyecto Rails) sean más difíciles.

Dicho esto, a veces tiene sentido usar una gema. Aquí hay algunas razones:

Empaquetar código para reutilización

Si estás agregando el mismo código en múltiples proyectos — digamos, en una sola organización o empresa — y hay pocas o ninguna personalización para este conjunto particular de código, entonces empaquetarlos en una gema puede tener sentido. Alinea esta base de código compartida que facilita la actualización y mantenibilidad.

Escenarios típicos podrían ser:

  • Sistema de facturación
  • Elementos de UI
  • Código repetitivo de despliegue

El alcance de la funcionalidad es limitado

Un escenario que normalmente quieres evitar es crear una gema que agregue demasiada funcionalidad. Las gemas que tienen un alcance limitado son más fáciles de mantener, entender y usar.

Ejemplos de esto podrían ser:

  • Wrapper de API (por ejemplo, servicio externo, base de datos, etc.)
  • Helper de pruebas para simular llamadas de red
  • Métodos utilitarios específicos de función (por ejemplo, conversión de moneda)

Como todo lo demás en programación, siempre hay excepciones a las razones y ejemplos anteriores. Las preguntas habituales que me hago serían:

  • ¿Puede esta gema funcionar por sí sola, específicamente para un pequeño subconjunto de características? O,
  • ¿Esta gema agrega una funcionalidad — aunque acoplada (como con Ruby on Rails) — que es limitada en alcance y tiene una implementación relativamente estable?

Con eso fuera del camino, hablemos de algunas consideraciones generales cuando creas una gema de Ruby.

Consideraciones para la autoría de gemas

Cuando lanzas cualquier código que está destinado a ser consumido por otros, hay varias cosas en las que necesitas pensar. Para nuestra discusión, asumo que estás pensando en hacer de código abierto tu gema.

¿Cómo llamaré a mi gema?

Elegir un nombre es una preferencia personal. Sin embargo, si planeas publicar en RubyGems.org, aún querrás respetar el espacio de nombres de organizaciones e individuos que tienen una marca registrada existente.

Para mí, prefiero nombres descriptivos (es decir, aburridos). Al consultar con empresas, he visto algunos archivos Gemfile muy grandes. Muy a menudo, he visto una variedad de nombres de gemas donde he tenido que verificar manualmente cuál es su propósito como dependencia.

Para nuestra gema de integración FilePond, la llamaremos filepond-rails.

¿Qué licencia elegirás?

Hay muchos tipos de modelos de licenciamiento de código abierto. Te recomiendo que consultes la página de Licencias y Estándares de la Open Source Initiative para más detalles.

ProyectoLicencia
Ruby on RailsMIT
BundlerMIT
DeviseMIT
SidekiqLGPLv3
aws-sdk-coreApache
NokogiriMIT
FaradayMIT
PumaBSD 3-Clause

El tipo de licencia que elijas puede depender de tu filosofía sobre el código abierto, un objetivo comercial específico, o algo completamente diferente. Para la gema que estamos construyendo en este tutorial, usaremos la licencia MIT.

¿Qué versión(es) de Rails soportará tu gema?

Si estás creando una gema que podría usarse en cualquier programa Ruby, necesitarás considerar versiones específicas de Ruby también. Sin embargo, debido a que el contexto de lo que estamos construyendo es para Rails, nuestra consideración se centrará principalmente en la versión de Rails.

Al lanzar una gema al mundo, se espera que definas requisitos de dependencia en el archivo gemspec (que repasaremos en un segundo). Ten en cuenta que cuantas más versiones de Ruby y Rails quieras soportar, más trabajo puede ser asegurar compatibilidades hacia atrás y hacia adelante.

Si miras el código fuente de algunas gemas, verás código como este:

if Rails::VERSION::MAJOR < 7
  # Implementación para Rails antiguo
else
  # Implementación para Rails 7+
end

Para filepond-rails, soportaremos Rails 7 y superior.

Instrucciones para que otros desarrollen o contribuyan

Otra cosa a considerar es si quieres que otros bifurquen y contribuyan a tu proyecto. Para proyectos de código abierto, esto suele ser una buena idea ya que proporciona un camino para que otros desarrolladores ofrezcan pull requests y ayuden a corregir errores.

Para facilitar que otros se involucren en tu proyecto, normalmente me gusta agregar la configuración de Docker e instrucciones claras sobre cómo hacer que el proyecto funcione rápidamente.

Para filepond-rails, esto es lo que haré:

# Para ejecutar la gema a través de la aplicación "dummy", ejecuta:
docker compose up

# Para entrar al entorno de desarrollo donde puedes ejecutar bin/rails g controller y otros comandos:
docker compose run app bash

Creando el scaffolding de tu gema

Para crear nuestra gema Rails para filepond-rails, hay varias formas de hacerlo.

Probablemente la forma más fácil es seguir las instrucciones en Rails Guide: Getting Started with Engines. También hay varias plantillas iniciales en GitHub que puedes usar y son útiles para crear gemas genéricas (es decir, no específicas de Rails).

Para nuestro proyecto, usaremos la Guía de Rails así:

rails plugin new filepond-rails --mountable

El comando rails anterior viene directamente de https://guides.rubyonrails.org/engines.html#generating-an-engine.

Hemos especificado la opción --mountable para poder “montar” nuestro controlador personalizado (y sus rutas) en la aplicación host. (La aplicación host es la aplicación Rails que está usando nuestra gema.)

El generador crea varios archivos y carpetas para comenzar. Veamos estos archivos:

  • Directorio app y una estructura interna que se parece a tu aplicación Rails regular (por ejemplo, con assets, controllers, helpers, jobs, mailers, etc).
  • Directorio bin para mantener bin stubs.
  • Directorio config con un único archivo route.rb, que define las rutas montables para tu gema.
  • Directorio lib que normalmente contendrá la mayor parte de todo el código personalizado para tu gema. Dentro de este directorio actualmente, Rails ha creado una constante con namespace. Para nuestra gema filepond-rails, tenemos un namespace FilePond::Rails. También hay un directorio tasks para mantener tareas Rake específicas de la gema.
  • Directorio test para nuestras pruebas.
  • filepond-rails.gemspec para describir nuestra gema, qué hace, el autor, licencia, configuraciones del servidor de gemas y dependencias. Para más información sobre este archivo y las diversas configuraciones que pueden ir aquí, consulta la Guía de Especificación en RubyGems.org.
  • Gemfile especifica cualquier otra gema que necesites para tu entorno de desarrollo. Normalmente, para dependencias de desarrollo, uso la directiva add_development_dependency para el archivo gemspec.
  • MIT-LICENSE es la licencia predeterminada creada por el generador de Rails. Modifica o cambia esto según sea necesario para tus necesidades.
  • Rakefile carga las tareas Rake integradas de la gema, las tareas Rake de la aplicación dummy, y cualquier otra tarea Rake personalizada que hayas definido.
  • README.md debe contener una descripción de tu gema, instrucciones sobre el uso de la gema y otra información relevante.

Creando nuestro código

Esta sección describirá los diversos cambios que haremos a nuestro código scaffolded y convertiremos esos archivos y carpetas genéricos de marcador de posición en una biblioteca publicable. Comencemos:

1. Definir el gemspec

Nuestro primer paso debería ser personalizar el gemspec.

Notarás que la versión de la gema está definida así:

spec.version = Filepond::Rails::VERSION

El gemspec hace referencia dinámicamente a lo que definas como Filepond::RAILS::VERSION. Esto está definido en lib/filepond/rails/version.rb:

module Filepond
  module Rails
    VERSION = "0.1.0"
  end
end

Para una primera versión, normalmente lo dejo como 0.1.0. Sin embargo, una vez que hayamos completado la primera versión de la gema, lo aumentaré a 1.0.0 ya que representa la primera versión real.

Es una buena idea usar Versionado Semántico (SemVer) aquí. Si no estás familiarizado con esto, te recomiendo que eches un vistazo a https://semver.org/.

Esencialmente, las “versiones” que especificas en tu software deben corresponder a algún significado. Lo siguiente es cómo se describe en el sitio web de SemVer:

  • Versión MAYOR cuando haces cambios de API incompatibles
  • Versión MENOR cuando agregas funcionalidad de manera compatible hacia atrás
  • Versión PATCH cuando haces correcciones de errores compatibles hacia atrás

Nota: Rails mismo sigue un patrón SemVer en cómo lanza nuevas versiones.

Para el resto de los gemspecs, ya hay buenos comentarios en línea para ayudarte a comenzar. Para nuestra gema, puedes ver nuestro gemspec terminado aquí: https://github.com/Code-With-Rails/filepond-rails/blob/main/filepond-rails.gemspec.

2. Copiando nuestro código

Debido a que comenzamos con una base de código ya funcionando para nuestra integración con FilePond, podemos copiar nuestro código desde allí a nuestro directorio de proyecto scaffolded.

Si estás siguiendo algún tipo de flujo de trabajo de desarrollo guiado por pruebas (TDD), podrías querer comenzar agregando pruebas. Para mí, al menos cuando se trata de gemas Rails, generalmente viene de código preexistente que quiero extraer de otro proyecto, así que mi flujo de trabajo generalmente comienza copiando el código existente y luego agregando pruebas.

Desde nuestro código preexistente (que puedes ver en https://github.com/code-With-Rails/filepond-demo), copiaremos lo siguiente:

  • ingress_controller.rb que maneja funcionalidad específica relacionada con FilePond
  • routes.rb que define algunas rutas para nuestro controlador anterior
  • Referencias a FilePond
  • El código JavaScript para instanciar FilePond

Hablemos rápidamente de cada uno de estos archivos y cómo podríamos modificarlos para encajar con nuestra gema:

ingress_controller.rb

No hay mucho que necesitemos modificar aquí ya que el código existente ya funciona. Sin embargo, necesitaremos poner namespace a nuestra constante de IngressController a Filepond::Rails::IngressController. Esto es para que podamos mantener todo lo que pertenece a nuestra gema en su propio namespace y evitar posibles colisiones de nombres con la aplicación host.

Este archivo debería entonces colocarse en app/controllers/filepond/rails/ingress_controller.rb.

routes.rb

En nuestro código original, simplemente agregamos nuestras rutas como parte de la aplicación principal. Dentro de una gema, necesitaremos “montarla” en nuestra aplicación host así:

# config/routes.rb de nuestra aplicación host
Rails.application.routes.draw do
  mount Filepond::Rails::Engine, at: '/filepond'
  # Y otras rutas, etc.
end

Puedes consultar la Guía de Rails para más información sobre montaje de engines.

Dentro del código fuente de nuestra propia gema, sin embargo, necesitamos configurar qué rutas se montan. Modificamos nuestro config/routes.rb así:

Filepond::Rails::Engine.routes.draw do
  # Para detalles sobre los endpoints de FilePond, consulta https://pqina.nl/filepond/docs/api/server

  # https://pqina.nl/filepond/docs/api/server/#fetch
  post 'active_storage/fetch', to: 'ingress#fetch'

  # https://pqina.nl/filepond/docs/api/server/#remove
  delete 'active_storage/remove', to: 'ingress#remove'
end

Al comparar esto con nuestro código original, puedes ver que es esencialmente lo mismo. Hicimos una mejora aquí agregando active_storage para identificar que estas rutas personalizadas de FilePond están destinadas a usarse con ActiveStorage (que era la intención de nuestra integración). Nota que esta es una preferencia personal (mía).

El mensaje clave aquí es pensar claramente sobre cómo nombras tus rutas. Debido a que las rutas están (típicamente) montadas dentro de un sub-path de todos modos (recuerda la opción hash at dentro de mount Filepond::Rails::Engine, at: '/filepond'), las cosas estarán separadas apropiadamente. Sin embargo, aún querrías ser intencionalmente descriptivo en los nombres de los paths.

Consulta https://api.rubyonrails.org/classes/ActionDispatch/Routing/Mapper/Base.html#method-i-mount para detalles adicionales sobre el método mount.

Agregando referencias a FilePond

Este paso es específico de nuestra gema, pero aplicaría a cualquier gema que estés creando que dependa de una dependencia upstream (típicamente JavaScript).

Idealmente, podríamos minimizar incluir cualquier código fuente de FilePond en nuestra gema y simplemente decirle al usuario final que lo agregue ellos mismos. Si es posible, podemos evitar agregar cualquier código en absoluto.

Para nuestra situación específica, usaremos importmap que nos permite hacer referencia a una versión específica de FilePond sin agregarla al código fuente de nuestra gema. Desafortunadamente, importmap-rails no soporta hacer referencia a archivos CSS.

Para facilitar las cosas a los usuarios de nuestra gema, agregaremos estos archivos CSS a nuestros assets para que puedan incluirse más fácilmente. Específicamente, agregamos filepond.css y filepond.min.css a la carpeta app/assets/stylesheets.

También agregamos instrucciones a nuestro README.md para que los usuarios sepan que necesitan hacer esto.

A continuación, también necesitamos asegurar que la biblioteca JavaScript upstream de FilePond se cargue. Similar a nuestra base de código original, agregamos un archivo importmap.rb al directorio config y lo modificamos así:

# config/importmap.rb
pin 'filepond', to: 'https://ga.jspm.io/npm:filepond@4.30.4/dist/filepond.js', preload: true

Esto “bloqueará” nuestra versión de la gema a FilePond v4.30.4. Cuando FilePond se actualice, necesitaremos aumentar esto para soportar la nueva versión.

Similar a los assets CSS, agregamos una nota en el README.md para instruir a los usuarios que agreguen javascript_importmap_tags a su archivo de layout application.html.erb para que las definiciones en importmap.rb se carguen al renderizar.

Agregando nuestro código JavaScript

Finalmente, necesitamos agregar nuestro código JavaScript para vincular todo. En nuestra base de código original, colocamos todo este código aquí: https://github.com/Code-With-Rails/filepond-demo/blob/main/app/javascript/application.js.

Si bien el código anterior puede funcionar para nuestra aplicación, no es apropiado cuando estamos creando una biblioteca.

Para esto, soportaremos el uso de módulos ESM. Modificamos nuestro código JavaScript para exportar funciones para que los usuarios de nuestra gema puedan importarlas:

// En el archivo JavaScript de la aplicación host...
import { FilePondRails, FilePond } from 'filepond-rails'

window.FilePond = FilePond
window.FilePondRails = FilePondRails

const input = document.querySelector('.filepond')
FilePondRails.create(input)

Nuestro objetivo aquí es hacer lo más fácil posible para los usuarios integrar nuestra biblioteca. Recuerda que nuestra audiencia de desarrolladores objetivo probablemente serán desarrolladores de Rails usando JavaScript vanilla o Stimulus.

Nota: Recuerda anteriormente que mencioné que como autor de una gema necesitas considerar qué dependencias de software requerirá tu biblioteca.

Para filepond-rails, he declarado que los desarrolladores que quieran usar esta biblioteca deben estar en Rails 7+ y usar importmap-rails. Debido a que importmap-rails depende del uso de módulos ESM, esto es lo que filepond-rails soportará en términos de carga de JavaScript.

Probando usando la aplicación Dummy

En este punto, podrías estar preguntándote: ¿Funciona todo esto?

Esa es una buena pregunta y una que es muy fácil de responder bootstrapping una aplicación Rails y siguiendo nuestras instrucciones de configuración. Dentro del código fuente de nuestra gema, hay una aplicación “Dummy” con la que podemos hacer exactamente eso.

Para el producto terminado, puedes ver el código fuente aquí: https://github.com/Code-With-Rails/filepond-rails/tree/main/test/dummy.

Dentro de esta aplicación, creamos un controlador, vista y JavaScript para instanciar el JavaScript personalizado de nuestra gema.

Una vez que hacemos todo esto, podemos iniciar nuestro servidor con el siguiente comando:

bin/rails server

Deberías poder ir a http://localhost:3000 para ver tu trabajo.

Nota: Consulta filepond-rails en https://github.com/Code-With-Rails/filepond-rails si quieres probar esto tú mismo.

Pregunta: En lugar de usar la aplicación Dummy, ¿cómo pruebo mi gema en mi aplicación host que estoy desarrollando al mismo tiempo?

Respuesta: Para poder hacer esto, puedes hacer referencia a tu gema localmente.

# Gemfile de la aplicación host
gem 'filepond-rails', path: '/path/to/filepond-rails-local'

Recuerda ejecutar bundle install y bundle update si actualizas el código dentro del código fuente de la gema.

Agregando pruebas

Para asegurar que nuestra gema funcione y que las futuras actualizaciones no rompan la funcionalidad, necesitaremos agregar algunas pruebas.

Para filepond-rails, nos quedaremos con minitest. Sin embargo, puedes elegir cualquier framework (por ejemplo, rspec) si lo prefieres.

Debido a que nuestra aplicación consiste en dos partes (nuestro IngressController y código de carga de JavaScript), estos serán los que probaremos.

La prueba del controlador es bastante simple y prueba estándar de controladores de Rails: https://github.com/Code-With-Rails/filepond-rails/blob/main/test/controllers/filepond/rails/ingress_controller_test.rb.

A continuación, agregamos pruebas del sistema. Las pruebas del sistema, según la Guía de Rails:

Las pruebas del sistema te permiten probar las interacciones del usuario con tu aplicación, ejecutando pruebas en un navegador real o headless. Las pruebas del sistema usan Capybara bajo el capó.

Para una gema que mejora la interfaz de usuario como filepond-rails, queremos asegurar que nuestro código fuente JavaScript se cargue y funcione correctamente.

Para crear una prueba del sistema, escribimos:

bin/rails generate system_test uploads

Esto generará el correspondiente test/system/filepond/rails/uploads_test.rb. Esta prueba, que se ejecuta escribiendo bin/rails app:test:system en la línea de comandos (ya que no se ejecuta por defecto con el resto de las pruebas), probará cosas en un navegador headless.

Publicando nuestra gema

Finalmente, hemos llegado a la etapa en la que podemos publicar nuestra gema. Antes de hacerlo, creemos un archivo CHANGELOG. El registro de cambios debe contener todos los cambios mayores, menores y de ruptura que hayas hecho. Normalmente también incluyo la fecha de lanzamiento.

A continuación, recomiendo que etiquetes las versiones de lanzamiento tal como lo harías para cualquier otro proyecto de software.

Ejemplo:

git tag v1.0.0

Una vez que hayas subido eso a GitHub, crea un release con una buena descripción.

Para publicar tu gema en RubyGems.org donde puede ser accedida por otros usuarios, sigue las instrucciones en https://guides.rubygems.org/publishing/.

Primero necesitarás construir tu gema así:

gem build filepond-rails

Esto creará un archivo como este: filepond-rails-1.0.0.gem.

Una vez que tengas eso, puedes hacer esto:

gem publish filepond-rails-1.0.0.gem

Nota: Necesitarás una cuenta en RubyGems.org y necesitarás autenticar tu CLI de gem para poder subir cambios públicamente.

Documentación

filepond-rails es una gema simple y pequeña, y por lo tanto un sitio web de proyecto completo no está justificado. Para gemas más complicadas con una API pública grande, la documentación apropiada debe estar presente.

Consejo: Consulta https://rubydoc.info/ y agrega tu gema allí. Es una herramienta útil para ayudar a generar documentación a partir de los comentarios de tu código y hacerla accesible públicamente para usuarios desarrolladores.

Ideas para mejoras

Como con cualquier proyecto, siempre hay espacio para mejoras. Esta publicación del blog solo rasca la superficie y proporciona los pasos mínimos para crear un proyecto de gema que tú y otros puedan usar en múltiples aplicaciones Rails.

Otras cosas que se pueden hacer (sin orden particular):

  • Usar Appraisal para probar contra múltiples versiones de Rails
  • Agregar CI de GitHub Action (no cubierto aquí, pero puedes consultar el repo de filepond-rails para ver cómo lo hicimos allí)
  • Agregar un pipeline de publicación a RubyGems.org (para que podamos saltarnos la publicación manual de la gema por completo)

Conclusión

Crear una gema de código abierto para que otros la usen puede ser una experiencia satisfactoria, tanto técnica como profesionalmente.

A nivel profesional, es una excelente manera de devolver a la comunidad y ayudar a otros que podrían estar enfrentando la misma situación que tú enfrentaste.

Técnicamente, la creación de bibliotecas también requiere que pienses cuidadosamente sobre el problema que quieres resolver y que pienses bien tu solución para que minimice problemas y no cause problemas a los usuarios finales. Por estas razones, normalmente me gusta mantener las bibliotecas de gemas pequeñas en tamaño con un enfoque estrecho y quirúrgico.

Espero que hayas encontrado útil este tutorial. Nuevamente, el código fuente de la gema filepond-rails se puede encontrar en https://github.com/Code-With-Rails/filepond-rails. Para la gema en sí, consulta https://rubygems.org/gems/filepond-rails.