- Crear servicios web para dar soporte a una aplicación de ABM
Una aplicación web que permita administrar un tipo de recurso, como por ejemplo una lista de libros, tendrá los siguientes componentes:
- Cliente web
- Vista de lista de libros
- Vista de alta de un libro
- Vista de modificación de un libro (compartida con vista de alta)
- Vista de eliminación de un libro (integrada en vista de lista)
- Servidor
- Servicio del sitio web
- Servicios web de gestión de libros (lista, creación, modificación, borrado)
- Comunicación con base de datos
Los servicios web de gestión de libros son, entonces, la interfaz entre el cliente y el servidor. Por lo tanto, el ciclo completo de trabajo cliente-servidor es el siguiente:
- El navegador solicita los distintos componentes del sitio web, páginas HTML, archivos de estilo, imágenes y scripts
- El servidor responde con el contenido estático correspondiente
- El navegador crea y ejecuta la aplicación cliente
- El cliente solicita la lista de libros a través de un servicio web
- El servidor responde con la lista de libros
- El cliente muestra la lista de libros
- El cliente crea/modifica/elimina un libro a través del servicio web correspondiente
- El servidor procesa el pedido y responde con un resultado
Este tipo de arquitectura deja bien separadas las distintas responsabilidades involucradas en el proceso:
- Servicio de la aplicación cliente
- Administración de los libros
Una de las principales ventajas es que la aplicación cliente podría tomar diferentes formas:
- aplicación web servida por el mismo servidor
- aplicación web servida desde otro servidor (considerar CORS)
- aplicación móvil
- otro sistema que respete la interfaz de servicios web
En sistemas en los cuales se sirven las páginas web con los datos ya embebidos, típico caso de uso de arquitecturas basadas en ASP o PHP, la falta de separación de responsabilidades dificulta la adecuación del sistema para dar soporte a distintos tipos de clientes.
Los servicios web son rutas HTTP definidas en el servidor que, basándose en el método de acceso utilizado y en la ruta misma, permitirán realizar diferentes operaciones sobre los recursos administrados, en este caso de ejemplo, libros. Por lo tanto, para una completa administración de libros deberán existir al menos las siguientes rutas:
Método | Ruta | Objetivo |
---|---|---|
GET |
/libros |
Obtener la lista completa de libros |
POST |
/libros |
Crear un libro |
GET |
/libros/:id |
Obtener un libro por ID |
PUT |
/libros/:id |
Modificar un libro por ID |
DELETE |
/libros/:id |
Eliminar un libro por ID |
Para crear el servicio web que provea el listado de clientes, hay que partir de una aplicación Express base cuyo archivo package.json
contine:
{
"dependencies": {
"express": "^4.13.0"
}
}
El archivo server.js
es donde se crea la aplicación Express base, la cual estará vinculada al puerto 80, puerto por defecto para servir pedidos HTTP:
var express = require("express");
var app = express();
app.listen(80, function () {
console.log("Servidor iniciado");
});
Para dejar lista la aplicación, se deberán instalar las dependencias ejecutando npm install
para luego iniciar el servidor con node server.js
o npm start
.
El listado de libros se debe servir desde la ruta /libros
del métido GET
, por lo tanto, hay que crear un middleware que responda a esa ruta y devuelva todos los libros. Como la conexión con una base de datos se desarrollará más adelante, los libros están representados simplemente como un arreglo de objetos en memoria:
var libros = [];
app.get("/libros", function (req, res, next) {
res.json(libros);
});
Abriendo desde un navegador la dirección de esta ruta se obtendrá la lisa de libros:
El objeto res
, segundo parámetro en cada función middleware es del tipo Response
. Como tal tiene propiedades y métodos útiles para responder un pedido HTTP. Los métodos principales son:
res.json(object)
envía un objeto JSONres.send(dato)
envía datos genéricos (buffer, string, objeto)res.status(código)
define el código HTTP de la respuestares.end()
termina un pedido sin enviar datosres.redirect([código,] ruta)
fuerza al cliente a redirigirse a la ruta especificada y por defecto responde con estado 302
Los códigos de error en el protocolo HTTP/1.1 (RFC 7231) tienen diferentes significados de acuerdo al bloque en el cual se encuentran:
Son todos códigos que representan información o éxito en el pedido/respuesta.
- 100 Continue: los encabezados fueron recibidos y se puede continuar
- 101 Switching protocols: se usa, por ejemplo, en conexiones WebSocket
- 200 OK: es la respuesta exitosa por defecto
- 201 Created: se usa para responder que un recurso fue creado como consecuencia del pedido
- 301 Moved Permanently: redirecciona al cliente informando un cambio permanente
- 302 Found: redirecciona al cliente de forma temporal (HTTP/1.0)
- 304 Not Modified: indica que el recurso solicitado no fue modificado
Son códigos de error devueltos como consecuencia de un problema del lado del cliente.
- 400 Bad Request: el pedido es inválido y no se va a procesar
- 401 Unauthorized: se require autenticación
- 403 Forbidden: no se permite el acceso (aún estando autenticado)
- 404 Not Found: no se encuentra el recurso
Son causados por problemas internos del servidor:
- 500 Internal Server Error: error genérico
- 503 Service Unavailable: el servicio no está disponible
- 504 Gateway Timeout: el servidor no responde y un gateway intermedio lo notifica
El alta de un nuevo libro se realiza haciendo un pedido POST
a la ruta de libros, /libros
. Para ello, hay que crear un middleware que atienda esa ruta, cree el libro basado en los datos enviados y devuelva el ID correspondiente al libro creado.
Express, sobre todo en sus últimas versiones, es un framework minimalista, que solo se encarga de la administración de rutas y middlewares. Para obtener los datos enviados en el cuerpo (body) de un pedido HTTP con facilidad hay que usar un middleware que decodifique los datos enviados y lo presente como un objeto propiedad del objeto req
. El más utilizado es el módulo body-parser
y se lo puede instalar actualizando el archivo package.json
automáticamente al ejecutar npm install --save body-parser
.
Como los middlewares se ejecutan en el orden en el que son agregados a la instancia de Express (app
), éste debe configurarse antes que el resto de las rutas correspondientes a los servicios web.
var bodyParser = require("body-parser");
app.use(bodyParser.json());
Nota: El módulo body-parser
provee varios intérpretes: JSON, buffer, texto y URL-encoded.
Una vez decodificado el cuerpo del perido HTTP, el intérprete crea una propiedad body
en el objeto req
conteniendo el objeto JSON enviado. Este objeto representa el nuevo libro a dar de alta, por lo tanto, deberá ser incorporado a la lista de libros. Sin embargo, previo a incorporarlo, es necesario crear un identificador de este nuevo libro, por ejemplo, llevando un número de secuencia en incrementándolo con cada pedido:
var seq = 0;
app.post("/libros", function (req, res, next) {
var _id = "ID" + seq++;
var libro = req.body;
libro._id = _id;
libros.push(libro);
res.status(201).json({
_id: _id
});
});
El objeto req
, primer parámetro en cada función middleware es del tipo Request
. Como tal tiene propiedades y métodos útiles para consultar datos sobre el pedido HTTP. Las propiedades y métodos principales son:
req.body
contiene el cuerpo decodificado del mensaje recibido (necesitabody-parser
)req.cookies
contiene las cookies recibidas (necesitacookie-parser
)req.params
contiene propiedades que mapean contra los parámetros especificados en la rutareq.originalURL
contiene la URL original del pedido (la URL puede ser alterada por middlewares previos)req.path
contiene la ruta al recurso solicitadoreq.query
contiene un objeto con los pares clave/valor del querystring
Para listar un libro específico se necesita crear un middleware que atienda a una ruta que comience con /libros/
pero que al final contenga el ID del libro, valor que puede ser cualquier ID válido generado previamente por el servidor. Por lo tanto, la ruta tiene al ID como parámetro y se define de esta manera:
app.get("/libros/:id", function (req, res, next) {
var _id = req.params.id;
// buscar entre los libros el que tenga el `_id` solicitado
// responder con dicho objeto
// responder con un error 404 si no se ha encontrado
});
Completar el middleware de solicitud de un libro específico.
Crear las rutas para borrar y modificar libros
Para mejorar la comprensión y mantenibilidad del código, conviene separar las responsabilidades entre el manejo de las rutas y las operaciones de sobre los datos. Asimismo, el agrupar la definición de rutas que trabajan sobre un mismo tipo de datos ayuda a la organización general del servidor.
Para preparar el código para poder integrar un acceso a base de datos, en lugar de mantener la lista de libros en memoria, hay que separar las funciones que trabajan sobre los datos y aislarlas en su propio módulo: datos.js
. Luego, los middlewares utilizarán las funciones provistas por este módulo, el cual tratará a todos los pedidos como asíncronos. De esta manera, el archivo datos.js
quedará definido como:
var seq = 0;
var libros = [];
module.exports = {
libros: {
selectAll: function (callback) {
callback(null, libros);
},
insert: function (libro, callback) {
var _id = "ID" + seq++;
libro._id = _id;
libros.push(libro);
callback(null, _id);
},
select: function (_id, callback) {
var encontrados = libros.filter(function (prod) {
return prod._id === _id;
});
if (!encontrados.length) {
callback(null, null);
return;
}
callback(null, encontrados[0]);
}
// TODO: delete
// TODO: update
}
};
Finalmente, el módulo server.js
deberá cargar el anterior y utilizarlo para realizar las operaciones sobre los libros:
var express = require("express");
var bodyParser = require("body-parser");
var datos = require("./datos");
var app = express();
app.use(bodyParser.json());
app.get("/libros", function (req, res, next) {
datos.libros.selectAll(function (err, libros) {
if (err) {
next(err);
return;
}
res.json(libros);
});
});
// definición del resto de las rutas
Completar la migración de todas las rutas a la nueva arquitectura.
Para agrupar rutas que trabajan sobre el mismo tipo de recursos se puede utilizar express.Router
. Éste crea grupos de rutas que pueden ser montados en cualquier otra ruta base de Express. Entonces, el primer paso es extraer todas las rutas en su propio archivo libros.js
:
var express = require("express");
var datos = require("./datos");
var router = express.Router();
router.get("/", function (req, res, next) {
datos.libros.selectAll(function (err, libros) {
if (err) {
next(err);
return;
}
res.json(libros);
});
});
// resto de las rutas
module.exports = router;
Por último, el archivo server.js
quedará reducido de esta manera:
var express = require("express");
var bodyParser = require("body-parser");
var libros = require("./libros");
var app = express();
app.use(bodyParser.json());
app.use("/libros", libros);
app.listen(80, function () {
console.log("Servidor iniciado");
});
Completar la rearquirectura utilizando express.Router
y probar que todo sigue funcionando correctamente.
Desarrollar una aplicación cliente que deberá los servicios web desarrollados anteriormente. Debe ser servida utilizando express.static
y podrá ser desarrollada utilizando un framework MVC como AngularJS.
Las API que siguen una arquitectura REST tienen algunas características principales:
- Los datos no se mantienen en una sesión. Cada pedido incluye toda la información necesaria para procesarlo.
- Se debe basar en otros protocolos estándares como HTTP con sus verbos y códigos de error, XML, JSON, etc.
En ocasiones es neceasario cambiar parte de una interfaz. En esos casos es recomendable alterar el comportamiento en conjunto con con la URL/URI asociada. Por ejemplo:
GET /v1/productos/:id
GET /v2/productos/:id
Otra opción es que el cliente indique la versión de la API en el encabezado Accept
del pedido HTTP.
Es una buena práctica enviar al cliente una descripción del error de forma tal que sea más sencillo identificar el problema y solucionarlo. Para esto, además del código HTTP correspondiente.
res.status(401).end({
error: "Invalid API key"
});
Dada la rearquitectura de los archivos que componen el servidor, la integración con una base de datos para persistir la información de los libros en lugar de almacenarlos en la memoria privada del proceso Node.js, hay que adaptar principalmente el archivo datos.js
.
A modo de ejemplo, el servidor se integrará con una base de datos PostgreSQL corriendo localmente y con la configuración por defecto. Para realizar operaciones contra la base de datos es necesario instalar un módulo de Node.js que administre las conexiones, ejecute las consultas y devuelva los resultados. El módulo más usado para esto es node-postgres
.
npm install --save pg
Éste módulo tiene como particularidad que maneja automáticamente un pool de conexiones con el servidor de base de datos para mejorar la performance en situaciones de carga. En lugar de establecer una conexión para cada consulta y cerrar la conexión al obtener la respuesta de la base, el módulo administra un grupo de 20 conexiones que son utilizadas en simultáneo para procesar las consultas y las mantiene abiertas por hasta 30 segundos en caso de que alguna de esas conexiones quede inactiva. Ambos parámetros son configurables para adaptar el módulo a las necesidades puntuales de cada caso.
Dentro del archivo datos.js
se debe, entonces, requerir el módulo y crear la cadena de conexión. Por ejemplo, al comienzo del archivo se deben incluir estas dos líneas:
var pg = require('pg');
// formato: postgres://<usuario>:<clave>@<dirección del servidor>:<puerto>/<nombre de la base>
var connString = "postgres://postgres:postgres@localhost/postgres";
Dado que pg
maneja internamente un pool de conexiones, es correcto pedir una de esas conexiones cada vez que es necesario hacer una consulta. Para ello, y considerando que este pedido se realizará en varios lugares de nuestro archivo, es conveniente crear una función que resuman esta tarea:
function getPgClient(callback) {
pg.connect(connString, function (err, client, done) {
if (err) {
done(true);
callback(err);
return;
}
callback(null, client, done);
});
}
La llamada a connect()
devuelve una conexión en la variable client
, la cual se podrá usar para realizar las consultas. El parámetro done
es esencial porque es el que liberará la conexión para ser reutilizada por otras consultas. En el caso de error, se debe llamar a done()
con un valor verdadero para que pg
elimine esa conexión del pool.
De la misma manera, la tarea de ejecutar una consulta y manejar el caso error es repetitiva y conviene crear otra función que se usará en el resto del módulo:
function queryPg(query, callback) {
getPgClient(function (err, client, done) {
if (err) {
callback(err);
return;
}
client.query(query, function (err, results) {
if (err) {
done(true);
callback(err);
return;
}
done();
callback(null, results);
});
});
}
Utilizando las funciones anteriores, es posible reestructurar el código de datos.js
de la siguiente manera la función para consultar todos los libros existentes:
selectAll: function (callback) {
queryPg("SELECT * FROM libros", function (err, results) {
if (err) {
callback(err);
return;
}
callback(null, results.rows);
});
}
Para la creación de un nuevo libro, es necesario parametrizar la consulta. Para ello, es necesario actualizar la función genérica de consulta para poder aceptar un array de parámetros:
function queryPgParams(query, params, callback) {
getPgClient(function (err, client, done) {
if (err) {
callback(err);
return;
}
client.query(query, params, function (err, results) {
if (err) {
done(true);
callback(err);
return;
}
done();
callback(null, results);
});
});
}
function queryPg(query, callback) {
queryPgParams(query, [], callback);
}
Así, la función de inserción podrá ser actualizada de la siguiente manera:
insert: function (libro, callback) {
var query = "INSERT INTO libros (titulo, autor, precio) VALUES ($1, $2, $3) RETURNING _id";
queryPgParams(query, [libro.titulo, libro.autor, libro.precio], function (err, results) {
if (err) {
callback(err);
return;
}
callback(null, results.rows[0]._id);
});
}
Cuando se trabaja con transacciones, es muy importante ejecutar las consultas client.query('COMMIT')
o client.query('ROLLBACK')
antes de llamar a done()
. Caso contrario esa conexión se reutilizará en otra consulta estando la transacción abierta.
Respecto del problema de inyección de codigo SQL, node-posgres
filtra automáticamente los parámetros de las consultas parametrizadas para evitar daños o accesos no deseados a los datos.
El módulo node-postgres
tiene una API basada en callbacks. Para aprovechar una interfaz basada en promesas sobre node-progress
es recomendable utilizar, por ejemplo, el módulo pg-promise
o la función promisify()
o promisifyAll()
de bluebird
.
Actualizar el resto de las funciones para completar la migración desde la persistencia en memoria a la persistencia en una base PostgreSQL.
Para restringir el uso de los servicios, es necesario implementar dos aspectos de seguridad informática: autenticación y autorización. El primer aspecto, autenticación, es el mecanismo por el cual se identifica a un usuario del sistema. La forma más simple es a través de el ingreso de usuario y clave. Una vez autenticado el usuario, el segundo aspecto entra en juego: autorización. Éste es el mecanismo por el cual, una vez conocido el usuario que realiza la consulta, a éste se le acepta o no la consulta.
Para recibir de forma segura en el servidor las credenciales del usuario en el esquema propuesto anteriormente, es necesario que el cliente haga un pedido POST sobre HTTPS con el nombre de usuario y clave correspondientes. Para ello, es necesario que express
atienda una ruta con un middleware como el siguiente:
app.post("/login", function (req, res, next) {
var user = req.body.user;
var pass = req.body.pass;
validateLogin(user, pass, function (err, userData) {
if (err) {
next(err);
return;
}
if (userData) {
req.session.user = userData;
res.end();
} else {
res.status(401).end();
}
});
});
La función validarLogin
debe consultar la base de usuarios y, si la combinación usuario/clave se encuentra, es conveniente devolver los datos esenciales del mismo para mantenerlos en sesión y poder ser utilizado en otros pedidos HTTP fácilmente.
Por otro lado, el objeto sesión, req.session
, es provisto por el middleware express-session
. Este middleware se encarga de la creación y almacenamiento de sesiones, como así también de identificar la sesión asociada a cada pedido HTTP y actualizar los datos de la sesión al completar dicha solicitud.
Dado que HTTP es un protocolo stateless, es decir que a nivel protocolo no se mantiene un estado común de un pedido a otro, este estado es necesario mantenerlo por afuera del protocolo a través de sesiones y cookies que identifican la sesión, las cuales son enviadas al cliente y recibidas en cada pedido subsiguiente.
El middleware express-session
utiliza estas cookies para encontrar nuevamente la sesión correspondiente al usuario que realizó el pedido y llenar con estos datos el objeto req.session
. express-session
tiene muchas opciones de configuración. Algunas para destacar son:
- cookie.maxAge define el tiempo de vida de la sesión en milisegundos. Pasado ese tiempo, la sesión expira y el usuario debe volver a autenticarse.
- resave fuerza a que la sesión se grabe al responder el pedido HTTP, más allá de que no haya sido modificada. Es conveniente que esté en
false
. - rolling fuerza responder con una cookie cada pedido y, al mismo tiempo, extiende el tiempo de vida de la sesión. Es conveniente que esté en
true
. - saveUninitialized fuerza a guardar nuevas sesiones aún si no fueron inicializadas. Es conveniente que esté en
false
, sobre todo en la implementación de flujos de login. - secret es una cadena de texto utilizada para firmar criptográficamente la cookie enviada al cliente y decifrar las recibidas y así validad su autenticidad.
Así como la autenticación consta de identificar a un usuario, obtener los datos de sesión y transmitir entre cliente y servidor un cookie contienendo el ID de dicha sesión, para cerrar la sesión solo hace falta destruir esa sesión del lado del servidor, invalidando el ID de sesión de forma de forzar al usuario a autenticarse nuevamente para obtener un nuevo ID.
El siguiente middleware es lo básicio que hay que realizar para lograr el cierre de sesión:
app.post("/logout", function (req, res, next) {
req.session.destroy(function (err) {
if (err) {
next (err);
return;
}
res.end();
});
});
El módulo express-session
mantiene las sesiones en memoria, en un almacén llamado MemStore
. Este almacén está pensado solo para desarrollo o para aplicaciones muy simples, pero sobre todo como base para mostrar la implementación de la interfaz que otros almacenes deben implementar.
El módulo connect-pg-simple
implementa una interfaz compatible con un almacen para ser utilizado con express-session
. Para utilizarlo se debe crear una tabla session
con el script provisto y luego configurar Express para que utilice el módulo:
var session = require("express-session");
var connectPg = require("connect-pg-simple");
var pgSession = connectPg(session);
var pg = require('pg');
app.use(session({
store: new pgSession({
pg: pg,
conString: "<connection string>"
}),
// otros parámetros para express-session
}));
Al definir una ruta de express
es posible incluir varios middlewares. Éstos van a ser ejecutados en el orden en el que fueron definidos, siempre y cuando cada uno vaya llamando a la función next()
para pasar el control al siguiente. Por lo tanto, una forma de restringir el acceso a determinadas rutas es incluir un middleware genérico que valide, por ejemplo, que el usuario se haya autenticado antes de proseguir:
function isUserAuth(req, res, next) {
if (!req.session.user) {
res.status(401).end({
error: "Usuario no autenticado"
});
return;
}
next();
}
Este middleware se debe, por lo tanto, incluir en la lista de middlewares que atienden una ruta determinada, por ejemplo para crear un nuevo libro o producto:
router.post("/", isUserAuth, function (req, res, next) {
// ...
};
- Documentación de la API de Express 4.x
- Códigos de error HTTP en Wikipedia
- Repositorio de
body-parser
en GitHub - Documentación sobre routing en Express 4.x
- Preguntas frecuentes del módulo node-postgres
- Ejemplo de implementación de un login en una aplicación AngularJS.