Construyendo un API RESTFul con IO.js y ECMAScript6

Sigue mi nueva serie de vídeo-tutoriales en Youtube y aprende a crear desde cero un API REST con Node.js , MongoDB y ECMAScript 2015

Hace más de un año publiqué en este blog un tutorial de cómo crear una API REST en Node.js con MongoDB. Es uno de los artículos más visitados de este blog, he incluso realicé un videotutorial sobre ello, y lo actualicé con la versión 4 de Express.

Despues de todo este tiempo, tocaba renovar el tutorial y no se me ocurre mejor actualización que utilizar ECMAScript 6 y IO.js para implementarlo.

Como posiblemente sabrás, IO.js es un fork de Node.js nacido para implementar las últimas novedades de ECMAScript y la máquina V8 en la que está basada Node.js para poder utilizar JavaScript en el servidor. Después de varios tiras y aflojas IO.js y Node.js confluirán en la versión 3.0 de IO.js y serán el mismo proyecto. Puedes considerar a IO.js la v2 de Node.js

Índice

  1. Requisitos
  2. Diseño del API
  3. Estructura de Archivos
  4. Instalando dependencias
  5. Desarrollo del API
  6. Ejecución y pruebas

Requisitos

SPOILER ALERT: En el siguiente enlace de GitHub tienes el repositorio del proyecto.

Lo primero que necesitamos hacer es instalar io.js en nuestro equipo. Lo vamos a hacer con NVM (Node Version Manager) Podemos instalar NVM con wget o curl:

$ curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.25.4/install.sh | bash
$ wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.25.4/install.sh | bash

Despues ya podemos instalar la última versión estable que es la 2.3.1

$ nvm install v2.3.1
$ nvm use v2.3.1

También necesitaremos instalar Babel para traducir ES6 a ES5, ya que algunas novedades de ES6 aún no están implementadas en IO.js, como por ejemplo los módulos y los import, IO.js sigue usando CommonJS con require.

$ npm install -g babel

Diseño del API

Vamos a implementar un API RESTful, lo que significa que haremos uso de todos los métodos de HTTP, para poder leer, escribir, actualizar y eliminar (POST, GET, PUT y DELETE).

HTTP verbs

También tenemos que responder con un código a cada petición para indicar al cliente si la respuesta ha sido correcta o ha ocurrido algún tipo de error.

Estos son los códigos más habituales:

HTTP Response codes

Vamos a crear un API que nos permita crear empleados y acceder a ellos. Algo como lo que hemos utilizado en los tutoriales de React, pero esta vez visto desde el lado del backend. Estos serán los métodos que utilizaremos y la URL de cada uno:

  • POST /employees - Crea un nuevo empleado
  • GET /employees - Devuelve una lista de todos los empleados
  • GET /employees/:id - Devuelve la información de un determinado empleado
  • PUT /employees/:id - Actualiza la información de un determinado empleado
  • DELETE /employees/:id - Elimina el empleado

Podríamos utilizar un framework completo como Express, Koa, Hapi, Restify, etc.. para desarrollar nuestra API, pero me he propuesto crear una desde 0, sin utilizar grandes frameworks, si no usar JavaScript puro y librerías pequeñas que resuelvan cosas concretas. Esto nos permite no depender exclusivamente de un framework y a la larga es mejor.

Los registros se almacenarán en MongoDB, una base de datos NoSQL.


Estructura de archivos

Esta va a ser la estructura de archivos y carpetas que tendremos en nuestro API, de manera que nos resulte sencilla de mantener y ampliar:

- package.json
- index.js
- node_modules/
- lib/
	- employees/
    	- index.js
        - model.js
    - router/
    	- index.js
    - utils/
    	- helpers.js
        - logger.js

Como siempre, package.json contiene las dependencias que vamos a utilizar en nuestra aplicación así como información del proyecto, etc...

  • index.js será nuestro fichero de arranque, en él definiremos nuestro servidor y la conexión con la base de datos.

  • node_modules, como todo proyecto de Node/IO, contiene las dependencias que instalamos vía npm y que después importaremos en nuestro proyecto

  • lib será la carpeta que contiene la lógica de nuestra aplicación, en ella tenemos 3 directorios importantes:

  • employees es el recurso que vamos a tener en nuestro API, dentro de el definiremos el modelo de datos con mongoose en el fichero model.js y en index.js los controladores que buscarán en la base de datos (MongoDB) los recursos y los manipularán. Si tuviéramos más entidades, como por ejemplo usuarios, tendríamos otro directorio dedicado a su modelo y controladores.

  • router es la carpeta que contiene las rutas del API.

  • En el directorio utils tendremos funciones que reutilizaremos en otras partes de la aplicación, con el fin de no repetir código.


