Skip to content

Instantly share code, notes, and snippets.

@estebandlp
Forked from gabmontes/ExpressABM.md
Created May 22, 2021 23:06
Show Gist options
  • Save estebandlp/93be138ef1f97a68f2349d0d0e943c09 to your computer and use it in GitHub Desktop.
Save estebandlp/93be138ef1f97a68f2349d0d0e943c09 to your computer and use it in GitHub Desktop.
Creación de un ABM (CRUD) básico con Express

ABM con Express

Objetivos

  • Crear servicios web para dar soporte a una aplicación de ABM

Estructura

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:

  1. El navegador solicita los distintos componentes del sitio web, páginas HTML, archivos de estilo, imágenes y scripts
  2. El servidor responde con el contenido estático correspondiente
  3. El navegador crea y ejecuta la aplicación cliente
  4. El cliente solicita la lista de libros a través de un servicio web
  5. El servidor responde con la lista de libros
  6. El cliente muestra la lista de libros
  7. El cliente crea/modifica/elimina un libro a través del servicio web correspondiente
  8. 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.

Interfaz de servicios web

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

Aplicación base

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.

Listado de libros

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:

http://localhost/libros

Objecto Response

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 JSON
  • res.send(dato) envía datos genéricos (buffer, string, objeto)
  • res.status(código) define el código HTTP de la respuesta
  • res.end() termina un pedido sin enviar datos
  • res.redirect([código,] ruta) fuerza al cliente a redirigirse a la ruta especificada y por defecto responde con estado 302

Códigos de error HTTP

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:

Códigos 1xx, 2xx y 3xx

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

Códigos 4xx

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

Códigos 5xx

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

Alta de un libro

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
    });
});

Objeto Request

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 (necesita body-parser)
  • req.cookies contiene las cookies recibidas (necesita cookie-parser)
  • req.params contiene propiedades que mapean contra los parámetros especificados en la ruta
  • req.originalURL contiene la URL original del pedido (la URL puede ser alterada por middlewares previos)
  • req.path contiene la ruta al recurso solicitado
  • req.query contiene un objeto con los pares clave/valor del querystring

Listado de un libro específico

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
});

Ejercicios

Ejercicio 1

Completar el middleware de solicitud de un libro específico.

Ejercicio 2

Crear las rutas para borrar y modificar libros

Mejorando la arquitectura: Acceso a datos

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

Ejercicios

Ejercicio 3

Completar la migración de todas las rutas a la nueva arquitectura.

Uso de express.Router

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");
});

Ejercicios

Ejercicio 4

Completar la rearquirectura utilizando express.Router y probar que todo sigue funcionando correctamente.

Ejercicio 5

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.

Consideraciones

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.

Versionado

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.

Mensajes de error complementarios

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"
});

Persistencia con PostgreSQL

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);
    });
}

Consideraciones sobre node-postgres

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.

Ejercicios

Ejercicio 6

Actualizar el resto de las funciones para completar la migración desde la persistencia en memoria a la persistencia en una base PostgreSQL.

Autenticación y autorización

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.

Autenticación con login y sesiones

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.

Cierre de sessión o logout

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();
    });
});

Sesiones permanentes

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
}));

Autorización con middlewares

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) {
    // ...
};

Recursos complementarios

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment