Sinatra como servidor rest, tutorial

Hagamos un ejemplo simple de cómo implementar las operaciones CRUD con un servidor basado en Sinatra con la forma REST como API. No vamos aquí adentrarnos en discusiones teóricas acerca de si REST+CRUD es o no una buena idea, pragmáticamente hablando es una idea muy útil y funcional y que concentra los esfuerzos del programador en profundizar acerca de la programación Web (HTTP).

Entonces lo que queremo es (requerimientos):

  1. Definir un modelo/estructura de datos con la cual trabajar.
  2. Definir sus operaciones CRUD
  3. Mapear dichas operaciones a los verbos HTTP:
    • POST - Create
    • GET - Retrieve
    • PUT - Update
    • DELETE - Delete

Trabajemos con AR

Para detalles de cómo trabajar con Active Record sin Rails, mire en ActiveRecord sin Rails.

 1require 'rubygems' if RUBY_VERSION < '1.9'
 2require 'sinatra'
 3require 'sqlite3'
 4require 'active_record'
 5Dir["models/*rb"].each { |f| require f }
 6
 7ActiveRecord::Base.establish_connection(
 8     :adapter => "sqlite3",
 9     :database  => "db/db.sqlite3" 
10)
11
12get '/xytlines/1' do
13  # Recuperemos de la tabla xytlines el primer registro 
14  # y devolvamos algo para empezar
15  Xytline.first.x
16end 

Corrida y primera salida

Lado servidor

$ ruby modelo-simple-con-ar.rb 
== Sinatra/1.2.6 has taken the stage on 4567 for development with backup from Mongrel
127.0.0.1 - - [14/Dec/2011 11:37:12] "GET /xytlines/1 HTTP/1.0" 200 19 0.0171
...

Lado cliente

$ curl http://localhost:4567/xytlines/1
Xytline.first.x = 1

Recuperación del objeto

Primero debemos ajustar nuestras rutas para que se acomoden a nuestro esquema:

1get %r{/xytlines/([\d]+)} do
2  # Ahora con el regex arriba, podemos hacer la búsqueda en la BD
3  # y devolver el x del registro encontrado
4  "Xytline.find(#{id}).x = #{Xytline.find(id).x}" 
5end

Con el regex especificado entonces sólo servimos las solicitudes GET que especifiquen algún número entero para la colección de Xytlines en nuestra base de datos, miremos nuestra nueva salida, reiniciando el lado servidor por supuesto:

$ curl http://localhost:4567/xytlines/1
Xytline.find(1).x = 1

¿Qué pasa si pedimos un registro inexistente?

Atrapemos el error de una forma simple, así:

1get %r{/xytlines/([\d]+)} do |id|
2  begin
3    "Xytline.find(#{id}).x = #{Xytline.find(id).x}" 
4  rescue
5    "No existe Xytline(#{id})" 
6  end
7end 

Ejemplo de salida de registro inexistente:

$ curl http://localhost:4567/xytlines/2
No existe Xytline(2)

Eliminando un objeto

En resumen, ya sabemos obtener un objeto de la colección Xytlines, es decir hemos cubierto la R(etrieve) del CRUD (básicamente) con el GET, ahora toca ir al otro extremo de la ecuación, eliminemos un registro (ajustaremos los formatos de salida luego).

Veamos el código, se muestra solamente lo relevante.

1delete %r{/xytlines/([\d]+)} do |id|
2  begin
3    Xytline.destroy("#{id}")
4    "Xytline(#{id}) eliminado" 
5  rescue
6    "No existe el Xytline(#{id})" 
7  end
8end

$ curl -X DELETE http://localhost:4567/xytlines/2
Xytline(2) eliminado
$ curl -X DELETE http://localhost:4567/xytlines/2 
No existe el Xytline(2)

Representación de datos

Hasta ahora hemos visto que es muy fácil obtener un objeto y eliminarlo, ahora toca lidiar con los dos aspectos más complicados, la creación de un objeto nuevo, y la actualización de dichos objetos en la base de datos. En ambos casos debemos pasar datos al lado servidor desde el cliente, entonces toca definir la representación de los datos del objeto para que estos puedan viajar desde el cliente hasta el servidor y en consecuencia se puedan entender.

De mis preferencias tengo JSON y YAML ya que con ellos se simplifica mucho el pase de datos entre ambos extremos, entonces vamos a querer representar nuestros objetos en formatos JSON y/o YAML y a usar los métodos HTTP POST y HTTP PUT para pasar dichos datos.

Primero recordemos como lidiar con el POST por ejemplo:

1post %r{/xytlines/([\d]+)} do |id|
2  # los datos vienen en el arreglo params
3  "Usted dijo '#{params[:mensaje]}'"        
4end
$ curl -X POST -d "mensaje=soy_tu_amo" http://localhost:4567/xytlines/1
Usted dijo 'soy_tu_amo'

Bien, como lo que nos interesa es tener un servicio tipo RESTfull API, no nos vamos a complicar en la gestión de formulario Web/HTML ni por el estilo, sino a concentrarnos en representaciones JSON y YAML. Le será terriblemente fácil generar y manipular dichas representaciones con por ejemplo con curl, y desarrollar su servicio mucho más rápido.

Vamos a volver a visitar el método GET para primero que nada, obtener una representación JSON para poder visualizar su simplicidad.

Xytline es un modelo ActiveRecord que simplemente tiene cuatro valores enteros.

 1class XytlinesMigration < ActiveRecord::Migration
 2  def self.up
 3    create_table :xytlines do |t|
 4      t.integer :x
 5      t.integer :y
 6      t.integer :t
 7      t.integer :q
 8    end
 9  end
10
11  def self.down
12     drop_table :xytlines
13  end
14end

Si el JSON o YAML correspondiente ha de representar este objeto, lo más simple que se me ocurre por ahora es representarlo como un Hash:

1Xtyline = { :x => x, :y=> y, :t => t , :q => q}

Veamos cómo nos ayuda Ruby YAML al respecto:

1>> require 'yaml'
2=> true
3>> xytline = { :x => 1, :y => 2 , :t => 3 , :q => 100 }
4=> {:x=>1, :t=>3, :y=>2, :q=>100}
5>> xytline.to_yaml
6=> "--- \n:x: 1\n:t: 3\n:y: 2\n:q: 100\n" 

Lo importante de notar aquí es que esto es una representación simple en texto que nos permite comunicar los datos entre ambos extremos o serializar a un archivo y luego recuperar dichos datos y reconstruir el Hash. Hagamos entonces el ejemplo.

Creando un objeto enviando un YAML

$ curl -X POST -F "yaml=<hash.yml;type=text/yaml" http://localhost:4567/xytlines/new 
Se creado exitosamente un nuevo registro xytline(10)

donde:

$ cat hash.yml 
--- 
:x: 1
:t: 3
:y: 2
:q: 200

Esto entonces simula un formulario rellenado por el usuario y enviado al servidor como text/yaml, estamos digamos literalmente subiendo el archivo yaml al servidor. Veamos el código:

 1# CREATE xytline/new
 2require 'yaml'
 3post %r{/xytlines/new} do 
 4  datos = YAML.load(params[:yaml])
 5  begin
 6    xytlines = Xytline.create([datos])
 7    "Se creado exitosamente un nuevo registro xytline(#{xytlines.first.id})" 
 8  rescue
 9    "No se ha podido crear un nuevo registro xytline" 
10  end
11end

Salida en el servidor:

$ ruby modelo-simple-con-ar.rb 
== Sinatra/1.2.6 has taken the stage on 4567 for development with backup from Mongrel
127.0.0.1 - - [14/Dec/2011 18:20:47] "POST /xytlines/new HTTP/1.1" 200 52 0.1140
...

Actualizando un objeto enviando un YAML

La técnica es idéntica excepción que se opera sobre un registro existente, es decir, el YAML debe representar un registro existente. Un nuestro caso simple el indice lo tomamos del mismo URL.

Lado cliente

$ curl -X PUT -F "yaml=<hash.yml;type=text/yaml" http://localhost:4567/xytlines/1
Se ha modificado exitosamente el registro xytline(1)

Lado servidor

$ ruby modelo-simple-con-ar.rb 
== Sinatra/1.2.6 has taken the stage on 4567 for development with backup from Mongrel
127.0.0.1 - - [14/Dec/2011 18:32:55] "PUT /xytlines/1 HTTP/1.1" 200 52 0.1076
...

Código servidor

 1# PUT xytlines/:id
 2put %r{/xytlines/([\d]+)} do |id|  
 3  begin
 4    xytline = Xytline.find("#{id}")
 5  rescue
 6    "No existe dicho xytline(#{id})" 
 7  end
 8
 9  cambios = YAML.load(params[:yaml])
10
11  begin
12    Xytline.update(id,cambios)
13    "Se ha modificado exitosamente el registro xytline(#{id})" 
14  rescue
15    "No se ha podido actualizar el registro xytline(#{id})" 
16  end
17end

Especificando el tipo

El uso de la opción -H nos permite agregar encabezados arbitrarios permitiendo así especificar el tipo de datos requeridos/aceptados por el cliente.

$ curl -H "Accept: text/yaml" http://localhost:4567/xytlines/7

Referencias: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14