Instalando dependencias

Como digo más arriba, voy a prescindir de Express u otro framework MVC para Node/IO. Vamos a utilizar pequeñas librerías que resuelvan cosas concretas.

Utilizaremos course para el manejo de rutas y métodos HTTP, body/json para parsear el body de las peticiones POST y PUT, mongoose para la conexión con la base de datos y la creación de modelos y winston para mostrar los logs de la aplicación por consola. En el campo de DevDependencies instalaremos babel para traducir el ES6 que no entienda IO.js

Creamos pues el package.json con el comando npm init y procedemos a instalar las dependencias:

$ npm install --save body@5.1.0
$ npm install --save course@0.0.1
$ npm install --save mongoose@4.0.6
$ npm install --save winston@1.0.0
$ npm install --save-dev babel@5.6.7

Desarrollo del API

Punto de entrada

Empezamos a crear el servidor que servirá (valga la redundancia) el API REST. Implementamos pues nuestro index.js:

'use strict'

import http from 'http'
import mongoose from 'mongoose'
import router from './lib/router'

const server    = http.createServer()
const port      = process.env.PORT || 3000
const database  = process.env.MONGO_URL || 'mongodb://localhost/directory'

mongoose.connect(database, onDBConnect)
server.on('request', router)
server.on('listening', onListening)

function onDBConnect (err, res) {
  if (err) console.log(`ERROR: on connecting to database, ${err}`)
  else {
    console.log(`Connection established to Database`)
    server.listen(port)
  }
}

function onListening () {
  console.log(`Server listening on http://localhost:${port}`)
}

Básicamente importamos las dependencias de http, mongoose y la que en breve implementaremos de lib/router

Cremos el servidor con http.createServer() y nos conectamos a la base de datos con mongoose.connect dónde, dentro de su callback, iniciamos el servidor con server.listen().

Haciendo uso de EventEmitter de Node.js/io.js, escuchamos los eventos de request y listening para llamar a su respectiva función. Para las rutas, cada vez que ocurra el evento request llamaremos a router, el cual implementaremos a continuación en lib/router/index.js

Implentación de los end-points

Primero importamos los módulos de course, nuestra funciónn de logger y el controlador de las rutas que posteriormente implementaremos EmployeeController:

'use strict'

import course from 'course'
import EmployeeController from '../employee'
import logger from '../utils/logger'

const router = course()
const employeeCtrl = new EmployeeController()

Creamos un middleware para todas las rutas con la función req.all donde especificamos el statusCode por defecto, la cabecera de Content-Type y la versión del API con x-ver:

router.all((req, res, next) => {
  logger.info(req.method, req.url)
  res.statusCode = 200
  res.setHeader('Content-Type', 'application/json')
  res.setHeader('x-ver', '1.0')
  next()
})

A continuación implementamos las rutas para GET, POST, PUT, y DELETE con su respectivo controlador que desarrollaremos a continuación:

router.get('/', (req, res) => {
  res.end('Welcome to Employee API REST')
})

router.get('/employees',                employeeCtrl.getAll)
router.get('/employees/:employeeId',    employeeCtrl.get)
router.post('/employees',               employeeCtrl.save)
router.delete('/employees/:employeeId', employeeCtrl.remove)
router.put('/employees/:employeeId',    employeeCtrl.update)

Para el resto de rutas, se supone que no están definidas, por tanto debemos mostrar un código de error y un mensaje de que no existe

function onRequest (req, res) {
  router(req, res, (err) => {
    if (err) return fail(err, res)

    res.statusCode = 404
    res.setHeader('Content-Type', 'text/plain')
    res.end(`404 Not Found: ${req.url}`)
  })
}

Finalmente exportamos la función, para poder utilizarla en index.js:

export default onRequest

Finalmente, el fichero lib/router/index.js al completo será el siguiente:

'use strict'

import course from 'course'
import EmployeeController from '../employee'
import logger from '../utils/logger'

const router = course()
const employeeCtrl = new EmployeeController()

router.all((req, res, next) => {
  logger.info(req.method, req.url)
  res.statusCode = 200
  res.setHeader('Content-Type', 'application/json')
  res.setHeader('x-ver', '1.0')
  next()
})

router.get('/', (req, res) => {
  res.end('Welcome to Employee API REST')
})

