La gracia de una API es que se mantenga integrada en el tiempo. Es muy importante que tenga una interfaz consistente y natural para los usuarios que se integran. Por esto, es muy difícil hacer cambios a su interfaz sin romper las integraciones.

Sin embargo, a medida que una API crece junto con el equipo que la desarrolla, es inevitable que haya cambios. Un clásico ejemplo es tener que transformar un campo booleano a un enum. Esto pasa cuando la respuesta de la API se ve así:

{
  ...,
  "confirmed": true,  // true|false
  ...
}

Y debe ser transformada para verse así:

{
  ...,
  "status": "confirmed",  // unconfirmed|confirmed|denied
  ...
}

En el código del usuario de la API, quizás se estaba operando con el campo confirmed, pero ahora este campo ya no existe y su aplicación dejó de funcionar. Pánico.

confirmed = data.fetch(:confirmed)

# => KeyError (key not found: :confirmed)

Este tipo de cambios se deben evitar a toda costa. Pero muchas veces hacer cambios es necesario, sólo que no queremos romper internet como npm el 2016. Entonces hay que decidir entre no hacer cambios (y retrasar el avance) o hacer los cambios y romper las integraciones de los clientes existentes.

Por suerte, existe la tercera opción de hacer cambios retrocompatibles usando esquemas de versionamiento.

Esquemas de versionamiento para APIs

El esquema de versionamiento para APIs más común es el de agregar un /v1 al inicio del path de la API. Cuando se quiere hacer un breaking change, se cambia a /v2, y así hasta /v430044500 y al infinito.

Como hay que mantener todo el código desde la versión /v1 hasta la versión /v430044500 (o perder a los usuarios que no migran a la última versión), hacer estos cambios generalmente se pospone hasta que la mayor parte de la interfaz de la API debe cambiar.

Para mí, es como cuando uno tiene que escribir un informe y termina con esta belleza: "Informe-final_(7)-arreglado(2).docx.pdf". El archivo "Informe-final_(5).docx" quedó desactualizado, junto con toda la gente que lo estaba leyendo. Porque seamos honestos: ¿quién quiere mantener al día 15 archivos distintos? y ¿por qué sería distinto con las versiones de una API?

Póngame el 1 nomás profe

Además, la versión /v3 deja de mejorarse mucho antes de que salga la versión /v4. Un día llega el líder del equipo y dictamina que es momento de implementar los 284 cambios a la API que habían estado acumulando, y hay que sacarlos lo antes posible. Por 2 meses, solamente se trabaja en implementar /v4, y tus usuarios de /v3 quedaron en el olvido.

Resulta que este problema es bastante común. Afortunadamente, nuestros amigos de Stripe le regalaron al mundo una idea para solucionarlo. Y el concepto es bastante elegante 🎩.

Versionando como los usuarios merecen

La idea general es simple: a cada organización se le asocia una versión con la forma 2022-01-27 el día que hacen su primera request a la API. De ahí en adelante, esa es la versión de su API. Cada llamada a la API también puede sobreescribir su versión por default usando el header Fintoc-Version para enviar una versión distinta.

Cada vez que se hace un cambio no retrocompatible se genera una nueva versión con la fecha en que dicho cambio sale a producción. La idea es que estos cambios sean pequeños, de manera que actualizar la versión de la API sea extremadamente simple para los usuarios, en vez de tener que re-integrarse a la API cambiada por completo.

Versionando como los devs merecen

El principal problema de versionar es que hay que mantener código extra, potencialmente MUCHO código extra. Para reducir esto, la estrategia es alejar las versiones de nuestros code paths y escribir el código como si solamente soportáramos la versión más nueva.

Con cada versión escribimos un pequeño módulo de código, que encapsula el cambio desde la versión pasada hasta la nueva. Este módulo define cómo transformar la request desde la versión anterior hasta la actual y cómo transformar la respuesta desde la versión actual hasta la anterior. Estos módulos se ven así:

class ChangeMetadataAndTokenNames < Versioning::VersionChange
  description \
    "Change the :data field name of the request " \
    "body to :metadata, change the :token field " \
    "name of the request params to :auth_token " \
    "and change the :data field name of the response " \
    "body to :metadata."

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

    params do |params|
      auth_token = params[:token]
      params.except(:token).merge(auth_token: auth_token)
    end
  end

  response MySerializer do
    body do |data|
      metadata = data[:metadata]
      data.except(:metadata).merge(data: metadata)
    end
  end
end

El ejemplo de arriba no es ideal (se cambian muchas cosas no relacionadas en un mismo módulo), pero ilustra la interfaz que los desarrolladores tienen para hacer cambios. Los módulos son expresivos y simples de escribir, y mantienen los cambios fuera de nuestro código core, permitiéndonos hacer cambios no retrocompatibles pero mantener el soporte a versiones antiguas a un costo fijo.

Todos hemos pasado por aquí

Cuando una versión es creada, debe agregarse a una lista con todas las versiones:

Versioning::VersionChanges.define do
  {
    '2022-01-27' => [
      VersionChanges::ChangeMetadataAndTokenNames,
      VersionChanges::ArbitraryChangeOnTheRequest
    ],
    '2021-08-08' => [
      VersionChanges::SomeOtherChange
    ],
    '2021-02-09' => [
      VersionChanges::FirstChange
    ]
  }
end

Esta lista le permite a la aplicación ordenar las versiones a aplicar antes y después procesar la request.

En el ejemplo anterior, si un usuario hiciera una request usando la versión 2021-02-09, se ejecutarían los cambios a la request de VersionChanges::SomeOtherChange primero, luego los cambios a la request de VersionChanges::ArbitraryChangeOnTheRequest y finalmente los cambios a la request de VersionChanges::ChangeMetadataAndTokenNames.

Ahora, el controlador tiene la request en el formato más nuevo y puede ejecutar el código de la acción. Al finalizar, la respuesta ahora es procesada primero por VersionChanges::ChangeMetadataAndTokenNames, luego por VersionChanges::ArbitraryChangeOnTheRequest y finalmente por VersionChanges::SomeOtherChange, para ser devuelta al usuario en el formato de la versión 2021-02-09.

Así combatimos versionamos nuestros demonios en Fintoc, para no traspasárselos a nuestros usuarios.

Escribí la segunda parte de este blog, con detalles de implementación. Puedes leerla acá:

De mágico a versátil: Ruby y el backstage de nuestro versionamiento
Cómo escribí nuestro módulo de versionamiento aprovechándome de la magia de Ruby.

¿Te gusta construir herramientas para otros desarrolladores?

Postula a Fintoc aquí: https://fintoc.com/jobs