#350 REST API Versioning
- Download:
- source codeProject Files in Zip (95 KB)
- mp4Full Size H.264 Video (32.3 MB)
- m4vSmaller H.264 Video (14.5 MB)
- webmFull Size VP8 Video (12.1 MB)
- ogvFull Size Theora Video (36.8 MB)
Supongamos que tenemos una aplicación que gestiona productos, con la que podemos ver un listado de elementos y su ficha de detalle, así como crear, editar y borrar productos.
Queremos proporcionar una API REST de forma que podamos gestionar productos sin la interfaz HTML. El controlador ProductsController
ya es REST, por lo que simplemente tenemos que añadir un bloque respond_to
a cada acción de forma que responda ante peticiones JSON, de esta manera:
def index @products = Product.all respond_to do |format| format.html format.json { render json: @products } end end
Si ahora visitamos la página /products.json
veremos el listado de productos en JSON.
¿Quiere decir que ya tenemos hecha una API en JSON? Bueno, hay un serio problema con este enfoque, y es el versionado. Es importante que las APIs sean consistentes. Por ejemplo cada producto tiene un atributo llamado released_on
que devuelve la fecha en la que se publicó el producto. Si queremos devolver la hora y cambiarle el nombre a la columna por released_at
sí que podríamos hacerlo, pero cualquier aplicación que esté usando la API quedaría rota, especialmente si utiliza el atributo released_on
.
Si queremos mantener una API consistente con las diferentes versiones no es buena idea mantener la API JSON junto con la interfaz HTML, y hay algunas gemas que sirven para subsanar este problema. Versionist hace fácil mantener versionadas las APIs mediante las rutas de la aplicación, e incluye varios generadores. RocketPants es otra gema que ayuda a construir APIs REST, incluyendo versionado. Merece la pena leer su README.
En este episodio no usaremos una gema porque escribiendo la API desde cero nos haremos una mejor idea de cómo funcionan las rutas en Rails y cómo se pueden aplicar al versionado de APIs. Ahora las rutas de la aplicación tienen el aspecto:
Store::Application.routes.draw do resources :products root to: 'products#index' end
Añadiremos algunas rutas específicas para la API de forma que estas rutas queden separadas de las rutas dedicadas a la interfaz HTML. Para esto utilizaremos un espacio de nombres llamado api
, lo que quiere decir que todas las rutas que definamos sobre él vendrán con el prefijo /api
. También podríamos añadir un subdominio en lugar de usar el prefijo api/
pero con este enfoque nos bastará. También tenemos que decidir cómo hacer el versionado. Una opción es guardar la versión como parte de la URL, lo que se puede hacer con otra llamada a namespace
. Así que por ahora pondremos el recurso products
debajo de este espacio de nombres para servir nuestros productos de forma REST.
Store::Application.routes.draw do namespace :api do namespace :v1 do resources :products end end resources :products root to: 'products#index' end
En el directorio /app/controllers
tendremos que crear un directorio api
con un subdirectorio v1
. En este directorio crearemos un controlador que llamaremos ProductsController
.
module Api module V1 class ProductsController < ApplicationController end end end
La clase hereda de ApplicationController
aunque podríamos hacerlo heredar de, por ejemplo, un Api::BaseController
si quisiéramos tener algún tipo de comportamiento compartido por todos los controladores de API de la aplicación. Ya podemos empezar a añadir acciones a este controlador.
module Api module V1 class ProductsController < ApplicationController respond_to :json def index respond_with Product.all end def show respond_with Product.find(params[:id]) end def create respond_with Product.create(params[:product]) end def update respond_with Product.update(params[:id], params[:products]) end def destroy respond_with Product.destroy(params[:id]) end end end end
Aquí hemos definido cada acción utilizando una llamada a respond_with
de forma que devuelva formato JSON. Se trata de una técnica muy simple y probablemente queramos hacer más cosas en una API de verdad. Cuando visitemos /api/v1/products.json
ahora veremos nuestra API.
La URL requiere que se especifique el formato JSON, de forma que si se quita la extensión .json
no recibiremos ninguna respuesta. Para que JSON sea el formato por defecto utilizaremos la opción defaults
en las rutas:
Store::Application.routes.draw do namespace :api, defaults: {format: 'json'} do namespace :v1 do resources :products end end resources :products root to: 'products#index' end
Ahora recibiremos datos en JSON cuando visitemos http://localhost:3000/api/v1/products
.
Una nueva versión de la API
Por ahora todo va bien, pero ¿qué tenemos que hacer cuando hagamos un cambio que rompa la compatibilidad de la API? Supongamos que queremos cambiar la columna released_on
y llamarla released_at
. Para demostrarlo, generaremos una migración que cambiará el tipo y el nombre de esta columna.
$ rails g migration change_products_released_on
El código de la migración tiene el siguiente aspecto.
class ChangeProductsReleasedOn < ActiveRecord::Migration def up rename_column :products, :released_on, :released_at change_column :products, :released_at, :datetime end def down change_column :products, :released_at, :date rename_column :products, :released_at, :released_on end end
Al lanzar rake db:migrate
se cambiará la base de datos,y al recargar la página de la API en el navegador veremos que la columna released_on
ya no aparece y ha sido sustituida por released_at
. No es un cambio compatible hacia atrás.
Deberíamos tener montado un sistema automático de forma que nos avise cuando rompamos la API de esta manera, así podremos corregir la versión anterior de la API antes de crear la nueva. Es un poco difícil porque usamos respond_with
en el controlador. La forma más fácil sería utilizar algo como RABL, como vimos en el episodio 322, con lo que tendríamos mayor control sobre los atributos que se devuelven en la respuesta JSON.
Para una API tan sencilla como la nuestra saldreamos del paso con un hack rápido. Puede ser un enfoque adecuado, especialmente si la API dispone de buena cobertura de tests. Lo que haremos será crear una nueva clase Product
en ProductsController
que hereda de la clase Product
del modelo, y es en la que haremos los cambios. Por tanto las referencias a Product
en el controlador utilizarán la nueva subclase en lugar de la original. Podremos sobrecargar los métodos en esta nueva clase de forma que se comporten de manera diferente para una versión concreta de la API. Tendremos que reescribir to_json
para que podamos poner de nuevo el atributo released_on
en la respuesta.
module Api module V1 class ProductsController < ApplicationController class Product < ::Product def as_json(options={}) super.merge(released_on: released_at.to_date) end end respond_to :json # Actions omitted end end end
Si recargamos la versión 1 de la API veremos que vuelve a funcionar, mostrando el atributo released_on
.
Los tests de nuestra aplicación deberían haber vuelto al verde. Tenemos un atributo released_at
en cada producto, pero se puede ver como una ventaja porque con esto facilitaremos la transición de aquellos que estén usando la API antigua. Técnicamente, en este punto no tenemos que publicar una nueva versión de la API, pero si tenemos suficientes cambios de este tipo tendremos que hacerlo.
Para generar una nueva versión de la API tan sólo tenemos que copiar el código de app/controllers/api/v1
al directorio v2
.
$ cp -R app/controllers/api/v1 app/controllers/api/v2
En el fichero de rutas podemos copiar las rutas de v1
en un nuevo espacio de nombres llamado v2
.
Store::Application.routes.draw do namespace :api, defaults: {format: 'json'} do namespace :v1 do resources :products end namespace :v2 do resources :products end end resources :products root to: 'products#index' end
Si hay mucha duplicidad en la rutas podríamos crear una función lambda y pasarla en forma de bloque a cada una de las versiones pero esto sólo sirve si las rutas son las mismas entre una versión y otra. En nuestro nuevo ProductsController
de la versión v2
podremos eliminar los hacks que hemos puesto en la versión v1
y cambiar el nombre del módulo.
module Api module V2 class ProductsController < ApplicationController respond_to :json def index respond_with Product.all end def show respond_with Product.find(params[:id]) end def create respond_with Product.create(params[:product]) end def update respond_with Product.update(params[:id], params[:products]) end def destroy respond_with Product.destroy(params[:id]) end end end end
Si ahora visitamos la versión 2 de la API veremos la salida más limpia sin el atributo released_on
que ya es a extinguir.
Puede parecernos que cuando implementamos el versionado de la API de esta manera estamos incurriendo en demasiada duplicación. En la versión 2 ProductsController
tiene casi el mismo aspecto que la versión 1. Pero no estamos vulnerando el principio DRY. Es importante preguntarse si tendremos que cambiar el código de una versión si lo cambiamos en la otra, lo que no es nuestro caso porque es difícil que volvamos a una versión anterior para tengamos que implementar alguna funcionalidad de una versión más nueva. Es más probable que tengamos código diferente en la versión antigua para que hacer que el modelo de datos sea compatible hacia atrás. También es posible que en todo caso eliminaremos las versiones más antiguas en algún punto, por lo que embarcarse en una refactorización puede resultar excesivo. Por supuesto, si pensamos que es necesario, podemos escribir una superclase diferente donde se guarde el comportamiento compartido entre que utilizarán ambas versiones de la API.
Números de versión
A continuación veremos cómo se especifica el número de versión. Ahora mismo se encuentra en la ruta de la URL, lo cual es muy sencillo y directo pero que algunos no consideran que sea la mejor práctica. Github, por ejemplo, hizo un cambio de forma que en lugar de incluir la versión en la ruta de la URL, ésta se pasa en una cabecera Accept
. ¿Cómo hacer esto en nuestra aplicación?
Primero cambiaremos los espacios de nombres en nuestras rutas con una llamada a scope
, de forma que podamos especificar el módulo a utilizar, así como las restricciones. La lógica de restricciones puede ser bastante complicada por lo que la movemos a otra clase.
Store::Application.routes.draw do namespace :api, defaults: {format: 'json'} do scope module: :v1, constraints: ApiConstraints.new(version: 1) do resources :products end scope module: :v2, constraints: ApiConstraints.new(version: 2, default: :true) do resources :products end end resources :products root to: 'products#index' end
Nótese que hemos añadido una opción extra en las condiciones de la versión v2
para indicar que esta es la versión por defecto. Todavía tenemos que definir una clase ApiConstraints
para usar en el fichero de rutas, y la definiremos en el directorio /lib
.
class ApiConstraints def initialize(options) @version = options[:version] @default = options[:default] end def matches?(req) @default || req.headers['Accept'].include?("application/vnd.example.v#{@version}") end end
La clase es muy sencilla, en initialize
se extraen las opciones como variables de instancia. También se añde el método matches?
que es el que activará la ruta para comprobar la restricción, comprobando además si se trata de la versión por defecto o si el encabezado Accept
de la petición HTTP coincide con la versión de API que se ha inicializado en las opciones. La cadena puede ser cualquier cosa, con tal de que coincida con el encabezado deseado.
Por comodidad, iremos a la parte superior del fichero de rutas y haremos el require
de esta clase ahí.
require 'api_constraints' Store::Application.routes.draw do namespace :api, defaults: {format: 'json'} do scope module: :v1, constraints: ApiConstraints.new(version: 1) do resources :products end scope module: :v2, constraints: ApiConstraints.new(version: 2, default: :true) do resources :products end end resources :products root to: 'products#index' end
Tras reiniciar el servidor si visitamos la ruta /api/products
recibiremos la versión por defecto de la API, o sea la versión 2. Podemos utilizar curl
en el terminal para especificar el encabezamiento Accept
para usar una versión diferente.
$ curl -H 'Accept: application/vnd.example.v1' http://localhost:3000/api/products
Esto devuelve una respuesta con productos que tienen el atributo released_on
, lo que coincide con la versión 1 de la API.