Ahora, para lidiar con el tipo y escoger el tipo de salida, podemos usar el objeto request:

 1# RETRIEVE xytline/:id
 2get %r{/xytlines/([\d]+)} do |id|
 3  # request.accept es Array
 4  begin
 5    if request.accept.include?("text/yaml")
 6      result = Xytline.find("#{id}").to_yaml
 7    else 
 8      # No es realmente correcto, deberíamos responder
 9      # que no manejamos ese tipo de datos
10      raise Sinatra::NotFound
11    end
12
13  rescue
14      raise Sinatra::NotFound
15  end
16
17  result
18end

Depurando tipos

Volviendo sobre una versión algo más completa del GET:

 1# RETRIEVE xytline/:id
 2get %r{/xytlines/([\d]+)} do |id|
 3  # request.accept es Array
 4  request.accept.each {|rh| puts rh}
 5  begin
 6    case 
 7    when request.accept.include?("text/yaml")
 8      # Salida yaml
 9      result = Xytline.find("#{id}").to_yaml
10    when request.accept.include?("text/html")
11      # Salida HTML
12      result = Xytline.find("#{id}").to_s
13    when request.accept.include?("text/plain")
14      # Datos del yaml pero enviando como texto plano
15      result = Xytline.find("#{id}").to_yaml
16    else
17      # Solicitud de tipo de contenido no aceptable
18      status 406
19    end
20  rescue
21    # No encuentro dicho registro
22    status 404
23  end
24
25  result
26end 

Depuremos con curl (agregando -v):

$ curl -v -H "Accept: text/yaml" http://localhost:4567/xytlines/3
* About to connect() to localhost port 4567 (#0)
*   Trying 127.0.0.1... connected
* Connected to localhost (127.0.0.1) port 4567 (#0)
> GET /xytlines/3 HTTP/1.1
> User-Agent: curl/7.21.7 (x86_64-redhat-linux-gnu) libcurl/7.21.7 NSS/3.12.10.0 zlib/1.2.5 libidn/1.22 libssh2/1.2.7
> Host: localhost:4567
> Accept: text/yaml
> 
< HTTP/1.1 200 OK
< Connection: close
< Date: Sat, 17 Dec 2011 15:43:41 GMT
< Content-Type: text/html;charset=utf-8
< Content-Length: 99
< 
--- !ruby/object:Xytline 
attributes: 
  x: 1
  y: 2
  id: 3
  q: 100
  t: 3
attributes_cache: {}

* Closing connection #0

Note por favor que la respuesta NO concuerda con lo solicitado por el cliente, text/yaml versus text/html. Para corregir, debemos configurar y definir:

 1configure do
 2  mime_type :yaml, 'text/yaml'
 3  mime_type :plain, 'text/yaml'
 4end
 5
 6# RETRIEVE xytline/:id
 7get %r{/xytlines/([\d]+)} do |id|
 8  # request.accept es Array
 9  request.accept.each {|rh| puts rh}
10  begin
11    case 
12    when request.accept.include?("text/yaml")
13      # Salida yaml
14      content_type :yaml
15      result = Xytline.find("#{id}").to_yaml
16    when request.accept.include?("text/html")
17      # Salida HTML
18      result = Xytline.find("#{id}").to_s
19    when request.accept.include?("text/plain")
20      # Datos del yaml pero enviando como texto plano
21      content_type :plain
22      result = Xytline.find("#{id}").to_yaml
23    else
24      # Solicitud de tipo de contenido no aceptable
25      status 406
26    end
27  rescue
28    # No encuentro dicho registro
29    status 404
30  end
31
32  result
33end 

Confirmemos ahora que la salida es adecuada:

$ curl -v -H "Accept: text/yaml" http://localhost:4567/xytlines/3
* About to connect() to localhost port 4567 (#0)
*   Trying 127.0.0.1... connected
* Connected to localhost (127.0.0.1) port 4567 (#0)
> GET /xytlines/3 HTTP/1.1
> User-Agent: curl/7.21.7 (x86_64-redhat-linux-gnu) libcurl/7.21.7 NSS/3.12.10.0 zlib/1.2.5 libidn/1.22 libssh2/1.2.7
> Host: localhost:4567
> Accept: text/yaml
> 
< HTTP/1.1 200 OK
< Connection: close
< Date: Sat, 17 Dec 2011 15:55:50 GMT
< Content-Type: text/yaml;charset=utf-8
< Content-Length: 99
< 
--- !ruby/object:Xytline 
attributes: 
  x: 1
  y: 2
  id: 3
  q: 100
  t: 3
attributes_cache: {}

* Closing connection #0

sinatra-activerecord-not-found.png (41.5 kB) Guillermo Gómez, 12/14/2011 12:13 pm