router.get('/employees',                employeeCtrl.getAll)
router.get('/employees/:employeeId',    employeeCtrl.get)
router.post('/employees',               employeeCtrl.save)
router.delete('/employees/:employeeId', employeeCtrl.remove)
router.put('/employees/:employeeId',    employeeCtrl.update)

function onRequest (req, res) {
  router(req, res, (err) => {
    if (err) return fail(err, res)

    res.statusCode = 404
    res.setHeader('Content-Type', 'text/plain')
    res.end(`404 Not Found: ${req.url}`)
  })
}

export default onRequest

Controladores

Ahora vamos a implementar los controladores de cada ruta. Como has podido observar, tenemos cinco rutas o endpoints para nuestro API, y cada una de ellas es manejada por una función:

router.get('/employees',                employeeCtrl.getAll)
router.get('/employees/:employeeId',    employeeCtrl.get)
router.post('/employees',               employeeCtrl.save)
router.delete('/employees/:employeeId', employeeCtrl.remove)
router.put('/employees/:employeeId',    employeeCtrl.update)

Por tanto, vamos a crear un nuevo fichero dentro de la carpeta employees que llamaremos index.js y será una clase con 5 métodos, cada uno de ellos controlará una ruta. Primero importamos la librería body/json que nos permitirá obtener los datos que recibamos en el body de una petición, y también el modelo de datos, que después implementaremos como Schema de mongoose:

import jsonBody from 'body/json'
import Employee from './model'

Creamos la clase EmployeeController con los métodos que usamos en router/index.js:

class EmployeeController {
	getAll(req, res) { ... }
    get(req, res) { ... }
    save(req, res) { ... }
    remove(req, res) { ... }
    update(req, res) { ... }
}

A continuación pasamos a implementar cada método

GET /employees

Usando el modelo Employee que es un Schema de mongoose, podemos emplear los métodos que provee mongoose para acceder a la base de datos y realizar operaciones en ella. Por tanto, la ruta GET /employees nos ha de devolver todos los objetos Empleado que haya en la base de datos, con un formato de mensaje como el siguiente:

{
	"message": "OK",
    "data": [
    	{
        	"_id": "558bf7ba690df074fdc8ef0b",
            "fullName": "Bill Gates",
			"picture": "http://images.com/billgates.jpg"
        },
        {
        	"_id": "558bf7c5690df074fdc8ef0c",
			"fullName": "Steve Jobs",
			"picture": "http://images.com/steve.jpg"
        },
        ...
    ]
}

Para conseguir esta respuesta, debemos buscar en la colección employees todos los documentos que existan, que nos devuelva únicamente los campos fullName y picture, y cuando se resuelva la promesa, devolver una respuesta a la petición con el código 200 de que todo fue bien y un objeto JSON con el mensaje de OK y el array de objetos Empleado buscados:

// GET /employees
getAll(req, res) {
	Employee
      .find({}, 'fullName picture')
      .then((employees) => {
        res.statusCode = 200
        res.end(JSON.stringify({
        	message: "OK",
            data: employees
        }))
      })
      .catch((err) => {
      	res.statusCode = 500
  		res.setHeader('Content-Type', 'text/plain')
  		res.end(err.message)
      })
  }
}

Para que el cliente pueda recibir los datos correctamente, deben ir en formato JSON, para ello hacemos uso del método JSON.stringify para codificar la respuesta. Cómo es algo que vamos a reutilizar, puede resultar interesante crear una función y tenerla en un módulo aparte para poder ser usada siempre que la necesitemos. También retuilizaremos la respuesta al error creando una función fail. Por tanto creamos el archivo utils/helpers.js y escribimos lo siguiente:

'use strict'

function fail (err, res) {
  res.statusCode = 500
  res.setHeader('Content-Type', 'text/plain')
  res.end(err.message)
}

function jsonfy (message, data) {
  return JSON.stringify({
    message : message,
    data    : data
  })
}

export { fail, jsonfy }

De esta manera nuestra función getAll(req, res) quedaría así:

getAll(req, res) {
    Employee
      .find({}, 'fullName picture')
      .then((employees) => {
        res.statusCode = 200
        res.end(jsonfy('OK', employees))
      })
      .catch((err) => fail(err, res))
}
POST /employees

Implementaremos a continuación la función POST que nos permite crear un nuevo registro (en este caso un nuevo Empleado) en la base de datos. También haremos uso del modelo y sus funciones de mongoose.

En este caso, haremos uso de body/json para poder parsear el contenido de la petición, y de la función create de Mongoose

// POST /employees
save(req, res) {
    jsonBody(req, res, (err, body) => {
      if (err) return fail(err, res)

      Employee
        .create(body)
        .then((employee) => {
          res.statusCode = 201
          res.end(jsonfy('OK', employee))
        })
        .catch((err) => fail(err, res))
    })
  }
