Cómo crear eventos propios para la suscripción de WebHooks

En muchos ejemplos y librerías he visto cómo es posible suscribirse a los diferentes eventos que nos ofrecen plataformas como GitHub, Stripe, Azure DevOps, etc a través de lo que se conoce como WebHook (gancho web). Sin embargo hay ocasiones en las que soy yo la que quiero crearme mis propios eventos y que sean otros los que se enganchen a ellos. En este artículo te cuento cómo crear tu propio emisor de eventos para tus suscriptores a través de WebHooks.

El que genera los eventos

Los WebHooks (ganchos web) utilizan el camino opuesto al esperado: es el servidor el que avisa a los clientes de que algo ha ocurrido en lugar de que el cliente pregunte continuamente si ha ocurrido algo 🙂 Esto permite un ahorro de recursos ya que ni el cliente tiene que “malgastar su tiempo” haciendo llamadas que no le aportan nada, ni el servidor tiene que atenderlas para decir que no tiene nada nuevo que contar al cliente.

Normalmente el código asociado al WebHook tendría una lógica que, en un momento determinado, haría que el evento X se produjera. En este caso voy a mantenerlo lo más simple posible y solo se va a lanzar un evento a los suscriptores cuando haga una llamada a una URL de mi WebHook:

//index.js
const express = require('express');
const routes = require('./routes');

const app = express();

app.use(express.json());
app.use(routes);

var server = app.listen(process.env.PORT || 3000, () => {
    console.log(`Webhook sender is listening at https://${server.address().address}:${server.address().port}`);
});

Por mantener las cosas un poco organizadas, mi archivo index.js simplemente crea un servidor web, con la librería express, y utiliza un segundo archivo llamado routes.js donde tiene las diferentes rutas que tiene expuestas mi servidor:

//routes.js
const { Router } = require('express');
const Controller = require('./controllers/WebHookController');

const routes = Router();

routes.post('/emitevent', Controller.emitEvent);
routes.post('/subscribe', Controller.subscribe);
routes.post('/unsubscribe', Controller.subscribe);

module.exports = routes;

En este lo que hago es definir todos los paths, los cuales utilizarán diferentes funciones definidas en un tercer archivo llamado controllers/WebHookController.js. Esta aplicación tiene tres opciones disponibles:

  • /emitevent: lo utilizo para generar un evento que avise a todos los suscriptores.
  • /subscribe: para que los clientes se suscriban a un evento concreto.
  • /unsubscribe: para eliminar la suscripción.

Por último, en el archivo WebHookController.js está la lógica propia de nuestro WebHook:

//controllers/WebHookController.js
const WebHooks = require('node-webhooks');

//Initialize webhooks module from on-disk databse
var webHooks = new WebHooks({
    db: './webHooksDB.json', //json file that store webhook URLs
    httpSuccessCodes: [200, 201, 202, 203, 204] //optional success http status code
});

var emitter = webHooks.getEmitter();

emitter.on('*.success', function (eventName, statusCode, body) {
    console.log('Success on trigger webHook ' + eventName + ' with status code', statusCode, 'and body', body)
});

emitter.on('*.failure', function (eventName, statusCode, body) {
    console.error('Error on trigger webHook ' + eventName + ' with status code', statusCode, 'and body', body)
});


module.exports = {

    async emitEvent(request, response) {
        console.log(request.body);
        webHooks.trigger('hello', request.body);
        return response.send('event sent');
    },

    subscribe(request, response) {

        console.log(request.body);

        webHooks.add(request.body.eventName, request.body.url).then(function () {
            console.log(`${request.body.url} subscribed to ${request.body.eventName}`);
            return response.status(200).send('ok');
        }).catch(function (err) {
            console.log(err);
            return response.status(500).send('error');
        });
    },

    unsubscribe(request, response) {
        console.log(request.body);

        webHooks.remove(request.body.eventName, request.body.url).then(function () {
            console.log(`${request.body.url} unsubscribed from ${request.body.eventName}`);
            return response.status(200).send('ok');
        }).catch(function (err) {
            console.log(err);
            return response.status(500).send('error');
        });
    }
}

Para este desarrollo estoy utilizando la librería node-webhooks, la cual me facilita el manejo de los suscriptores, así como de los eventos. Cuando inicializo el objeto WebHooks debo indicarle la base de datos dónde se van a gestionar las URLs y eventos que debe manejar. Para este ejemplo he utilizado simplemente un archivo JSON en el disco local de la máquina (esto debería de ser una base de datos). Inicialmente este archivo será únicamente un objeto vacío:

{}

También puedes facilitar cuáles son los códigos de estado de HTTP que para ti significan que la llamada a los suscriptores ha sido satisfactoria. Esto va enlazado con lo que ocurre a continuación, ya que es posible suscribirse a su vez a los eventos success o failure que ocurren cuando el objeto WebHook manda un evento a los clientes suscritos. De esta manera puedo saber si la llamada a estos se ha realizado con éxito o no, además de su respuesta, por si quiero registrar algo en mi sistema o hacer algo con ella.

Por último, tengo las tres funciones que necesito como mínimo para que mi ejemplo funcione: la primera, emitEvent, que hará una llamada de prueba a través método webHooks.trigger con el evento que quiero lanzar, el cuerpo de la llamada, que será de tipo POST, y adicionalmente podría añadirle las cabeceras. Si quisiera suscribir clientes a eventos debo utilizar el método subscribe, el cual utilizará webHooks.add con el nombre del evento y la URL del cliente al que llamaré si ocurre algo (en este ejemplo el único evento por el que se lanzarían llamadas es el llamado hello). Por último unsubscribe solamente elimina de la “base de datos” el cliente que lo solicite, utilizando webHooks.remove.

Probando nuestro WebHook

Para probar que las suscripciones a nuestro WebHook funcionan de manera correcta podemos hacerlo de varias formas. La más sencilla es creando un cliente como este, que se suscribe al evento hello antes de arrancar el servidor:

const express = require('express');
const { Router } = require('express');
const http = require('http');

var app = express();
var routes = Router();

routes.post('/handler', (req, res) => {
    console.log(req.body);
    res.send(`This is what I got: ${JSON.stringify(req.body)}`);
});

app.use(express.json());
app.use(routes);

//subscribe
var data = JSON.stringify({
    url: 'http://localhost:4000/handler',
    eventName: 'hello'
})

var options = {
    host: 'localhost',
    port: 3000,
    path: '/subscribe',
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Content-Length': data.length
    }
};

const req = http.request(options, (res) => {
    console.log(`statusCode: ${res.statusCode}`);

    res.on('data', (d) => {
        process.stdout.write(d)
    });

});

req.on('error', (error) => {
    console.error(error)
})

req.write(data);
req.end();

var server = app.listen(4000, () => console.log(`Client listening on port ${server.address().port}`));

En el momento que ejecuto mi código, primeramente el servidor y después el cliente, la salida por consola del servidor será esta:

Cliente suscrito al evento hello

Por otro lado el cliente simplemente informará de que la llamada al WebHook le ha devuelto un 200 cuando se ha suscrito al evento hello:

En este primer paso, lo que significa es que el archivo webHooksDB.json se habrá actualizado con el evento y las URLs a las que debe avisar en el caso de que este se produzca (en este ejemplo solo una):

{
    "hello": [
        "http://localhost:4000/handler"
    ]
}

Este cliente, a parte de estar suscrito a nuestro WebHook podría estar haciendo otras cosas, como atender otras peticiones de sus propios clientes. Gracias a este sistema se despreocupa de tener que preguntar al servidor si ha ocurrido el evento en cuestión, ya que el servidor se encargará de notificarle cuando este suceda. Para probarlo, haz una llamada de tipo POST a http://localhost:3000/emitevent parecida a la siguiente:

Llamada a través de Postman a /emitEvent

Asegurate de que la llamada tiene como cabecera Content-Type: application/json. Esta lo que hará será invocar al método emitEvent con el cuerpo elegido y este a su vez notificará a todos los suscriptores del evento hello:

Como puedes ver, la notificación a nuestro cliente ha sido satisfactoria, devolviendo este un 200 y como cuerpo de su mensaje lo que ha recibido por nuestra parte.
Si lo vemos desde el lado del cliente, el resultado sería este:

Esta respuesta la ha generado el método asociado al path /handler que es el que indicamos que estaría suscrito si el evento hello ocurre.

Si por ejemplo queremos utilizar este sistema dentro de un flujo, Azure Logic Apps tiene un conector llamado HTTP Webhook, el cual puede suscribirse al nuestro:

Azure Logic App – Conector HTTP WebHook

Y posteriormente quedarse a la espera de que este evento ocurra:

El conector HTTP WebHook permanece a la espera hasta que el evento ocurra

En el momento que lancemos la misma petición a emitEvent, en este caso desplegando nuestro ejemplo en la nube, el propio conector cancelaría la espera y se quedaría como resultado lo recibido por este.

El conector ha finalizado la espera y devuelve el body enviado por el WebHook

El código de ejemplo lo tienes en mi GitHub.

¡Saludos!