GET /employees/:employeeId

Ahora vamos a implementar la petición GET de un sólo empleado. Debemos buscar en la base de datos con el parámetro que indiquemos en el endpoint. En este caso será :employeeId que no es más que el _id con el que MongoDB almacena los documentos. Para poder acceder a ese valor lo recogemos en this.employeeId y para poder buscar uno concreto lo hacemos con el método findById:

get(req, res) {
    let employeeId = this.employeeId

    Employee
      .findById(employeeId)
      .then((employee) => {

        if(employee) {
          res.statusCode = 200
          res.end(jsonfy('OK', employee))
        } else {
          res.statusCode = 404
          res.end(jsonfy(`Employee ${employeeId} does not exists`))
        }

      })
      .catch((err) => fail(err, res))
  }
PUT /employee/:employeeId

En toda API RESTful que se precie, debemos tener la opción de actualizar nuestros registros. Este controlador es una mezcla entre un GET id y un POST, ya que debemos hacer uso de body/json para parsear el contenido de la petición y una búsqueda por id. Mongoose nos da la función findOneAndUpdate para ello.

update(req, res) {
    let employeeId = this.employeeId

    if (!employeeId) {
      res.statusCode = 404
      return next()
    }

    jsonBody(req, res, (err, body) => {
      if (err) return fail(err, res)
      let updatedEmployee = body

      Employee
        .findOneAndUpdate({ _id: employeeId }, updatedEmployee)
        .then((employee) => {
          res.statusCode = 200
          res.end(jsonfy(`Employee ${employeeId} updated succesfully`, employee))
        })
        .catch((err) => fail(err, res))
    })
  }
DELETE /employee/:employeeId

Por último implementamos la función de borrado de un registro de la base de datos. Para ello usaremos el método findOneAndRemove de mongoose que nos lo facilita.

remove(req, res, next) {
    let employeeId = this.employeeId

    if (!employeeId) {
      res.statusCode = 404
      return next()
    }

    Employee
      .findOneAndRemove({ _id: employeeId })
      .then(() => {
        res.statusCode = 204
        res.end(jsonfy(`Employee ${employeeId} deleted succesfully`))
      })
      .catch((err) => fail(err, res))
  }

Con estos 5 métodos tendríamos nuestro controlador terminado. El fichero lib/employees/index.js al completo sería así:

'use strict'

import jsonBody from 'body/json'
import { fail, jsonfy } from '../utils/helpers'
import Employee from './model'

class EmployeeController {

  // --  GET /employees -------------------------------

  getAll(req, res) {

    Employee
      .find({}, 'fullName picture')
      .then((employees) => {
        res.statusCode = 200
        res.end(jsonfy('OK', employees))
      })
      .catch((err) => fail(err, res))
  }

  // -- GET /employees/:employeeId ------------------

  get(req, res) {
    let employeeId = this.employeeId

    Employee
      .findById(employeeId)
      .then((employee) => {

        if(employee) {
          res.statusCode = 200
          res.end(jsonfy('OK', employee))
        } else {
          res.statusCode = 404
          res.end(jsonfy(`Employee ${employeeId} does not exists`))
        }

      })
      .catch((err) => fail(err, res))
  }

  // -- POST /employees ------------------------------

  save(req, res) {

    jsonBody(req, res, (err, body) => {
      if (err) return fail(err, res)

      Employee
        .create(body)
        .then((employee) => {
          res.statusCode = 201
          res.end(jsonfy('OK', employee))
        })
        .catch((err) => fail(err, res))
    })
  }

  // -- DELETE /employees/:employeeId ---------------

  remove(req, res, next) {
    let employeeId = this.employeeId

    if (!employeeId) {
      res.statusCode = 404
      return next()
    }

    Employee
      .findOneAndRemove({ _id: employeeId })
      .then(() => {
        res.statusCode = 204
        res.end(jsonfy(`Employee ${employeeId} deleted succesfully`))
      })
      .catch((err) => fail(err, res))
  }

  // -- PUT /employees/:employeeId --------------------

  update(req, res) {
    let employeeId = this.employeeId

    if (!employeeId) {
      res.statusCode = 404
      return next()
    }

    jsonBody(req, res, (err, body) => {
      if (err) return fail(err, res)
      let updatedEmployee = body

      Employee
        .findOneAndUpdate({ _id: employeeId }, updatedEmployee)
        .then((employee) => {
          res.statusCode = 200
          res.end(jsonfy(`Employee ${employeeId} updated succesfully`, employee))
        })
        .catch((err) => fail(err, res))
    })
  }
}

export default EmployeeController

Modelo/Schema

En nuestro controlador hemos hecho uso de Empleado que es un Schema de Mongoose que modela la entidad que utilizamos en nuestro API. En él definimos que campos o atributos va a tener y de que tipo. Creamos entonces el fichero lib/employees/model.js donde definimos el Schema con los 5 campos que tiene cada Empleado, su nombre, su imagen, departamento, título y teléfono. Todos de tipo string

'use strict'

import mongoose from 'mongoose'

const employeeSchema = new mongoose.Schema({
  fullName  : { type: String },
  picture   : { type: String },
  department: { type: String },
  title     : { type: String },
  phone     : { type: String }
})

let Employee = mongoose.model('Employee', employeeSchema)

export default Employee

Y listo! ya tendríamos nuestro API RESTful. ¿Cómo lo probamos?


Ejecución y pruebas

Para poder probar nuestro API sin necesidad de implementar un Frontend, tenemos la herramienta web Postman Que nos permite ingresar las URL de nuestra API y elegir los métodos a probar (GET, POST, PUT y DELETE).

Primero arrancamos nuestro servidor API, simplemente usamos el comando $ babel-node index.js, situándonos en el directorio del proyecto. Despues en http://localhost:3000 tendremos nuestro API corriendo

Prueba de POST

Prueba de POST

Primero testearemos si el método POST funciona correctamente. Indicamos que queremos hacer un POST a la url http://localhost:3000/employees y en la pestaña Body, indicamos la opción Raw y ponemos el siguiente contenido como cuerpo de la petición:

{
  "fullName":"Steve Jobs",
  "picture":"http://images.com/steve.jpg",
  "department":"Marketing",
  "title": "CEO", 
  "phone":"667-667-667"
}

Enviamos, con SEND y si todo sale bien, tendremos la siguiente respuesta:

{
	"message": "OK",
    "data": {
    	{
          "_id": "558bf7c5690df074fdc8ef0c",
          "__v": 0,
          "fullName":"Steve Jobs",
          "picture":"http://images.com/steve.jpg",
          "department":"Marketing",
          "title": "CEO", 
          "phone":"667-667-667"
		}
    }
}

Podemos probar a insertar otro registro para posteriormente probar el método GET.

Prueba de GET

Despues de insertar varios registros, vamos a probar los GET, primero el que devuelve todos con GET http://localhost:3000/employees, deberíamos obtener una respuesta similar a esta:

{
  "message": "OK",
  "data": [
    {
      "_id": "558bf7ba690df074fdc8ef0b",
      "fullName": "Bill Gates",
      "picture": "http://images.com/billgates.jpg"
    },
    {
      "_id": "558bf7c5690df074fdc8ef0c",
      "fullName": "Steve Jobs",
      "picture": "http://images.com/steve.jpg"
    },
    {
      "_id": "558bf842690df074fdc8ef0d",
      "fullName": "Sergei Brin",
      "picture": "http://images.com/sbrin.jpg"
    },
    {
      "_id": "558bfc040fc9284ffe9b7ca9",
      "fullName": "Jeff Bezos",
      "picture": "http://images.com/bezos.jpg"
    }
  ]
}

Y si pedimos un objeto en concreto, por ejemplo: GET http://localhost:3000/employees/558bf7ba690df074fdc8ef0b ésta sería la respuesta:

{
  "message": "OK",
  "data": {
    "_id": "558bf7ba690df074fdc8ef0b",
    "fullName": "Bill Gates",
    "picture": "http://images.com/billgates.jpg",
    "department": "Business",
    "title": "CEO",
    "phone": "666-666-666",
    "__v": 0
  }
}

Los métodos PUT y delete funcionan similar.

En este enlace de github he colgado el repositorio de este proyecto. Siéntete libre de hacerle fork para estudiarlo y aprender y estás totalmente invitada/o a ampliarlo y/o mejorarlo.


¿Quieres contactar conmigo personalmente? Puedes hacerlo por email a través de Earn.com. Tiene asociado un coste de $20, qué sólo se te cobrará cuando responda.

Desarrollador web Frontend y apasionado de JavaScript. Aquí te enseño todo lo que aprendo y conozco sobre JavaScript y la programación web en general.

¿Te gusta lo que lees?
Apúntate a mi boletín, newsletter, lista de correo o como quieras llamarlo. Sólamente envío 1 email al mes con lo más relevante. ¿Te apuntas?